feat(coding-agent): added hashline entrypoint and redirected callers

- Added `./hashline` package exports and redirected callers to the new hashline entrypoint.
- Moved hashline logic out of `edit/` to `src/hashline` and removed `edit/modes/hashline`/`edit/line-hash` paths.
- Added hashline parsers, anchors, types, and diff helpers with stricter input and mismatch validation.
- Implemented preflight and cache-recovery execution flows to reapply edits and handle stale anchor mismatches.
- Documented the hashline API relocation as breaking changes in `CHANGELOG.md`.
This commit is contained in:
can1357
2026-05-09 23:01:54 +02:00
parent 0a24c5a892
commit 85003caaaf
29 changed files with 2607 additions and 2651 deletions
+4
View File
@@ -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.
+8
View File
@@ -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"
@@ -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";
+9 -10
View File
@@ -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";
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -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";
@@ -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<number>();
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<string, string>;
constructor(
public readonly mismatches: HashMismatch[],
public readonly fileLines: string[],
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
this.name = "HashlineMismatchError";
const remaps = new Map<string, string>();
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<number>(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<number>(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);
}
}
+732
View File
@@ -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<HashlineEdit, { kind: "delete" }>;
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<number> {
const lines = new Set<number>();
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<number>, externalTargetLines: Set<number>): 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<string>();
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<number, IndexedEdit[]> {
const byLine = new Map<number, IndexedEdit[]>();
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 } : {}),
};
}
@@ -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"
]
@@ -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 = "@";
@@ -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 };
}
@@ -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<string> },
absolutePath: string,
pathText: string,
): Promise<string> {
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);
}
@@ -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<ReadHashlineFileResult> {
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<EditToolDetails>): string {
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
}
function getEditDetails(result: AgentToolResult<EditToolDetails>): 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<void> {
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<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
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<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
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<string, string[]>();
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") }));
}
@@ -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;
@@ -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";
+110
View File
@@ -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;
}
@@ -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 };
}
@@ -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);
}
@@ -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,
};
}
@@ -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<Uint8Array> {
return (
typeof value === "object" &&
value !== null &&
"getReader" in value &&
typeof (value as { getReader?: unknown }).getReader === "function"
);
}
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
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<Uint8Array> | AsyncIterable<Uint8Array>,
options: HashlineStreamOptions = {},
): AsyncGenerator<string> {
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;
}
@@ -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<typeof hashlineEditParamsSchema>;
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;
}
@@ -0,0 +1,3 @@
export function stripTrailingCarriageReturn(line: string): string {
return line.endsWith("\r") ? line.slice(0, -1) : line;
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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.
+1 -1
View File
@@ -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";
@@ -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,
+1 -1
View File
@@ -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", () => {