@d3ara1n/pi-hashline-edit 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/hash.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  /**
2
- * Per-line context-aware hash.
2
+ * Per-line content hash.
3
3
  *
4
- * Each line's hash is computed by concatenating "previous line + this line +
5
- * next line", so identical content lines (blank lines, `}`, `return`) get
6
- * different hashes due to different neighbors, making in-file collisions
7
- * effectively zero. Residual collisions are resolved by {@link hashFileLines}
8
- * via automatic length extension (per-file zero-collision guarantee).
4
+ * The hash mixes the 1-based line number into the line content, so every line
5
+ * gets a unique hash by construction line numbers are unique, therefore no
6
+ * in-file collision is possible, and no length extension / fallback is ever
7
+ * needed.
8
+ *
9
+ * Why line + content (not content alone, not content + neighbors):
10
+ *
11
+ * - The line number is the address; the hash is a checksum that the line at
12
+ * that address is still what was read. Mixing the number in makes the hash a
13
+ * pure fingerprint of (position, content): it changes only when the line's
14
+ * own content changes, never when a neighbor changes. (A neighbor-aware hash
15
+ * would change an unchanged line's hash when an adjacent line is edited — a
16
+ * spurious dependency with no benefit under this design's position-fixed
17
+ * apply.)
18
+ * - Content alone would leave identical lines (blank lines, `}`) sharing a
19
+ * hash; mixing the line number disambiguates them for free.
20
+ *
21
+ * Drift (file changed since read) is caught up-front by the global stale check
22
+ * (`text !== snapshot.text`). This hash's job is to verify the model actually
23
+ * read the line — it cannot forge a `(line, content)` hash without reading.
9
24
  *
10
25
  * @module pi-hashline-edit/core
11
26
  */
@@ -38,62 +53,20 @@ function toBase32(n: number, len: number): string {
38
53
  }
39
54
 
40
55
  /**
41
- * Compute the context-aware hash of a single line.
56
+ * Compute the hash of a single line from its 1-based line number and content.
42
57
  *
43
- * @param prev previous line content (`""` for the first line)
44
- * @param cur this line's content
45
- * @param next next line content (`""` for the last line)
46
- * @param len hash length (default 4, 20 bits ≈ 1M values)
58
+ * @param line 1-based line number
59
+ * @param content the line's content (no line terminator)
60
+ * @param len hash length (default 4, 20 bits 1M values)
47
61
  */
