@d3ara1n/pi-hashline-edit 0.1.2 → 0.2.0

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/README.md CHANGED
@@ -2,15 +2,44 @@
2
2
 
3
3
  > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash, replacing `oldText`/`newText` matching.
4
4
 
5
- Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
5
+ Edits reference lines by `LINE#HASH` anchors (copied from `read`/`grep` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
6
+
7
+ ## Why hashline?
8
+
9
+ The built-in `edit` matches `oldText`/`newText` exactly. When the model can't reproduce the source verbatim — wrong indentation, a non-unique snippet, or a line that drifted since the read — the edit fails and you loop. Hashline sidesteps all of it:
10
+
11
+ - **No string-not-found loops** — you edit by reference (`LINE#HASH`), not by retyping the line you want to change.
12
+ - **No whitespace battles** — the new content is the only thing you type; nothing has to match what's already there. Indentation mistakes on the *old* code are impossible.
13
+ - **Unique-by-construction hashes** — the line number is folded into the hash, so identical lines (blank lines, `}`) never share a hash and never collide.
14
+ - **Chain edits without re-reading** — a successful `edit` returns fresh anchors for the lines it produced, so the next edit cites them directly instead of forcing a full re-read.
15
+ - **Grep-to-edit, no detour** — search results carry the same `LINE#HASH` anchors (grouped by file, context lines included); grab one and edit directly, skipping the read you'd otherwise need.
16
+ - **Surgical drift detection** — each cited anchor is rechecked against the current line only; an unrelated change elsewhere never blocks your edit.
17
+ - **Self-healing stale anchors** — a drifted anchor (content shifted by an edit above it) doesn't force a full re-read: the applicator rescans ±lines for the original content and hands back a fresh `LINE#HASH` to retry with, only asking for a re-read when the content genuinely changed.
18
+
19
+ ## When to use it
20
+
21
+ Routine local code editing in pi — the common case. If you spend turns fighting "old_string not found" or fixing indentation the model dropped, this is the fix.
22
+
23
+ ## When to turn it off
24
+
25
+ Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project.
26
+
27
+ ## Gotchas (vs. the built-in `read`/`edit`)
28
+
29
+ Once hashline overrides the built-ins, a few things behave differently:
30
+
31
+ - **`read` is globally overridden.** Every read shows the `LINE#HASH│` prefix on each line — even reads that won't lead to an edit. This is expected (it's the substrate the reliability is built on), just don't be surprised when the format changes for all files.
32
+ - **Conservative overlap.** Two ops whose ranges touch (e.g. `insert_after` immediately followed by `replace` at the same line) are rejected to avoid backfill ambiguity — issue them as two separate `edit` calls.
6
33
 
7
34
  ## Design
8
35
 
9
- - **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
10
- - **Line + content hash**: each line's hash mixes its 1-based line number into its content, so every line gets a unique hash by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
11
- - **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the CURRENT line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair, no drift relocation.
12
- - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` (`LINE#HASH│`) for the lines it produced (and the line that shifted into a deletion gap), so the next edit to the same file can cite them directly.
13
- - **No legacy compatibility**: overrides the built-in `edit`/`read`. `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades)so you always know whether hashline is actually in use.
36
+ - **Per-line hash + line number, dual anchor**: `read` shows each line as `3#aF3│code`; `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
37
+ - **Line folded into the hash**: each line's hash mixes its 1-based line number into its content, so every line is unique by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
38
+ - **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the current line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair.
39
+ - **Shifted-anchor recovery**: a mismatched anchor isn't a dead end. The applicator rescans ±`shiftRadius` lines for the original content holding the original line number fixed and re-hashing each candidate (`hash(line, candidate) === cited` iff the candidate *is* the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case.
40
+ - **Atomic batches, all failures collected**: every op in one `edit` is verified against the same snapshot; if any anchor fails, *all* failures (each with its recovery) are returned together and nothing is written partial writes would shift lines and invalidate the very recovery info just returned.
41
+ - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` for the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly.
42
+ - **No legacy compatibility**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use.
14
43
 
15
44
  ## Protocol
16
45
 
@@ -18,11 +47,22 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
18
47
 
19
48
  ```
20
49
  src/foo.ts · 6 lines
21
- 1#aF3│import { compute } from "./util"
22
- 2#7Qk│
23
- 3#mP0│export function foo(x: number) {
50
+ 1#aF3│import { compute } from "./util"
51
+ 2#7Qk│
52
+ 3#mP0│export function foo(x: number) {
53
+ ```
54
+
55
+ `grep` output (results grouped by file, each line anchored — copy `LINE#HASH` straight into an edit):
56
+
57
+ ```
58
+ src/foo.ts · 2 matches
59
+ 3#mP0│export function foo(x: number) {
60
+ 4#kLp│ return x + 1
61
+ src/util.ts · 1 match
62
+ 10#aF3│ const z = compute(x)
24
63
  ```
25
64
 
65
+
26
66
  `edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
27
67
 
28
68
  ```jsonc
@@ -37,6 +77,20 @@ src/foo.ts · 6 lines
37
77
 
38
78
  Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
39
79
 
80
+ ## Configuration
81
+
82
+ Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in a project (project replaces global):
83
+
84
+ ```jsonc
85
+ {
86
+ "hashlineEdit": {
87
+ "enabled": true, // set false to fall back to the built-in read/edit
88
+ "hashLen": 4, // hash length, 2–8 (default 4)
89
+ "shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
90
+ }
91
+ }
92
+ ```
93
+
40
94
  ## Installation
41
95
 
42
96
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -103,14 +103,14 @@ test("anchor hash mismatch rejected (line changed)", () => {
103
103
  const text = "a\nb\n";
104
104
  const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
105
105
  assert.equal(r.ok, false);
106
- if (!r.ok) assert.equal(r.error.kind, "anchor");
106
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
107
107
  });
108
108
 
109
109
  test("line out of range rejected", () => {
110
110
  const text = "a\n";
111
111
  const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
112
112
  assert.equal(r.ok, false);
113
- if (!r.ok) assert.equal(r.error.kind, "anchor");
113
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
114
114
  });
115
115
 
