/** * Hashline utilities — core logic for hash-anchored edits. * * Supports both Bun (xxHash32) and Node (FNV-1a fallback) environments. */ import { HASHLINE_DICT, HASHLINE_REF_PATTERN } from "./hashline-constants.js"; // Package-local copy — no dependency on ~/.pi/agent/extensions/shared/* // ── Hash Computation ─────────────────────────────────────────────────── const RE_SIGNIFICANT = /[\p{L}\p{N}]/u; /** FNV-1a 32-bit hash — used as fallback when Bun.xxHash32 is unavailable. */ function fnv1a32(input: string, seed = 0): number { let hash = 0x811c9dc5 ^ seed; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); } return hash >>> 0; } function computeHash32(normalizedContent: string, seed: number): number { // Bun provides a fast xxHash32 implementation const bun = (globalThis as Record).Bun as | { hash?: { xxHash32?: (s: string, seed: number) => number } } | undefined; if (bun?.hash?.xxHash32) { return bun.hash.xxHash32(normalizedContent, seed); } return fnv1a32(normalizedContent, seed); } function computeNormalizedLineHash( lineNumber: number, normalizedContent: string, ): string { const stripped = normalizedContent; const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber; const hash = computeHash32(stripped, seed); const index = hash % 256; return HASHLINE_DICT[index]; } /** Compute the 2-char hash ID for a line. */ export function computeLineHash(lineNumber: number, content: string): string { return computeNormalizedLineHash( lineNumber, content.replace(/\r/g, "").trimEnd(), ); } /** Format a single line as `LINE#HASH|content`. */ export function formatHashLine(lineNumber: number, content: string): string { if (content.trim().length === 0) { return `${lineNumber}|${content}`; } const hash = computeLineHash(lineNumber, content); return `${lineNumber}#${hash}|${content}`; } /** Format an entire file's content as hashlines. * @param startLine 1-based line number of the first line in `content`. Defaults to 1 * so that whole-file reads are unchanged. Pass the absolute start line for partial * reads so that the returned tags are directly usable with `hashline_edit`. */ export function formatHashLines(content: string, startLine = 1): string { if (!content) return ""; const lines = content.split("\n"); return lines .map((line, index) => formatHashLine(startLine + index, line)) .join("\n"); } // ── Validation ───────────────────────────────────────────────────────── export interface LineRef { line: number; hash: string; } interface HashMismatch { line: number; expected: string; } const MISMATCH_CONTEXT = 2; export class HashlineMismatchError extends Error { readonly remaps: ReadonlyMap; constructor(mismatches: HashMismatch[], 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; } static formatMessage( mismatches: HashMismatch[], fileLines: string[], ): string { const mismatchByLine = new Map(); for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch); const displayLines = new Set(); for (const mismatch of mismatches) { const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT); const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT); for (let line = low; line <= high; line++) displayLines.add(line); } const sortedLines = [...displayLines].sort((a, b) => a - b); const output: string[] = []; output.push( `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` + "Use updated {line_number}#{hash_id} references below (\u003e\u003e\u003e marks changed lines).", ); output.push(""); let previousLine = -1; for (const line of sortedLines) { if (previousLine !== -1 && line > previousLine + 1) { output.push(" ..."); } previousLine = line; const content = fileLines[line - 1] ?? ""; const prefix = formatHashLine(line, content); if (mismatchByLine.has(line)) { output.push(`\u003e\u003e\u003e ${prefix}`); } else { output.push(` ${prefix}`); } } return output.join("\n"); } } /** Normalize a line reference string (strip noise, extract LINE#ID). */ export function normalizeLineRef(ref: string): string { const originalTrimmed = ref.trim(); let trimmed = originalTrimmed; trimmed = trimmed.replace(/^(?:\u003e\u003e\u003e|[+-])\s*/, ""); trimmed = trimmed.replace(/\s*#\s*/, "#"); trimmed = trimmed.replace(/\|.*$/, ""); trimmed = trimmed.trim(); if (HASHLINE_REF_PATTERN.test(trimmed)) { return trimmed; } const extracted = trimmed.match(/([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/); if (extracted) { return extracted[1]; } return originalTrimmed; } /** Parse a line reference into { line, hash }. Throws on invalid format. */ export function parseLineRef(ref: string): LineRef { const normalized = normalizeLineRef(ref); const match = normalized.match(HASHLINE_REF_PATTERN); if (match) { return { line: Number.parseInt(match[1], 10), hash: match[2], }; } const hashIdx = normalized.indexOf("#"); if (hashIdx > 0) { const prefix = normalized.slice(0, hashIdx); const suffix = normalized.slice(hashIdx + 1); if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) { throw new Error( `Invalid line reference: "${ref}". "${prefix}" is not a line number. ` + `Use the actual line number from the read output.`, ); } } throw new Error( `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`, ); } function suggestLineForHash(ref: string, lines: string[]): string | null { const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/); if (!hashMatch) return null; const hash = hashMatch[1]; for (let i = 0; i < lines.length; i++) { if (computeLineHash(i + 1, lines[i]) === hash) { return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i])}"?`; } } return null; } function parseLineRefWithHint(ref: string, lines: string[]): LineRef { try { return parseLineRef(ref); } catch (parseError) { const hint = suggestLineForHash(ref, lines); if (hint && parseError instanceof Error) { throw new Error(`${parseError.message} ${hint}`); } throw parseError; } } /** Validate that a line reference points to an existing line with matching hash. */ export function validateLineRef(lines: string[], ref: string): void { const { line, hash } = parseLineRefWithHint(ref, lines); if (line < 1 || line > lines.length) { throw new Error( `Line number ${line} out of bounds. File has ${lines.length} lines.`, ); } const content = lines[line - 1]; if (computeLineHash(line, content) !== hash) { throw new HashlineMismatchError([{ line, expected: hash }], lines); } } /** Validate multiple line references at once. */ export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = []; for (const ref of refs) { const { line, hash } = parseLineRefWithHint(ref, lines); if (line < 1 || line > lines.length) { throw new Error( `Line number ${line} out of bounds (file has ${lines.length} lines)`, ); } const content = lines[line - 1]; if (computeLineHash(line, content) !== hash) { mismatches.push({ line, expected: hash }); } } if (mismatches.length > 0) { throw new HashlineMismatchError(mismatches, lines); } } // ── Edit Types ───────────────────────────────────────────────────────── export interface ReplaceEdit { op: "replace"; pos: string; end?: string; lines: string | string[]; } export interface AppendEdit { op: "append"; pos?: string; lines: string | string[]; } export interface PrependEdit { op: "prepend"; pos?: string; lines: string | string[]; } export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit; export interface RawHashlineEdit { op?: "replace" | "append" | "prepend"; pos?: string; end?: string; lines?: string | string[] | null; } // ── Edit Normalization ───────────────────────────────────────────────── function normalizeAnchor(value: string | undefined): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed === "" ? undefined : trimmed; } function toLineArray(value: string | string[] | null | undefined): string[] { if (value === null || value === undefined) return []; if (Array.isArray(value)) return value; return value.split("\n"); } function requireLines(edit: RawHashlineEdit, index: number): string | string[] { if (edit.lines === undefined) { throw new Error( `Edit ${index}: lines is required for ${edit.op ?? "unknown"}`, ); } if (edit.lines === null) { return []; } return edit.lines; } function normalizeReplaceEdit( edit: RawHashlineEdit, index: number, ): HashlineEdit { const pos = normalizeAnchor(edit.pos); const end = normalizeAnchor(edit.end); const anchor = pos ?? end; if (!anchor) { throw new Error(`Edit ${index}: replace requires at least one anchor`); } const lines = requireLines(edit, index); const normalized: ReplaceEdit = { op: "replace", pos: anchor, lines }; if (end) normalized.end = end; return normalized; } function normalizeAppendEdit( edit: RawHashlineEdit, index: number, ): HashlineEdit { const pos = normalizeAnchor(edit.pos); const lines = requireLines(edit, index); const normalized: AppendEdit = { op: "append", lines }; if (pos) normalized.pos = pos; return normalized; } function normalizePrependEdit( edit: RawHashlineEdit, index: number, ): HashlineEdit { const pos = normalizeAnchor(edit.pos); const lines = requireLines(edit, index); const normalized: PrependEdit = { op: "prepend", lines }; if (pos) normalized.pos = pos; return normalized; } /** Convert raw edits into normalized HashlineEdit objects. */ export function normalizeHashlineEdits( rawEdits: RawHashlineEdit[], ): HashlineEdit[] { return rawEdits.map((rawEdit, index) => { const edit = rawEdit ?? {}; switch (edit.op) { case "replace": return normalizeReplaceEdit(edit, index); case "append": return normalizeAppendEdit(edit, index); case "prepend": return normalizePrependEdit(edit, index); default: throw new Error( `Edit ${index}: unsupported op "${String(edit.op)}". ` + `Use "replace", "append", or "prepend".`, ); } }); } // ── Edit Application ─────────────────────────────────────────────────── function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } function getEditLineNumber(edit: HashlineEdit): number { switch (edit.op) { case "replace": return edit.end ? parseLineRef(edit.end).line : parseLineRef(edit.pos).line; case "append": return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY; case "prepend": return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY; default: return Number.POSITIVE_INFINITY; } } function collectLineRefs(edits: HashlineEdit[]): string[] { return edits.flatMap((edit) => { switch (edit.op) { case "replace": return edit.end ? [edit.pos, edit.end] : [edit.pos]; case "append": case "prepend": return edit.pos ? [edit.pos] : []; default: return []; } }); } function detectConflictingEdits(edits: HashlineEdit[]): string | null { // 1. Overlapping replace-range vs replace-range const ranges: { start: number; end: number; idx: number }[] = []; for (let i = 0; i < edits.length; i++) { const edit = edits[i]; if (edit.op !== "replace" || !edit.end) continue; const start = parseLineRef(edit.pos).line; const end = parseLineRef(edit.end).line; ranges.push({ start, end, idx: i }); } if (ranges.length >= 2) { const sorted = [...ranges].sort( (a, b) => a.start - b.start || a.end - b.end, ); for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1]; const curr = sorted[i]; if (curr.start <= prev.end) { return ( `Overlapping range edits detected: ` + `edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` + `edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` + `Use pos-only replace for single-line edits.` ); } } } // 2. Append/prepend inside a replace range for (let i = 0; i < edits.length; i++) { const edit = edits[i]; if (edit.op !== "replace" || !edit.end) continue; const rangeStart = parseLineRef(edit.pos).line; const rangeEnd = parseLineRef(edit.end).line; for (let j = 0; j < edits.length; j++) { if (i === j) continue; const other = edits[j]; if (other.op !== "append" && other.op !== "prepend") continue; if (!other.pos) continue; const line = parseLineRef(other.pos).line; if (line >= rangeStart && line <= rangeEnd) { return ( `Ambiguous mixed edit: edit ${j + 1} (${other.op} at line ${line}) ` + `is inside replace range of edit ${i + 1} (lines ${rangeStart}-${rangeEnd}). ` + `Split into separate calls or adjust anchors.` ); } } } return null; } export interface HashlineApplyReport { content: string; noopEdits: number; } /** Apply hashline edits to content. Validates hashes before applying. */ export function applyHashlineEditsWithReport( content: string, edits: HashlineEdit[], ): HashlineApplyReport { if (edits.length === 0) { return { content, noopEdits: 0 }; } const EDIT_PRECEDENCE: Record = { replace: 0, append: 1, prepend: 2, }; const sortedEdits = [...edits].sort((a, b) => { const lineA = getEditLineNumber(a); const lineB = getEditLineNumber(b); if (lineB !== lineA) return lineB - lineA; return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3); }); let noopEdits = 0; let lines = content.length === 0 ? [] : content.split("\n"); const refs = collectLineRefs(sortedEdits); validateLineRefs(lines, refs); const conflictError = detectConflictingEdits(sortedEdits); if (conflictError) throw new Error(conflictError); for (const edit of sortedEdits) { switch (edit.op) { case "replace": { const { line: posLine } = parseLineRef(edit.pos); if (edit.end) { const { line: endLine } = parseLineRef(edit.end); if (posLine > endLine) { throw new Error( `Invalid range: start line ${posLine} cannot be greater than end line ${endLine}`, ); } const newLines = toLineArray(edit.lines); const before = lines.slice(0, posLine - 1); const after = lines.slice(endLine); const result = [...before, ...newLines, ...after]; if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } else { const newLines = toLineArray(edit.lines); const result = [...lines]; result.splice(posLine - 1, 1, ...newLines); if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } break; } case "append": { const newLines = toLineArray(edit.lines); if (edit.pos) { const { line } = parseLineRef(edit.pos); const result = [...lines]; result.splice(line, 0, ...newLines); if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } else { // EOF append const result = [...lines, ...newLines]; if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } break; } case "prepend": { const newLines = toLineArray(edit.lines); if (edit.pos) { const { line } = parseLineRef(edit.pos); const result = [...lines]; result.splice(line - 1, 0, ...newLines); if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } else { // BOF prepend const result = [...newLines, ...lines]; if (arraysEqual(result, lines)) { noopEdits++; break; } lines = result; } break; } } } return { content: lines.join("\n"), noopEdits, }; } // ── Conservative Remap Suggestions ──────────────────────────────────── export interface ConservativeRemapResult { /** Unique remap suggestions: old ref → new ref */ suggestions: Map; /** Refs whose hash appears on multiple lines — cannot safely remap */ ambiguous: Set; } /** * Build conservative remap suggestions for stale anchors. * * For each ref, checks if its hash appears exactly once in the file. * - Unique match → added to `suggestions` with the current line#hash. * - Multiple matches → added to `ambiguous` (caller must re-read). * - No match → ignored (hash itself changed; caller must re-read). */ export function buildConservativeRemapSuggestions( refs: string[], fileLines: string[], ): ConservativeRemapResult { const suggestions = new Map(); const ambiguous = new Set(); // Build hash → line numbers mapping const hashToLines = new Map(); for (let i = 0; i < fileLines.length; i++) { const hash = computeLineHash(i + 1, fileLines[i]); if (!hashToLines.has(hash)) { hashToLines.set(hash, []); } hashToLines.get(hash)?.push(i + 1); } for (const ref of refs) { try { const { hash } = parseLineRef(ref); const lines = hashToLines.get(hash); if (lines && lines.length === 1) { const newLine = lines[0]; const newHash = computeLineHash(newLine, fileLines[newLine - 1]); suggestions.set(ref, `${newLine}#${newHash}`); } else if (lines && lines.length > 1) { ambiguous.add(ref); } // If hash not found at all, skip — content changed, can't remap } catch { // Invalid ref format, skip } } return { suggestions, ambiguous }; } /** Convenience wrapper that returns only the content. */ export function applyHashlineEdits( content: string, edits: HashlineEdit[], ): string { return applyHashlineEditsWithReport(content, edits).content; }