@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 +63 -9
- package/package.json +1 -1
- package/src/core/apply.test.ts +98 -7
- package/src/core/apply.ts +126 -39
- package/src/core/hash.ts +0 -4
- package/src/core/types.ts +45 -10
- package/src/index.ts +2 -0
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +70 -22
- package/src/pi/grep-tool.ts +335 -0
- package/src/pi/pi.test.ts +7 -0
- package/src/pi/read-tool.ts +16 -3
- package/src/pi/state.ts +1 -1
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
|
|
10
|
-
- **Line
|
|
11
|
-
- **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
package/src/core/apply.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
52
|
-
|
|
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
|
|
56
|
-
if (
|
|
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 {
|
|
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
|
|
72
|
-
if (
|
|
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
|
|
77
|
-
if (
|
|
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
|
|
100
|
-
* @param edits
|
|
101
|
-
* @param hashLen
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
}
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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 —
|
|
218
|
-
//
|
|
219
|
-
|
|
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(
|
|
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
|
+
});
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|