116
116
  test("anchor mismatch when the cited line's content differs (live verification)", () => {
@@ -118,14 +118,14 @@ test("anchor mismatch when the cited line's content differs (live verification)"
118
118
  const text = "a\nb\n";
119
119
  const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: computeLineHash(2, "b") }, body: ["x"] }]);
120
120
  assert.equal(r.ok, false);
121
- if (!r.ok) assert.equal(r.error.kind, "anchor");
121
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
122
122
  });
123
123
 
124
124
  test("reverse-order range rejected", () => {
125
125
  const text = "a\nb\nc\n";
126
126
  const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
127
127
  assert.equal(r.ok, false);
128
- if (!r.ok) assert.equal(r.error.kind, "range");
128
+ if (!r.ok) assert.equal(r.failure.kind, "range");
129
129
  });
130
130
 
131
131
  test("overlapping edits rejected", () => {
@@ -135,7 +135,7 @@ test("overlapping edits rejected", () => {
135
135
  { op: "replace", start: at(text, 3), body: ["y"] },
136
136
  ]);
137
137
  assert.equal(r.ok, false);
138
- if (!r.ok) assert.equal(r.error.kind, "range");
138
+ if (!r.ok) assert.equal(r.failure.kind, "range");
139
139
  });
140
140
 
141
141
  test("conflict at the same insertion point rejected", () => {
@@ -145,14 +145,14 @@ test("conflict at the same insertion point rejected", () => {
145
145
  { op: "insert_after", anchor: at(text, 1), body: ["y"] },
146
146
  ]);
147
147
  assert.equal(r.ok, false);
148
- if (!r.ok) assert.equal(r.error.kind, "range");
148
+ if (!r.ok) assert.equal(r.failure.kind, "range");
149
149
  });
150
150
 
151
151
  test("noop (byte-identical body) rejected", () => {
152
152
  const text = "a\nb\n";
153
153
  const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
154
154
  assert.equal(r.ok, false);
155
- if (!r.ok) assert.equal(r.error.kind, "noop");
155
+ if (!r.ok) assert.equal(r.failure.kind, "noop");
156
156
  });
157
157
 
158
158
  test("unrelated change elsewhere does NOT block the edit (no global stale check)", () => {
@@ -171,3 +171,94 @@ test("CRLF line endings preserved", () => {
171
171
  if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
172
172
  });
173
173
 
174
+ // --- shifted-anchor recovery ---
175
+
176
+ test("shifted recovery: content moved down → found with a fresh anchor", () => {
177
+ const readText = "a\nb\nc\nd\ne\n";
178
+ const currentText = "a\nX\nb\nc\nd\ne\n"; // inserted X after line 1 → "c" moved 3→4
179
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
180
+ assert.equal(r.ok, false);
181
+ if (!r.ok && r.failure.kind === "anchor") {
182
+ const f = r.failure.failures[0];
183
+ assert.equal(f.recovery.kind, "found");
184
+ if (f.recovery.kind === "found") {
185
+ assert.equal(f.recovery.newLine, 4);
186
+ // the rescued anchor must verify against the current file
187
+ assert.equal(computeLineHash(4, splitLines(currentText)[3]), f.recovery.newHash);
188
+ }
189
+ }
190
+ });
191
+
192
+ test("rescued anchor lets the retry succeed without a re-read", () => {
193
+ const readText = "a\nb\nc\nd\ne\n";
194
+ const currentText = "a\nX\nb\nc\nd\ne\n";
195
+ // first attempt with a stale anchor → rescued
196
+ const r1 = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
197
+ assert.equal(r1.ok, false);
198
+ if (r1.ok) return;
199
+ if (r1.failure.kind !== "anchor") return;
200
+ const f = r1.failure.failures[0];
201
+ assert.equal(f.recovery.kind, "found");
202
+ if (f.recovery.kind !== "found") return;
203
+ // retry with the rescued anchor → succeeds, file unchanged elsewhere
204
+ const r2 = applyEdits(currentText, [
205
+ { op: "replace", start: { line: f.recovery.newLine, hash: f.recovery.newHash }, body: ["C"] },
206
+ ]);
207
+ assert.equal(r2.ok, true);
208
+ if (r2.ok) assert.equal(r2.text, "a\nX\nb\nC\nd\ne\n");
209
+ });
210
+
211
+ test("shifted recovery: duplicate content → ambiguous candidates", () => {
212
+ const readText = "a\nx\nb\nx\nc\n";
213
+ const currentText = "a\nY\nx\nb\nx\nc\n"; // both "x" shifted
214
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["Z"] }]);
215
+ assert.equal(r.ok, false);
216
+ if (!r.ok && r.failure.kind === "anchor") {
217
+ const f = r.failure.failures[0];
218
+ assert.equal(f.recovery.kind, "ambiguous");
219
+ if (f.recovery.kind === "ambiguous") {
220
+ assert.deepEqual(
221
+ f.recovery.candidates.map((c) => c.line),
222
+ [3, 5],
223
+ );
224
+ }
225
+ }
226
+ });
227
+
228
+ test("shifted recovery: content genuinely changed → none, with live content", () => {
229
+ const readText = "a\nb\nc\n";
230
+ const currentText = "a\nBCHANGED\nc\n"; // "b" is gone
231
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["B"] }]);
232
+ assert.equal(r.ok, false);
233
+ if (!r.ok && r.failure.kind === "anchor") {
234
+ const f = r.failure.failures[0];
235
+ assert.equal(f.recovery.kind, "none");
236
+ assert.equal(f.current?.content, "BCHANGED");
237
+ }
238
+ });
239
+
240
+ test("collect-all: two stale anchors in one batch → both failures returned", () => {
241
+ const readText = "a\nb\nc\nd\n";
242
+ const currentText = "X\na\nb\nc\nd\n"; // inserted X at top → all shifted +1
243
+ const r = applyEdits(currentText, [
244
+ { op: "replace", start: at(readText, 2), body: ["B"] },
245
+ { op: "replace", start: at(readText, 4), body: ["D"] },
246
+ ]);
247
+ assert.equal(r.ok, false);
248
+ if (!r.ok && r.failure.kind === "anchor") {
249
+ assert.equal(r.failure.failures.length, 2);
250
+ const found = r.failure.failures.map((f) => (f.recovery.kind === "found" ? f.recovery.newLine : -1));
251
+ assert.deepEqual(found, [3, 5]);
252
+ }
253
+ });
254
+
255
+ test("shiftRadius=0 disables rescue (always none)", () => {
256
+ const readText = "a\nb\nc\nd\ne\n";
257
+ const currentText = "a\nX\nb\nc\nd\ne\n";
258
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }], 4, 0);
259
+ assert.equal(r.ok, false);
260
+ if (!r.ok && r.failure.kind === "anchor") {
261
+ assert.equal(r.failure.failures[0].recovery.kind, "none");
262
+ }
263
+ });
264
+
package/src/core/apply.ts CHANGED
@@ -7,6 +7,20 @@
7
7
  * misremembered) fails its own anchor; unchanged lines elsewhere never block