48
- export function computeLineHash(prev: string, cur: string, next: string, len = 4): string {
49
- const h = fnv1a32(`${prev}\n${cur}\n${next}`);
50
- return toBase32(h, len);
51
- }
52
-
53
- /** Compute raw per-line hashes (no collision handling). */
54
- function rawHashes(lines: readonly string[], len: number): string[] {
55
- return lines.map((line, i) =>
56
- computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
57
- );
58
- }
59
-
60
- /** Find hash values that appear more than once. */
61
- function duplicatedHashes(hashes: readonly string[]): Set<string> {
62
- const counts = new Map<string, number>();
63
- for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
64
- return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
65
- }
66
-
67
- /** Fallback: force uniqueness with an index suffix (theoretically unreachable under context-aware hashing). */
68
- function forceUnique(hashes: string[]): string[] {
69
- const seen = new Map<string, number>();
70
- return hashes.map((h) => {
71
- const c = seen.get(h) ?? 0;
72
- seen.set(h, c + 1);
73
- return c === 0 ? h : `${h}${c}`;
74
- });
62
+ export function computeLineHash(line: number, content: string, len = 4): string {
63
+ return toBase32(fnv1a32(`${line}\n${content}`), len);
75
64
  }
76
65
 
77
66
  /**
78
- * Compute per-line hashes for the whole file and resolve in-file collisions:
79
- * colliding lines are recomputed with a longer len until unique within the file
80
- * (per-file zero-collision guarantee).
81
- *
82
- * Design rationale: context-aware hashing already drives the collision
83
- * probability near zero; this extension is a defensive fallback guaranteeing
84
- * apply never mislocates due to hash ambiguity.
67
+ * Compute per-line hashes for a file. Unique by construction — the 1-based line
68
+ * number is part of each hash, so two identical content lines always differ.
85
69
  */
86
70
  export function hashFileLines(lines: readonly string[], len = 4): string[] {
87
- if (lines.length === 0) return [];
88
- let hashes = rawHashes(lines, len);
89
- for (let curLen = len; curLen <= len + 4; curLen++) {
90
- const dups = duplicatedHashes(hashes);
91
- if (dups.size === 0) return hashes;
92
- hashes = hashes.map((h, i) =>
93
- dups.has(h)
94
- ? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
95
- : h,
96
- );
97
- }
98
- return forceUnique(hashes);
71
+ return lines.map((content, i) => computeLineHash(i + 1, content, len));
99
72
  }
package/src/core/index.ts CHANGED
@@ -9,9 +9,5 @@
9
9
 
10
10
  export * from "./types.ts";
11
11
  export { computeLineHash, hashFileLines } from "./hash.ts";
12
- export { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
13
- export type { AnchorVerifyResult } from "./snapshot.ts";
14
- export { parsePatch } from "./parse.ts";
15
- export type { ParseResult } from "./parse.ts";
12
+ export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
16
13
  export { applyEdits } from "./apply.ts";
17
- export { buildDiff } from "./diff.ts";
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
4
+
5
+ test("splitLines edge cases", () => {
6
+ assert.deepEqual(splitLines(""), []);
7
+ assert.deepEqual(splitLines("a"), ["a"]);
8
+ assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
9
+ assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
10
+ assert.deepEqual(splitLines("a\n\n"), ["a", ""]);
11
+ assert.deepEqual(splitLines("\n"), [""]);
12
+ });
13
+
14
+ test("splitLines strips CRLF \\r", () => {
15
+ assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
16
+ assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
17
+ });
18
+
19
+ test("joinLines restores line endings", () => {
20
+ assert.equal(joinLines(["a", "b"]), "a\nb\n");
21
+ assert.equal(joinLines([]), "");
22
+ assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
23
+ assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
24
+ });
25
+
26
+ test("detectLineEnding", () => {
27
+ assert.equal(detectLineEnding("a\nb\n"), "lf");
28
+ assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
29
+ assert.equal(detectLineEnding("a\nb\r\nc\n"), "crlf");
30
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Line text helpers: split/join with CRLF normalization and line-ending
3
+ * detection.
4
+ *
5
+ * CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
6
+ * clean lines, matching the `\r`-free content the model copies from the
7
+ * display); detectLineEnding records the original ending so joinLines can
8
+ * restore it — guaranteeing a CRLF file keeps its endings after edit.
9
+ *
10
+ * @module pi-hashline-edit/core
11
+ */
12
+
13
+ import type { LineEnding } from "./types.ts";
14
+
15
+ /**
16
+ * Split text into lines, stripping the trailing `\r` of each line (CRLF
17
+ * normalization, so hashes are based on clean lines).
18
+ *
19
+ * Convention: a trailing newline is treated as the terminator of the last line,
20
+ * not as producing an extra empty trailing line.
21
+ * - `"a\nb\n"` → `["a", "b"]`
22
+ * - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
23
+ * - `"a\n\n"` → `["a", ""]`
24
+ * - `""` → `[]`
25
+ */
26
+ export function splitLines(text: string): string[] {
27
+ if (text === "") return [];
28
+ const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
29
+ return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
30
+ }
31
+
32
+ /** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
33
+ export function detectLineEnding(text: string): LineEnding {
34
+ return text.includes("\r\n") ? "crlf" : "lf";
35
+ }
36
+
37
+ /** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
38
+ export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
39
+ if (lines.length === 0) return "";
40
+ const sep = ending === "crlf" ? "\r\n" : "\n";
41
+ return lines.join(sep) + sep;
42
+ }
package/src/core/types.ts CHANGED
@@ -12,8 +12,8 @@ export interface Anchor {
12
12
 
13
13
  /**
14
14
  * Edit operation. Every line-numbered op references a line via {@link Anchor} —
15
- * the line number is for humans, the hash for machine verification; both must
16
- * match the snapshot at once.
15
+ * the line number is the address, the hash a checksum that the line at that
16
+ * address is still what was read; both must match at apply time.
17
17
  */
18
18
  export type Edit =
19
19
  | { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
@@ -25,47 +25,28 @@ export type Edit =
25
25
 
26
26
  export type LineEnding = "lf" | "crlf";
27
27
 
28
- /** File snapshot: original text recorded at read time + per-line context-aware hash. */
29
- export interface FileSnapshot {
30
- readonly path: string;
31
- /** `lineHashes[i]` = hash of line (i+1); length always equals the file's line count. */
32
- readonly lineHashes: readonly string[];
33
- readonly text: string;
34
- /** Hash length used when generating lineHashes; apply must reuse it for the new snapshot to avoid length drift on the next verification. */
35
- readonly hashLen: number;
36
- /** Original file line ending (lf/crlf); apply restores it so a CRLF file keeps its line endings after edit. */
37
- readonly lineEnding: LineEnding;
38
- }
39
-
40
- /** A single parsed file patch. */
41
- export interface ParsedPatch {
42
- readonly path: string;
43
- readonly edits: Edit[];
44
- }
45
-
46
28
  /** Error kinds. */
47
29
  export type PatchErrorKind =
48
- | "parse" // malformed input
49
- | "stale" // file changed (current text !== snapshot.text)
50
- | "anchor" // anchor hash does not match the snapshot (model misremembered) or line out of range
51
- | "collision" // hash appears at multiple lines, cannot locate uniquely
52
- | "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
30
+ | "anchor" // anchor hash does not match the current line content (line changed, or model misremembered) or line out of range
31
+ | "range" // illegal operation range (overlap, reverse order, etc.)
53
32
  | "noop"; // edit produced no change (body byte-identical to the target)
54
33
 
55
34
  export interface PatchError {
56
35
  readonly kind: PatchErrorKind;
57
36
  readonly message: string;
58
- /** Line number (1-based) in the input patch, for error localization. */
59
- readonly line?: number;
60
37
  }
61
38
 
62
- /** Apply result. */
39
+ /**
40
+ * Apply result. On success, `touchedLines` lists the 0-based line indices in
41
+ * the NEW file that this edit produced (inserted or replaced) — callers use it
42
+ * to surface fresh `LINE#HASH` anchors so the model can chain edits without a
43
+ * re-read.
44
+ */
63
45
  export type ApplyResult =
64
46
  | {
65
47
  readonly ok: true;
66
48
  readonly text: string;
67
- readonly newSnapshot: FileSnapshot;
68
49
  readonly changed: boolean;
69
- readonly diff: string;
50
+ readonly touchedLines: readonly number[];
70
51
  }
71
52
  | { readonly ok: false; readonly error: PatchError };
package/src/index.ts CHANGED
@@ -1,30 +1,27 @@
1
1
  /**
2
2
  * pi-hashline-edit extension entry.
3
3
  *
4
- * Overrides the built-in read/edit: read outputs "lineNo#hash│content" and
5
- * records a snapshot; edit accepts only a hashline patch (LINE#HASH anchors),
6
- * and legacy oldText/newText is rejected explicitly (no silent degradation).
7
- * The renderer is inherited from the built-in automatically.
4
+ * Overrides the built-in read/edit: read outputs "lineNo#hash│content";
5
+ * edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
6
+ * legacy oldText/newText is rejected explicitly (no silent degradation). Each
7
+ * tool carries its own renderer.
8
8
  *
9
9
  * @module pi-hashline-edit
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { loadConfig } from "./pi/config.ts";
14
- import { clearSnapshots, getState } from "./pi/state.ts";
14
+ import { getState } from "./pi/state.ts";
15
15
  import { makeEditOverride } from "./pi/edit-tool.ts";
16
16
  import { makeReadOverride } from "./pi/read-tool.ts";
17
17
 
18
18
  export default function (pi: ExtensionAPI) {
19
19
  const cwd = process.cwd();
20
20
 
21
- // refresh config and snapshots on session start / reload
21
+ // refresh config on session start / reload
22
22
  pi.on("session_start", async () => {
23
- const config = loadConfig(cwd);
24
23
  const state = getState();
25
- state.config = config;
26
- state.hashLen = config.hashLen;
27
- clearSnapshots();
24
+ state.config = loadConfig(cwd);
28
25
  });
29
26
 
30
27
  pi.registerTool(makeReadOverride(cwd));
@@ -1,95 +1,109 @@
1
1
  /**
2
- * Override edit: accepts only a hashline patch (`input`).
2
+ * Override edit: hashline ops via structured `edits` (LINE#HASH anchors).
3
3
  *
4
- * Not backward-compatible with legacy oldText/newText when legacy input is
5
- * detected it errors explicitly so the developer knows the model didn't use the
6
- * new approach, rather than silently degrading. Tolerance is limited to
7
- * format-only normalizations that don't affect the result (optional colon, CRLF
8
- * at the parse layer); paradigm-level compatibility is refused outright.
4
+ * Each op in `edits` references line anchors copied from read output (or from a
5
+ * prior edit's "Updated anchors"). The core verifies each anchor live against
6
+ * the current file content — no snapshot, no global stale check: a cited line
7
+ * that changed (or was misremembered) fails its own anchor; unchanged lines
8
+ * elsewhere never block the edit. Legacy oldText/newText is not accepted — the
9
+ * schema requires an `op` discriminator, so legacy payloads are rejected at the
10
+ * schema layer (a visible failure, never a silent degradation).
9
11
  *
10
- * Concurrency safety: the read-modify-write is wrapped in
11
- * withFileMutationQueue, serializing multiple edits to the same file to prevent
12
- * data loss under pi's default parallel execution. AbortSignal is honored —
13
- * checked after read / before write, so a user cancel never touches the disk.
12
+ * On success the result carries fresh `LINE#HASH` anchors for the lines this
13
+ * edit produced (and the line that shifted into a deletion gap), so the model
14
+ * can chain edits without a re-read.
15
+ *
16
+ * Concurrency safety: read-modify-write is wrapped in withFileMutationQueue.
17
+ * AbortSignal is honored — checked after read / before write.
14
18
  *
15
19
  * @module pi-hashline-edit/pi
16
20
  */
17
21
 
18
- import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
22
+ import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
19
23
  import { Type, type Static } from "typebox";
24
+ import { Text } from "@earendil-works/pi-tui";
20
25
  import { readFile, writeFile } from "node:fs/promises";
21
- import { applyEdits, parsePatch } from "../core/index.ts";
22
- import type { Edit, FileSnapshot, PatchError } from "../core/types.ts";
26
+ import { applyEdits, hashFileLines } from "../core/index.ts";
27
+ import { splitLines } from "../core/lines.ts";
28
+ import type { Edit, PatchError } from "../core/types.ts";
23
29
  import { canonicalPath } from "./read-tool.ts";
24
- import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
25
- import { Text } from "@earendil-works/pi-tui";
30
+ import { getState } from "./state.ts";
31
+
32
+ /** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
33
+ const MAX_ANCHOR_LINES = 40;
26
34
 
27
- // The schema deliberately omits additionalProperties:false: when the model
28
- // mistakenly sends legacy edits/oldText/newText, those extra fields reach
29
- // execute as-is and are caught and rejected explicitly by missingInputError.
30
- // This relies on typebox allowing extra properties by default + pi validation
31
- // not stripping them — do not change either of these.
35
+ const anchorSchema = Type.Object({
36
+ line: Type.Number({ description: "1-based line number" }),
37
+ hash: Type.String({ description: "Line content hash copied from read output (the #HASH after the line number)" }),
38
+ });
39
+
40
+ const editOpSchema = Type.Object({
41
+ op: Type.Union(
42
+ [
43
+ Type.Literal("replace"),
44
+ Type.Literal("delete"),
45
+ Type.Literal("insert_after"),
46
+ Type.Literal("insert_before"),
47
+ Type.Literal("append"),
48
+ Type.Literal("prepend"),
49
+ ],
50
+ { description: "Operation kind" },
51
+ ),
52
+ anchor: Type.Optional(anchorSchema),
53
+ end: Type.Optional(anchorSchema),
54
+ body: Type.Optional(Type.Array(Type.String(), { description: "New content lines (required for replace/insert/append/prepend; omit for delete)" })),
55
+ });
32
56
 
33
57
  const editSchema = Type.Object({
34
58
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
35
- input: Type.Optional(
36
- Type.String({
37
- description:
38
- "Hashline patch referencing LINE#HASH anchors from your latest read. Ops: replace / delete / insert_after / insert_before / append / prepend. Body rows start with `+`. This tool does NOT accept legacy oldText/newText.",
39
- }),
40
- ),
59
+ edits: Type.Array(editOpSchema, { description: "Hashline ops, each referencing LINE#HASH anchors from your latest read or edit result" }),
41
60
  });
42
61
 
62
+ type EditOpInput = Static<typeof editOpSchema>;
63
+
43
64
  /** Turn a PatchError into helpful hint text for the model. */
44
65
  function errorText(e: PatchError, path: string): string {
45
66
  switch (e.kind) {
46
- case "stale":
47
- return `File ${path} changed since your last read. Re-read it before editing.`;
48
67
  case "anchor":
49
68
  return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
50
- case "collision":
51
- return `Hash collision: ${e.message}. Re-read ${path}.`;
52
69
  case "range":
53
70
  return `Bad range: ${e.message}`;
54
71
  case "noop":
55
72
  return `Edit produced no change: ${e.message}`;
56
- case "parse":
57
- return `Parse error${e.line ? ` at line ${e.line}` : ""}: ${e.message}`;
58
- }
59
- }
60
-
61
- /** Get the first changed line after applying (for firstChangedLine / TUI jump). insert_after uses anchor + 1. */
62
- function minAnchorLine(edits: readonly Edit[]): number | undefined {
63
- let min: number | undefined;
64
- for (const e of edits) {
65
- let line: number | undefined;
66
- if (e.op === "replace" || e.op === "delete") line = e.start.line;
67
- else if (e.op === "insert_after") line = e.anchor.line + 1;
68
- else if (e.op === "insert_before") line = e.anchor.line;
69
- else if (e.op === "prepend") line = 1;
70
- // append has no anchor, skip (trailing append)
71
- if (line !== undefined && (min === undefined || line < min)) min = line;
72
73
  }
73
- return min;
74
74
  }
75
75
 
76
- /**
77
- * The model didn't use hashline (sent legacy oldText/newText or is missing
78
- * input) tell it explicitly, don't silently degrade. Exported for testing.
79
- * params is `any` so it can detect legacy fields outside the schema.
80
- */
81
- export function missingInputError(path: string, params: any): string {
82
- const legacy =
83
- Array.isArray(params?.edits) ||
84
- typeof params?.oldText === "string" ||
85
- typeof params?.newText === "string";
86
- if (legacy) {
87
- return `⚠ ${path}: you sent the legacy oldText/newText format, but this edit tool ONLY accepts hashline \`input\`. The legacy path was intentionally removed so this is surfaced, not silently degraded. Re-read ${path} to get LINE#HASH anchors, then send \`input\` (e.g. \`replace 4#aF3:\` followed by \`+\` body rows).`;
76
+ /** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
77
+ function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } | { ok: false; error: string } {
78
+ const edits: Edit[] = [];
79
+ for (const o of ops) {
80
+ switch (o.op) {
81
+ case "replace":
82
+ if (!o.anchor) return { ok: false, error: "replace needs `anchor` {line, hash}" };
83
+ if (!o.body) return { ok: false, error: "replace needs `body`" };
84
+ edits.push({ op: "replace", start: o.anchor, end: o.end, body: o.body });
85
+ break;
86
+ case "delete":
87
+ if (!o.anchor) return { ok: false, error: "delete needs `anchor` {line, hash}" };
88
+ edits.push({ op: "delete", start: o.anchor, end: o.end });
89
+ break;
90
+ case "insert_after":
91
+ case "insert_before":
92
+ if (!o.anchor) return { ok: false, error: `${o.op} needs \`anchor\` {line, hash}` };
93
+ if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
94
+ edits.push({ op: o.op, anchor: o.anchor, body: o.body });
95
+ break;
96
+ case "append":
97
+ case "prepend":
98
+ if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
99
+ edits.push({ op: o.op, body: o.body });
100
+ break;
101
+ }
88
102
  }
89
- return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
103
+ return { ok: true, edits };
90
104
  }
91
105
 
92
- /** Build an error result (with isError: true so the TUI/agent loop treats it as a failure, not a success). */
106
+ /** Build an error result (isError: true so the TUI/agent loop treats it as a failure). */
93
107
  function errResult(text: string) {
94
108
  return {
95
109
  isError: true as const,
@@ -98,6 +112,21 @@ function errResult(text: string) {
98
112
  };
99
113
  }
100
114
 
115
+ /**
116
+ * Format the updated anchors (fresh LINE#HASH│content) for the touched new-file
117
+ * lines, so the model can chain edits without a re-read. Capped to bound tokens.
118
+ */
119
+ function formatUpdatedAnchors(newText: string, touched: readonly number[], hashLen: number): string {
120
+ const newLines = splitLines(newText);
121
+ const newHashes = hashFileLines(newLines, hashLen);
122
+ const idxs = [...new Set(touched)].sort((a, b) => a - b);
123
+ if (idxs.length === 0) return "";
124
+ const rows = idxs.map((i) => `${i + 1}#${newHashes[i]}│${newLines[i]}`);
125
+ const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
126
+ const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
127
+ return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
128
+ }
129
+
101
130
  export function makeEditOverride(cwd: string) {
102
131
  const builtin = createEditTool(cwd);
103
132
 
@@ -105,32 +134,30 @@ export function makeEditOverride(cwd: string) {
105
134
  name: "edit" as const,
106
135
  label: "edit",
107
136
  description:
108
- "Edit a file via hashline patch (LINE#HASH anchors, content-verified). Does NOT accept legacy oldText/newText.",
109
- promptSnippet: "Edit files via hashline LINE#HASH anchors (input patch); legacy oldText/newText not accepted",
137
+ "Edit a file via hashline ops (LINE#HASH anchors, content-verified). Each op in `edits` references line anchors from your latest read or edit result.",
138
+ promptSnippet: "Edit files via hashline ops (edits[] with LINE#HASH anchors from read)",
110
139
  promptGuidelines: [
111
- "Pass `input`: a hashline patch referencing `LINE#HASH` anchors copied from your latest read output (e.g. `replace 12#aF3:`).",
112
- "Ops: `replace LINE#HASH[..LINE#HASH]:` · `delete LINE#HASH` · `insert_after LINE#HASH:` · `insert_before LINE#HASH:` · `append:` · `prepend:`.",
113
- "Body rows start with `+` followed by the literal line. `+` alone = blank line. Literal `+`/`-` lines become `++`/`+-`.",
114
- "After each successful edit, re-ground: line numbers shift, so take the next edit's anchors from a fresh read.",
115
- "This tool does NOT accept legacy oldText/newTextsending those returns an error (intentional, so it's visible).",
140
+ "Pass `edits`: an array of ops. Each op = {op, anchor?, end?, body?}.",
141
+ "op replace | delete | insert_after | insert_before | append | prepend.",
142
+ "anchor & end = {line, hash} copied from your latest read or edit result (the `#HASH` after each line number). replace/delete take anchor (+ optional end for a range); insert_after/insert_before take anchor; append/prepend take neither.",
143
+ "body = string[] of new content lines (required for replace/insert/append/prepend; omit for delete).",
144
+ "A successful edit returns `Updated anchors` for the changed lines use those (not stale line numbers) for the next edit to the same file; re-read only if you need lines outside that set.",
116
145
  ],
117
146
  parameters: editSchema,
118
- renderShell: "self" as const,
147
+ renderShell: "default" as const,
119
148
 
120
149
  renderCall(args: Static<typeof editSchema>, theme: any) {
121
150
  let text = theme.fg("toolTitle", theme.bold("edit "));
122
151
  text += theme.fg("accent", args.path);
123
- if (args.input) {
124
- const firstOp = args.input.split("\n").find((l) => l.trim() && !l.startsWith("+"));
125
- if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
126
- }
152
+ const n = args.edits?.length ?? 0;
153
+ if (n) text += theme.fg("dim", ` ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
127
154
  return new Text(text, 0, 0);
128
155
  },
129
156
 
130
- renderResult(result: any, { isPartial, expanded }: any, theme: any) {
157
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
131
158
  if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
132
159
  const content = result.content?.[0];
133
- if (result.isError) {
160
+ if (context.isError) {
134
161
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
135
162
  return new Text(theme.fg("error", t), 0, 0);
136
163
  }
@@ -160,19 +187,19 @@ export function makeEditOverride(cwd: string) {
160
187
 
161
188
  const path = params.path;
162
189
  const absPath = canonicalPath(cwd, path);
163
- const input = params.input;
164
190
 
165
- // No input → tell explicitly (distinguish legacy vs missing), don't silently degrade
166
- if (typeof input !== "string" || input.trim() === "") {
167
- return errResult(missingInputError(path, params as any));
191
+ if (!params.edits?.length) {
192
+ return errResult(`Edit ${path}: \`edits\` is empty or missing.`);
168
193
  }
169
194
  // withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
170
- return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
195
+ return await withFileMutationQueue(absPath, () => runHashline(absPath, path, params.edits, signal));
171
196
  },
172
197
  };
173
198
  }
174
199
 
175
- async function runHashline(absPath: string, displayPath: string, input: string, signal: AbortSignal | undefined) {
200
+ async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
201
+ const hashLen = getState().config.hashLen;
202
+
176
203
  let currentText: string;
177
204
  try {
178
205
  currentText = (await readFile(absPath)).toString("utf-8");
@@ -183,18 +210,13 @@ async function runHashline(absPath: string, displayPath: string, input: string,
183
210
  // Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
184
211
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
185
212
 
186
- // Use the recorded snapshot, or if there is none (the model didn't read) build one from the
187
- // current file — anchor verification still forces the model to use a real hash (it can't guess
188
- // correctly without reading), naturally steering it to read first.
189
- const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
190
-
191
- // input has no file header; prepend one so parsePatch passes (path is just a label)
192
- const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
193
- if (!parsed.ok) {
194
- return errResult(errorText(parsed.error, displayPath));
195
- }
213
+ const translated = toCoreEdits(editOps);
214
+ if (!translated.ok) return errResult(translated.error);
196
215
 
197
- const result = applyEdits(currentText, parsed.patch.edits, snap);
216
+ // Anchors are verified against the current content. A line that changed (or a
217
+ // hash the model didn't actually read) fails its own anchor — steering it to
218
+ // read first. Unrelated changes elsewhere never block the edit.
219
+ const result = applyEdits(currentText, translated.edits, hashLen);
198
220
  if (!result.ok) {
199
221
  return errResult(errorText(result.error, displayPath));
200
222
  }
@@ -209,20 +231,16 @@ async function runHashline(absPath: string, displayPath: string, input: string,
209
231
  return errResult(`Error writing ${displayPath}: ${msg}`);
210
232
  }
211
233
 
212
- // Update the snapshot: consecutive edits need no re-read (result.newSnapshot is based on the new text), via LRU
213
- putSnapshot(absPath, result.newSnapshot);
214
-
234
+ // pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
235
+ const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
236
+ const details: EditToolDetails = {
237
+ diff,
238
+ patch: generateUnifiedPatch(displayPath, currentText, result.text),
239
+ firstChangedLine,
240
+ };
241
+ const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
215
242
  return {
216
- content: [
217
- { type: "text" as const, text: `Edited ${displayPath} (${parsed.patch.edits.length} op(s)).` },
218
- ],
219
- details: {
220
- // details.diff must use pi's generateDiffString (+N content format); the built-in
221
- // renderer's parseDiffLine only recognizes this format — core's standard unified diff
222
- // would be grayed out as plain text
223
- diff: generateDiffString(currentText, result.text),
224
- patch: generateUnifiedPatch(displayPath, currentText, result.text),
225
- firstChangedLine: minAnchorLine(parsed.patch.edits),
226
- },
243
+ content: [{ type: "text" as const, text: `Edited ${displayPath} (${translated.edits.length} op(s)).${anchors}` }],
244
+ details,
227
245
  };
228
246
  }