8
8
  * the edit.
9
9
  *
10
+ * Shifted-anchor recovery: when a cited anchor no longer matches, we rescan
11
+ * ±radius lines for the original content, holding the ORIGINAL line number fixed
12
+ * and re-hashing each candidate's content. On a unique hit the new anchor (with
13
+ * its freshly computed hash) is returned so the caller can retry without a
14
+ * re-read; on several hits they are reported as ambiguous; on none the live
15
+ * content at the cited line is returned to steer a re-read.
16
+ *
17
+ * Batch semantics: all ops are verified against the same current snapshot. If
18
+ * ANY anchor fails, EVERY failure (with recovery) is collected and returned
19
+ * together — nothing is written. This keeps the rescue report and the on-disk
20
+ * file in sync: a partial write would shift lines and invalidate the very
21
+ * recovery info we just returned. Range issues among the surviving ops are
22
+ * deferred until anchors are corrected.
23
+ *
10
24
  * Other strict semantics:
11
25
  * - Operation ranges must not overlap (including the same insertion point).
12
26
  * - body byte-identical to the whole-file result → `noop` error (guides the
@@ -17,7 +31,7 @@
17
31
 
18
32
  import { computeLineHash } from "./hash.ts";
19
33
  import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
20
- import type { Anchor, ApplyResult, Edit, PatchError } from "./types.ts";
34
+ import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
21
35
 
22
36
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
23
37
  interface SpanOp {
@@ -26,40 +40,94 @@ interface SpanOp {
26
40
  newLines: string[];
27
41
  }
28
42
 
29
- /** Verify an anchor against the current file: line in range and hash matches the live content. */
30
- function checkAnchor(lines: readonly string[], line: number, hash: string, hashLen: number): PatchError | null {
31
- if (line < 1 || line > lines.length) {
32
- return {
33
- kind: "anchor",
34
- message: `line ${line} does not exist (file has ${lines.length} lines)`,
35
- };
43
+ /** Default ±line radius for shifted-anchor recovery. */
44
+ const DEFAULT_SHIFT_RADIUS = 15;
45
+
46
+ /**
47
+ * Verify an anchor against the live content; on mismatch, attempt shifted
48
+ * recovery. Returns null when the anchor matches, otherwise an
49
+ * {@link AnchorFailure} carrying the recovery outcome and the cited line's
50
+ * current snapshot.
51
+ *
52
+ * Recovery holds the ORIGINAL line number fixed and re-hashes each candidate's
53
+ * content: `computeLineHash(citedLine, candidateContent) === citedHash` holds
54
+ * iff the candidate IS the original content (modulo negligible hash collision).
55
+ * A returned candidate's anchor uses the candidate's real line number with a
56
+ * hash computed for that line, so it verifies on retry.
57
+ */
58
+ function verifyAnchor(
59
+ lines: readonly string[],
60
+ cited: Anchor,
61
+ which: "anchor" | "end",
62
+ opIndex: number,
63
+ op: Edit["op"],
64
+ hashLen: number,
65
+ radius: number,
66
+ ): AnchorFailure | null {
67
+ const { line, hash } = cited;
68
+ if (line >= 1 && line <= lines.length && computeLineHash(line, lines[line - 1], hashLen) === hash) {
69
+ return null;
36
70
  }
37
- if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
38
- return {
39
- kind: "anchor",
40
- message: `line ${line} changed or hash is wrong: re-read to get the current #HASH`,
41
- };
71
+
72
+ // Shifted recovery: scan ±radius (excluding the already-failed cited line).
73
+ const candidates: { line: number; hash: string }[] = [];
74
+ const lo = Math.max(1, line - radius);
75
+ const hi = Math.min(lines.length, line + radius);
76
+ for (let c = lo; c <= hi; c++) {
77
+ if (c === line) continue;
78
+ if (computeLineHash(line, lines[c - 1], hashLen) === hash) {
79
+ candidates.push({ line: c, hash: computeLineHash(c, lines[c - 1], hashLen) });
80
+ }
81
+ }
82
+
83
+ let recovery: AnchorRecovery;
84
+ if (candidates.length === 1) {
85
+ recovery = { kind: "found", newLine: candidates[0].line, newHash: candidates[0].hash };
86
+ } else if (candidates.length > 1) {
87
+ recovery = { kind: "ambiguous", candidates };
88
+ } else {
89
+ recovery = { kind: "none" };
42
90
  }
43
- return null;
91
+
92
+ const current =
93
+ line >= 1 && line <= lines.length
94
+ ? { hash: computeLineHash(line, lines[line - 1], hashLen), content: lines[line - 1] }
95
+ : null;
96
+
97
+ return { opIndex, which, op, cited, recovery, current };
44
98
  }
45
99
 
100
+ type TranslateResult =
101
+ | { readonly ok: true; readonly op: SpanOp }
102
+ | { readonly ok: false; readonly anchorFailures: AnchorFailure[] }
103
+ | { readonly ok: false; readonly rangeError: string };
104
+
46
105
  /** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
47
- function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): { op: SpanOp } | { error: PatchError } {
106
+ function translateEdit(
107
+ edit: Edit,
108
+ opIndex: number,
109
+ lines: readonly string[],
110
+ hashLen: number,
111
+ radius: number,
112
+ ): TranslateResult {
48
113
  switch (edit.op) {
49
114
  case "replace":
50
115
  case "delete": {
51
- const startErr = checkAnchor(lines, edit.start.line, edit.start.hash, hashLen);
52
- if (startErr) return { error: startErr };
116
+ const failures: AnchorFailure[] = [];
117
+ const startF = verifyAnchor(lines, edit.start, "anchor", opIndex, edit.op, hashLen, radius);
118
+ if (startF) failures.push(startF);
53
119
  let endLine = edit.start.line;
54
120
  if (edit.end) {
55
- const endErr = checkAnchor(lines, edit.end.line, edit.end.hash, hashLen);
56
- if (endErr) return { error: endErr };
121
+ const endF = verifyAnchor(lines, edit.end, "end", opIndex, edit.op, hashLen, radius);
122
+ if (endF) failures.push(endF);
57
123
  endLine = edit.end.line;
58
124
  }
125
+ if (failures.length > 0) return { ok: false, anchorFailures: failures };
59
126
  if (endLine < edit.start.line) {
60
- return { error: { kind: "range", message: `range ${edit.start.line}..${endLine} ends before it starts` } };
127
+ return { ok: false, rangeError: `range ${edit.start.line}..${endLine} ends before it starts` };
61
128
  }
62
129
  return {
130
+ ok: true,
63
131
  op: {
64
132
  lo: edit.start.line - 1,
65
133
  hi: endLine,
@@ -68,20 +136,20 @@ function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): {
68
136
  };
69
137
  }
70
138
  case "insert_after": {
71
- const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
72
- if (err) return { error: err };
73
- return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
139
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
140
+ if (f) return { ok: false, anchorFailures: [f] };
141
+ return { ok: true, op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
74
142
  }
75
143
  case "insert_before": {
76
- const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
77
- if (err) return { error: err };
78
- return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
144
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
145
+ if (f) return { ok: false, anchorFailures: [f] };
146
+ return { ok: true, op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
79
147
  }
80
148
  case "append": {
81
- return { op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
149
+ return { ok: true, op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
82
150
  }
83
151
  case "prepend": {
84
- return { op: { lo: 0, hi: 0, newLines: edit.body } };
152
+ return { ok: true, op: { lo: 0, hi: 0, newLines: edit.body } };
85
153
  }
86
154
  }
87
155
  }
@@ -94,21 +162,40 @@ function maxAffected(op: SpanOp): number {
94
162
  /**
95
163
  * Apply edits to `text`. Anchors are verified against the current content; on
96
164
  * success `touchedLines` gives the 0-based indices of the new-file lines this
97
- * edit produced.
165
+ * edit produced. On any anchor mismatch, all failures (with shifted recovery)
166
+ * are collected and returned together — nothing is written.
98
167
  *
99
- * @param text current full file text
100
- * @param edits parsed edit operations
101
- * @param hashLen hash length used to verify anchors (default 4)
168
+ * @param text current full file text
169
+ * @param edits parsed edit operations
170
+ * @param hashLen hash length used to verify anchors (default 4)
171
+ * @param shiftRadius ±line radius for shifted-anchor recovery (default 15; 0 disables rescue)
102
172
  */
103
- export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
173
+ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius = DEFAULT_SHIFT_RADIUS): ApplyResult {
104
174
  const lines = splitLines(text);
105
175
  const ending = detectLineEnding(text);
106
176
 
107
177
  const ops: SpanOp[] = [];
108
- for (const edit of edits) {
109
- const t = translateEdit(edit, lines, hashLen);
110
- if ("error" in t) return { ok: false, error: t.error };
111
- ops.push(t.op);
178
+ const anchorFailures: AnchorFailure[] = [];
179
+ let rangeError: string | null = null;
180
+
181
+ for (let i = 0; i < edits.length; i++) {
182
+ const t = translateEdit(edits[i], i, lines, hashLen, shiftRadius);
183
+ if (t.ok) {
184
+ ops.push(t.op);
185
+ } else if ("anchorFailures" in t) {
186
+ anchorFailures.push(...t.anchorFailures);
187
+ } else if (rangeError === null) {
188
+ rangeError = t.rangeError;
189
+ }
190
+ }
191
+
192
+ // Anchor failures take priority: the model must fix anchors first; range
193
+ // issues among surviving ops are premature until anchors are corrected.
194
+ if (anchorFailures.length > 0) {
195
+ return { ok: false, failure: { kind: "anchor", failures: anchorFailures } };
196
+ }
197
+ if (rangeError !== null) {
198
+ return { ok: false, failure: { kind: "range", message: rangeError } };
112
199
  }
113
200
 
114
201
  // Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
@@ -117,7 +204,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
117
204
  if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
118
205
  return {
119
206
  ok: false,
120
- error: {
207
+ failure: {
121
208
  kind: "range",
122
209
  message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
123
210
  },
@@ -135,7 +222,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
135
222
  if (newText === text) {
136
223
  return {
137
224
  ok: false,
138
- error: {
225
+ failure: {
139
226
  kind: "noop",
140
227
  message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
141
228
  },
package/src/core/hash.ts CHANGED
@@ -18,10 +18,6 @@
18
18
  * - Content alone would leave identical lines (blank lines, `}`) sharing a
19
19
  * hash; mixing the line number disambiguates them for free.
20
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.
24
- *
25
21
  * @module pi-hashline-edit/core
26
22
  */
27
23
 
package/src/core/types.ts CHANGED
@@ -25,22 +25,57 @@ export type Edit =
25
25
 
26
26
  export type LineEnding = "lf" | "crlf";
27
27
 
28
- /** Error kinds. */
29
- export type PatchErrorKind =
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.)
32
- | "noop"; // edit produced no change (body byte-identical to the target)
28
+ /**
29
+ * Outcome of shifted-anchor recovery. When a cited anchor's hash no longer
30
+ * matches the live content, the applicator rescans ±radius lines for the
31
+ * original content holding the ORIGINAL line number fixed and re-hashing each
32
+ * candidate's content (`computeLineHash(citedLine, candidateContent) === citedHash`
33
+ * iff the candidate is the original content). A ready-to-resend anchor (with the
34
+ * freshly computed hash) is returned so the model can retry without a re-read.
35
+ *
36
+ * - `found` — exactly one nearby line holds the original content; resend the op
37
+ * with the provided anchor.
38
+ * - `ambiguous` — several nearby lines match (e.g. duplicate content); the model
39
+ * picks the right one from the candidates (each carries its own new hash).
40
+ * - `none` — the content genuinely changed; re-read.
41
+ */
42
+ export type AnchorRecovery =
43
+ | { readonly kind: "found"; readonly newLine: number; readonly newHash: string }
44
+ | {
45
+ readonly kind: "ambiguous";
46
+ readonly candidates: ReadonlyArray<{ readonly line: number; readonly hash: string }>;
47
+ }
48
+ | { readonly kind: "none" };
33
49
 
34
- export interface PatchError {
35
- readonly kind: PatchErrorKind;
36
- readonly message: string;
50
+ /**
51
+ * A single anchor that failed verification, with its recovery attempt.
52
+ *
53
+ * `opIndex` is the 0-based position in the input `edits[]`; `which` names the
54
+ * op's anchor (`"anchor"` = start, `"end"` = range end); `op` is the op kind.
55
+ * `current` is the cited line's live content + hash (null if the line number is
56
+ * out of range) — surfaced when recovery is `none` so the model can self-diagnose.
57
+ */
58
+ export interface AnchorFailure {
59
+ readonly opIndex: number;
60
+ readonly which: "anchor" | "end";
61
+ readonly op: Edit["op"];
62
+ readonly cited: Anchor;
63
+ readonly recovery: AnchorRecovery;
64
+ readonly current: { readonly hash: string; readonly content: string } | null;
37
65
  }
38
66
 
67
+ /** Batch-level failure. `anchor` carries every per-anchor failure collected across the batch. */
68
+ export type ApplyFailure =
69
+ | { readonly kind: "anchor"; readonly failures: readonly AnchorFailure[] }
70
+ | { readonly kind: "range"; readonly message: string }
71
+ | { readonly kind: "noop"; readonly message: string };
72
+
39
73
  /**
40
74
  * Apply result. On success, `touchedLines` lists the 0-based line indices in
41
75
  * the NEW file that this edit produced (inserted or replaced) — callers use it
42
76
  * to surface fresh `LINE#HASH` anchors so the model can chain edits without a
43
- * re-read.
77
+ * re-read. On failure, `failure` is either the collected set of anchor failures
78
+ * (each with recovery) or a single range/noop error; nothing is written.
44
79
  */
45
80
  export type ApplyResult =
46
81
  | {
@@ -49,4 +84,4 @@ export type ApplyResult =
49
84
  readonly changed: boolean;
50
85
  readonly touchedLines: readonly number[];
51
86
  }
52
- | { readonly ok: false; readonly error: PatchError };
87
+ | { readonly ok: false; readonly failure: ApplyFailure };
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { loadConfig } from "./pi/config.ts";
14
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
+ import { makeGrepOverride } from "./pi/grep-tool.ts";
17
18
 
18
19
  export default function (pi: ExtensionAPI) {
19
20
  const cwd = process.cwd();
@@ -26,4 +27,5 @@ export default function (pi: ExtensionAPI) {
26
27
 
27
28
  pi.registerTool(makeReadOverride(cwd));
28
29
  pi.registerTool(makeEditOverride(cwd));
30
+ pi.registerTool(makeGrepOverride(cwd));
29
31
  }
package/src/pi/config.ts CHANGED
@@ -15,9 +15,11 @@ export interface HashlineEditConfig {
15
15
  enabled: boolean;
16
16
  /** Line hash length (default 4). */
17
17
  hashLen: number;
18
+ /** ±line radius for shifted-anchor recovery (default 15; 0 disables rescue). */
19
+ shiftRadius: number;
18
20
  }
19
21
 
20
- const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
22
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
21
23
 
22
24
  function getAgentDir(): string {
23
25
  const envDir = process.env.PI_AGENT_DIR;
@@ -49,5 +51,9 @@ export function loadConfig(cwd?: string): HashlineEditConfig {
49
51
  typeof raw.hashLen === "number" && raw.hashLen >= 2 && raw.hashLen <= 8
50
52
  ? raw.hashLen
51
53
  : DEFAULT_CONFIG.hashLen,
54
+ shiftRadius:
55
+ typeof raw.shiftRadius === "number" && raw.shiftRadius >= 0 && raw.shiftRadius <= 100
56
+ ? raw.shiftRadius
57
+ : DEFAULT_CONFIG.shiftRadius,
52
58
  };
53
59
  }
@@ -25,7 +25,7 @@ import { Text } from "@earendil-works/pi-tui";
25
25
  import { readFile, writeFile } from "node:fs/promises";
26
26
  import { applyEdits, hashFileLines } from "../core/index.ts";
27
27
  import { splitLines } from "../core/lines.ts";
28
- import type { Edit, PatchError } from "../core/types.ts";
28
+ import type { ApplyFailure, Edit } from "../core/types.ts";
29
29
  import { canonicalPath } from "./read-tool.ts";
30
30
  import { getState } from "./state.ts";
31
31
 
@@ -61,16 +61,59 @@ const editSchema = Type.Object({
61
61
 
62
62
  type EditOpInput = Static<typeof editOpSchema>;
63
63
 
64
- /** Turn a PatchError into helpful hint text for the model. */
65
- function errorText(e: PatchError, path: string): string {
66
- switch (e.kind) {
67
- case "anchor":
68
- return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
69
- case "range":
70
- return `Bad range: ${e.message}`;
71
- case "noop":
72
- return `Edit produced no change: ${e.message}`;
64
+ /**
65
+ * Turn an ApplyFailure into LLM-facing text. The FIRST line is a terse summary
66
+ * (the TUI's renderResult shows only the first line of an error result); the
67
+ * remaining lines carry the structured detail the model needs to retry without a
68
+ * re-read rescued anchors / ambiguous candidates / the cited line's live
69
+ * content. range/noop are already terse single-line messages.
70
+ */
71
+ function formatFailure(failure: ApplyFailure, path: string): string {
72
+ if (failure.kind === "range") return failure.message;
73
+ if (failure.kind === "noop") return failure.message;
74
+
75
+ const lines: string[] = [];
76
+ let found = 0;
77
+ let ambiguous = 0;
78
+ let none = 0;
79
+ for (const f of failure.failures) {
80
+ const where = `op #${f.opIndex} ${f.op} ${f.which} (line ${f.cited.line})`;
81
+ switch (f.recovery.kind) {
82
+ case "found": {
83
+ found++;
84
+ lines.push(
85
+ `• ${where}: content shifted to line ${f.recovery.newLine}. Resend this op with ${f.which} { "line": ${f.recovery.newLine}, "hash": "${f.recovery.newHash}" }.`,
86
+ );
87
+ break;
88
+ }
89
+ case "ambiguous": {
90
+ ambiguous++;
91
+ const nums = f.recovery.candidates.map((c) => c.line).join(", ");
92
+ const list = f.recovery.candidates
93
+ .map((c) => `{ "line": ${c.line}, "hash": "${c.hash}" }`)
94
+ .join(" / ");
95
+ lines.push(
96
+ `• ${where}: ambiguous — same content at lines ${nums}. Pick the right one and resend ${f.which} ${list}.`,
97
+ );
98
+ break;
99
+ }
100
+ case "none": {
101
+ none++;
102
+ const cur =
103
+ f.current != null
104
+ ? `current line ${f.cited.line}: ${f.cited.line}#${f.current.hash}│${f.current.content}`
105
+ : `line ${f.cited.line} is out of range`;
106
+ lines.push(`• ${where}: not found nearby — content changed. ${cur}. Re-read ${path} for fresh anchors.`);
107
+ break;
108
+ }
109
+ }
73
110
  }
111
+ const parts: string[] = [];
112
+ if (found) parts.push(`${found} rescued`);
113
+ if (ambiguous) parts.push(`${ambiguous} ambiguous`);
114
+ if (none) parts.push(`${none} need re-read`);
115
+ const brief = `Anchor mismatch: ${parts.join(", ")}.`;
116
+ return `${brief}\n${lines.join("\n")}`;
74
117
  }
75
118
 
76
119
  /** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
@@ -103,13 +146,16 @@ function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } |
103
146
  return { ok: true, edits };
104
147
  }
105
148
 
106
- /** Build an error result (isError: true so the TUI/agent loop treats it as a failure). */
107
- function errResult(text: string) {
108
- return {
109
- isError: true as const,
110
- content: [{ type: "text" as const, text }],
111
- details: undefined,
112
- };
149
+ /**
150
+ * Fail the edit by throwing. pi's contract: a tool failure is signaled by throwing,
151
+ * not by returning `{ isError: true }` — the framework derives `context.isError` from
152
+ * whether execute threw, and overwrites `result.isError` with it
153
+ * (`updateResult({ ...result, isError: event.isError })`). Returning an isError object
154
+ * left the TUI rendering failures as success (green). The thrown message reaches the
155
+ * LLM verbatim; renderResult shows its first line in red.
156
+ */
157
+ function errResult(text: string): never {
158
+ throw new Error(text);
113
159
  }
114
160
 
115
161
  /**
@@ -198,7 +244,7 @@ export function makeEditOverride(cwd: string) {
198
244
  }
199
245
 
200
246
  async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
201
- const hashLen = getState().config.hashLen;
247
+ const { hashLen, shiftRadius } = getState().config;
202
248
 
203
249
  let currentText: string;
204
250
  try {
@@ -214,11 +260,13 @@ async function runHashline(absPath: string, displayPath: string, editOps: readon
214
260
  if (!translated.ok) return errResult(translated.error);
215
261
 
216
262
  // 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);
263
+ // hash the model didn't actually read) fails its own anchor — but first we try
264
+ // shifted recovery: if the content merely moved within ±shiftRadius, a fresh
265
+ // anchor is returned so the model can retry without a re-read. All failures in
266
+ // the batch are collected (nothing written on any failure).
267
+ const result = applyEdits(currentText, translated.edits, hashLen, shiftRadius);
220
268
  if (!result.ok) {
221
- return errResult(errorText(result.error, displayPath));
269
+ return errResult(formatFailure(result.failure, displayPath));
222
270
  }
223
271
 
224
272
  // Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Override grep: search results carry `LINE#HASH│` anchors (same format as
3
+ * read), grouped by file. The model can copy `LINE#HASH` straight into an edit
4
+ * anchor — no re-read needed. Context lines (`-C`) are anchored too.
5
+ *
6
+ * We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
7
+ * control formatting and can compute each line's hash from its FULL content
8
+ * while displaying a truncated copy. (The built-in grep truncates long lines
9
+ * before formatting; hashing that truncated text would not match what edit
10
+ * verifies against the full line — so the hash must be computed from the full
11
+ * content, independently of what is displayed.)
12
+ *
13
+ * Falls back to the built-in grep when: hashline disabled, aborted, or ripgrep
14
+ * cannot be located.
15
+ *
16
+ * @module pi-hashline-edit/pi
17
+ */
18
+
19
+ import {
20
+ createGrepTool,
21
+ truncateHead,
22
+ truncateLine,
23
+ formatSize,
24
+ DEFAULT_MAX_BYTES,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { Text } from "@earendil-works/pi-tui";
27
+ import { spawn } from "node:child_process";
28
+ import { createInterface } from "node:readline";
29
+ import { access, constants, readFile, stat } from "node:fs/promises";
30
+ import { basename, delimiter, join, relative } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { hashFileLines } from "../core/hash.ts";
33
+ import { splitLines } from "../core/lines.ts";
34
+ import { getState } from "./state.ts";
35
+ import { canonicalPath } from "./read-tool.ts";
36
+
37
+ const DEFAULT_LIMIT = 100;
38
+ /** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
39
+ const GREP_MAX_LINE_LENGTH = 500;
40
+
41
+ /** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
42
+ async function findRg(): Promise<string | null> {
43
+ const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
44
+ const piRg = join(agentDir, "bin", "rg");
45
+ try {
46
+ await access(piRg, constants.X_OK);
47
+ return piRg;
48
+ } catch {}
49
+ for (const dir of process.env.PATH?.split(delimiter) ?? []) {
50
+ if (!dir) continue;
51
+ const p = join(dir, "rg");
52
+ try {
53
+ await access(p, constants.X_OK);
54
+ return p;
55
+ } catch {}
56
+ }
57
+ return null;
58
+ }
59
+
60
+ interface RawMatch {
61
+ filePath: string;
62
+ lineNumber: number;
63
+ match: boolean;
64
+ }
65
+
66
+ /**
67
+ * Convert the anchored grep output (grouped, `LINE#HASH│`) into a human-readable
68
+ * form for the TUI: drop the hash, keep file headers and line numbers. The model
69
+ * still receives the anchored `content` text; this only affects what the user sees.
70
+ */
71
+ function toDisplayLines(raw: string, theme: any): string[] {
72
+ const out: string[] = [];
73
+ for (const line of raw.split("\n")) {
74
+ // anchored line first: "lineNo#HASH│content" → " lineNo: content"
75
+ const a = line.match(/^(\d+)#[A-Za-z0-9]+│(.*)$/);
76
+ if (a) {
77
+ out.push(theme.fg("dim", ` ${a[1]}:`) + theme.fg("toolOutput", ` ${a[2]}`));
78
+ continue;
79
+ }
80
+ // file header: "path · N match(es)" → path accent, count dim
81
+ const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
82
+ if (h) {
83
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
84
+ continue;
85
+ }
86
+ // truncation notice block "[...]"
87
+ if (line.startsWith("[")) {
88
+ out.push(theme.fg("warning", line));
89
+ continue;
90
+ }
91
+ out.push(theme.fg("toolOutput", line));
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Build the grep override (a ToolDefinition fragment for registerTool). */
97
+ export function makeGrepOverride(cwd: string) {
98
+ const builtin = createGrepTool(cwd);
99
+ const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
100
+ builtin.execute(toolCallId, params, signal, onUpdate);
101
+
102
+ return {
103
+ name: "grep" as const,
104
+ label: "grep",
105
+ description:
106
+ "Search file contents for a pattern. Matches show per-line content hashes (LINE#HASH│content) grouped by file — copy LINE#HASH straight into an edit anchor, no re-read needed. Context lines (context) are anchored too. Respects .gitignore.",
107
+ promptSnippet: "Search file contents; results show LINE#HASH anchors usable directly in edit (no re-read needed)",
108
+ promptGuidelines: [
109
+ "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
110
+ "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
111
+ "Pass `pattern`; optionally `path`, `glob`, `ignoreCase`, `literal`, `context` (lines before+after each match), `limit` (max matches, default 100).",
112
+ ],
113
+ parameters: builtin.parameters,
114
+
115
+ renderShell: "default" as const,
116
+
117
+ renderCall(args: any, theme: any) {
118
+ const pattern = args?.pattern ?? "";
119
+ const p = args?.path ?? ".";
120
+ let text =
121
+ theme.fg("toolTitle", theme.bold("grep ")) +
122
+ theme.fg("accent", `/${pattern}/`) +
123
+ theme.fg("toolOutput", ` in ${p}`);
124
+ if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
125
+ if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
126
+ return new Text(text, 0, 0);
127
+ },
128
+
129
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
130
+ if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
131
+ if (context?.isError) {
132
+ const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
133
+ return new Text(theme.fg("error", t), 0, 0);
134
+ }
135
+ const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
136
+ const styled = toDisplayLines(out, theme);
137
+ const maxLines = expanded ? styled.length : 15;
138
+ const shown = styled.slice(0, maxLines);
139
+ const more =
140
+ !expanded && styled.length > maxLines
141
+ ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
142
+ : "";
143
+ return new Text(shown.join("\n") + more, 0, 0);
144
+ },
145
+
146
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
147
+ const state = getState();
148
+ // disabled or already aborted → built-in grep (it handles abort itself)
149
+ if (!state.config.enabled || signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
150
+
151
+ const rgPath = await findRg();
152
+ // ripgrep unavailable → degrade to the built-in (which can auto-download rg)
153
+ if (!rgPath) return delegate(toolCallId, params, signal, onUpdate);
154
+
155
+ const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
156
+ const searchPath = canonicalPath(cwd, searchDir || ".");
157
+ const hashLen = state.config.hashLen;
158
+
159
+ let isDir = true;
160
+ try {
161
+ isDir = (await stat(searchPath)).isDirectory();
162
+ } catch {
163
+ throw new Error(`Path not found: ${searchPath}`);
164
+ }
165
+
166
+ return new Promise((resolvePromise, reject) => {
167
+ if (signal?.aborted) {
168
+ reject(new Error("Operation aborted"));
169
+ return;
170
+ }
171
+
172
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
173
+ if (ignoreCase) args.push("--ignore-case");
174
+ if (literal) args.push("--fixed-strings");
175
+ if (glob) args.push("--glob", glob);
176
+ const ctx = context && context > 0 ? context : 0;
177
+ if (ctx > 0) args.push("--context", String(ctx));
178
+ args.push("--", String(pattern), searchPath);
179
+
180
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
181
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
182
+ const rl = createInterface({ input: child.stdout });
183
+ let stderr = "";
184
+ let matchCount = 0;
185
+ let matchLimitReached = false;
186
+ let linesTruncated = false;
187
+ let aborted = false;
188
+ let killedDueToLimit = false;
189
+ const raw: RawMatch[] = [];
190
+
191
+ const cleanup = () => {
192
+ rl.close();
193
+ signal?.removeEventListener("abort", onAbort);
194
+ };
195
+ const stopChild = (dueToLimit = false) => {
196
+ if (!child.killed) {
197
+ killedDueToLimit = dueToLimit;
198
+ child.kill();
199
+ }
200
+ };
201
+ const onAbort = () => {
202
+ aborted = true;
203
+ stopChild();
204
+ };
205
+ signal?.addEventListener("abort", onAbort, { once: true });
206
+ child.stderr?.on("data", (chunk: Buffer) => {
207
+ stderr += chunk.toString();
208
+ });
209
+
210
+ rl.on("line", (line: string) => {
211
+ if (!line.trim() || matchCount >= effectiveLimit) return;
212
+ let event: any;
213
+ try {
214
+ event = JSON.parse(line);
215
+ } catch {
216
+ return;
217
+ }
218
+ if (event.type === "match") {
219
+ matchCount++;
220
+ const filePath = event.data?.path?.text;
221
+ const lineNumber = event.data?.line_number;
222
+ if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: true });
223
+ if (matchCount >= effectiveLimit) {
224
+ matchLimitReached = true;
225
+ stopChild(true);
226
+ }
227
+ } else if (event.type === "context") {
228
+ const filePath = event.data?.path?.text;
229
+ const lineNumber = event.data?.line_number;
230
+ if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: false });
231
+ }
232
+ });
233
+
234
+ child.on("error", (error) => {
235
+ cleanup();
236
+ reject(new Error(`Failed to run ripgrep: ${error.message}`));
237
+ });
238
+
239
+ child.on("close", async (code) => {
240
+ cleanup();
241
+ if (aborted) {
242
+ reject(new Error("Operation aborted"));
243
+ return;
244
+ }
245
+ if (!killedDueToLimit && code !== 0 && code !== 1) {
246
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
247
+ return;
248
+ }
249
+ if (raw.length === 0) {
250
+ resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
251
+ return;
252
+ }
253
+
254
+ // Dedupe by (file, line); a line that is both a match and a context line counts as a match.
255
+ const map = new Map<string, RawMatch>();
256
+ for (const m of raw) {
257
+ const key = `${m.filePath}:${m.lineNumber}`;
258
+ const prev = map.get(key);
259
+ if (!prev || (!prev.match && m.match)) map.set(key, m);
260
+ }
261
+
262
+ // Group by file, each group sorted by line number.
263
+ const byFile = new Map<string, RawMatch[]>();
264
+ for (const m of map.values()) {
265
+ const arr = byFile.get(m.filePath) ?? [];
266
+ arr.push(m);
267
+ byFile.set(m.filePath, arr);
268
+ }
269
+ for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
270
+
271
+ // Read each file once and hash all its lines; hash is computed from the FULL line.
272
+ const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
273
+ const getFile = async (fp: string) => {
274
+ let entry = fileCache.get(fp);
275
+ if (!entry) {
276
+ let content = "";
277
+ try {
278
+ content = (await readFile(fp)).toString("utf-8");
279
+ } catch {
280
+ content = "";
281
+ }
282
+ const lines = splitLines(content);
283
+ entry = { lines, hashes: hashFileLines(lines, hashLen) };
284
+ fileCache.set(fp, entry);
285
+ }
286
+ return entry;
287
+ };
288
+
289
+ const formatPath = (fp: string): string => {
290
+ if (isDir) {
291
+ const rel = relative(searchPath, fp).replace(/\\/g, "/");
292
+ if (rel && !rel.startsWith("..")) return rel;
293
+ }
294
+ return basename(fp);
295
+ };
296
+
297
+ const blocks: string[] = [];
298
+ for (const [fp, matches] of byFile) {
299
+ const { lines, hashes } = await getFile(fp);
300
+ const n = matches.filter((m) => m.match).length;
301
+ const header = `${formatPath(fp)} · ${n} match${n !== 1 ? "es" : ""}`;
302
+ const rows: string[] = [];
303
+ for (const m of matches) {
304
+ const content = lines[m.lineNumber - 1] ?? "";
305
+ const hash = hashes[m.lineNumber - 1] ?? "";
306
+ const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
307
+ if (wasTruncated) linesTruncated = true;
308
+ rows.push(`${m.lineNumber}#${hash}│${disp}`);
309
+ }
310
+ blocks.push(`${header}\n${rows.join("\n")}`);
311
+ }
312
+
313
+ let output = blocks.join("\n\n");
314
+ const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
315
+ output = truncation.content;
316
+
317
+ const notices: string[] = [];
318
+ if (matchLimitReached)
319
+ notices.push(
320
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
321
+ );
322
+ if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
323
+ if (linesTruncated)
324
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
325
+ if (notices.length) output += `\n\n[${notices.join(". ")}]`;
326
+
327
+ resolvePromise({
328
+ content: [{ type: "text" as const, text: output }],
329
+ details: undefined,
330
+ });
331
+ });
332
+ });
333
+ },
334
+ };
335
+ }
package/src/pi/pi.test.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { canonicalPath } from "./read-tool.ts";
4
+ import { homedir } from "node:os";
4
5
 
5
6
  test("canonicalPath resolves relative and absolute", () => {
6
7
  assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
7
8
  assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
8
9
  assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
9
10
  });
11
+
12
+ test("canonicalPath expands ~ to home directory", () => {
13
+ const home = homedir();
14
+ assert.equal(canonicalPath("/cwd", "~"), home);
15
+ assert.equal(canonicalPath("/cwd", "~/foo.ts"), `${home}/foo.ts`);
16
+ });
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { createReadTool } from "@earendil-works/pi-coding-agent";
13
13
  import { readFile } from "node:fs/promises";
14
- import { resolve } from "node:path";
14
+ import { join, resolve } from "node:path";
15
+ import { homedir } from "node:os";
15
16
  import { hashFileLines } from "../core/hash.ts";
16
17
  import { splitLines } from "../core/lines.ts";
17
18
  import { getState } from "./state.ts";
@@ -19,9 +20,21 @@ import { getState } from "./state.ts";
19
20
  const MAX_LINES = 2000;
20
21
  const MAX_BYTES = 256 * 1024;
21
22
 
22
- /** canonical path: shared by read/edit to resolve a file consistently. */
23
+ /**
24
+ * Canonical absolute path: shared by read/edit/grep to resolve a file consistently.
25
+ * Expands a leading `~` / `~/` to the user's home directory. (`~user` is not supported.)
26
+ */
23
27
  export function canonicalPath(cwd: string, p: string): string {
24
- return resolve(cwd, p);
28
+ return resolve(cwd, expandTilde(p));
29
+ }
30
+
31
+ /** Mirrors pi core's `normalizePath` tilde handling: expands `~` / `~/` (and `~\` on Windows), leaves `~user` untouched. */
32
+ function expandTilde(p: string): string {
33
+ if (p === "~") return homedir();
34
+ if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\"))) {
35
+ return join(homedir(), p.slice(2));
36
+ }
37
+ return p;
25
38
  }
26
39
 
27
40
  /** Build the read override (a ToolDefinition fragment for registerTool). */
package/src/pi/state.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import type { HashlineEditConfig } from "./config.ts";
11
11
 
12
12
  const GLOBAL_KEY = "__piHashlineEdit";
13
- const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
13
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
14
14
 
15
15
  export interface HashlineEditState {
16
16
  config: HashlineEditConfig;