pi-hashline-edit-pro 2.7.1 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -33
- package/index.ts +2 -3
- package/package.json +1 -1
- package/prompts/grep-guidelines.md +3 -2
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -2
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -4
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +3 -2
- package/prompts/undo-last-change.md +1 -1
- package/src/constants.ts +4 -2
- package/src/grep.ts +119 -15
- package/src/hash-store.ts +5 -1
- package/src/hashline/hash.ts +10 -5
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +2 -2
- package/src/hashline/resolve.ts +6 -6
- package/src/insert.ts +2 -2
- package/src/read.ts +17 -10
- package/src/replace-diff.ts +92 -8
- package/src/replace-render.ts +32 -6
- package/src/replace-response.ts +3 -1
- package/src/replace-undo.ts +4 -2
- package/src/replace.ts +3 -2
- package/src/utils.ts +13 -0
package/README.md
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-hashline-edit-pro) [](https://www.npmjs.com/package/pi-hashline-edit-pro)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Anchor-based `read`, `replace`, `insert`, and `grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
|
|
6
6
|
|
|
7
|
-
Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character
|
|
7
|
+
Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character anchors and collision resolution.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- `read` returns every line as `
|
|
12
|
-
- `replace` targets a range of
|
|
13
|
-
- `insert` adds lines after or before a line by
|
|
14
|
-
- `grep` returns matching lines (and requested context) with `
|
|
15
|
-
- Editing one part of a file leaves the
|
|
16
|
-
- After a `write` you get the new anchors. After a `replace` or `insert` you get the diff with the new
|
|
11
|
+
- `read` returns every line as `anchor│content`. The anchor is the line's address.
|
|
12
|
+
- `replace` targets a range of anchors, so edits land on the lines you meant.
|
|
13
|
+
- `insert` adds lines after or before a line by anchor: the anchor line is preserved and the new lines are applied literally, never deduplicated.
|
|
14
|
+
- `grep` returns matching lines (and requested context) with `anchor│content` rows that are served like read output, so search results are immediately editable.
|
|
15
|
+
- Editing one part of a file leaves the anchors of the rest unchanged, so anchors from an earlier read stay valid across edits.
|
|
16
|
+
- After a `write` you get the new anchors. After a `replace` or `insert` you get the diff with the new anchors.
|
|
17
17
|
- The most recent replace or insert on a file can be reverted, even after a restart.
|
|
18
18
|
- Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
|
|
19
19
|
|
|
@@ -27,7 +27,7 @@ szJ│ console.log("world");
|
|
|
27
27
|
kQm│}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
2. Replace a line by its
|
|
30
|
+
2. Replace a line by its anchor:
|
|
31
31
|
|
|
32
32
|
```json
|
|
33
33
|
{
|
|
@@ -54,7 +54,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
54
54
|
|
|
55
55
|
## The read tool
|
|
56
56
|
|
|
57
|
-
`read` returns a text file with every line prefixed by `
|
|
57
|
+
`read` returns a text file with every line prefixed by `anchor│content`. The anchor is 3 characters from `A-Za-z0-9` (for example `aB3`).
|
|
58
58
|
|
|
59
59
|
| Parameter | Description |
|
|
60
60
|
| --- | --- |
|
|
@@ -63,20 +63,20 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
63
63
|
|
|
64
64
|
Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
|
|
65
65
|
|
|
66
|
-
Lines up to
|
|
66
|
+
Lines up to 50KB are shown in full. A larger line is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced via that anchor; `grep` shows an anchored fragment around a match on such a line instead.
|
|
67
67
|
|
|
68
68
|
Edge cases:
|
|
69
69
|
|
|
70
70
|
- Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
|
|
71
71
|
- Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text. The NUL-byte check covers the whole file, not just the sampled bytes: a file with a NUL byte anywhere is rejected as binary.
|
|
72
72
|
- UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
|
|
73
|
-
- Empty files come back as a single empty-line
|
|
73
|
+
- Empty files come back as a single empty-line anchor (`anchor│`); use `replace` on that anchor to insert content.
|
|
74
74
|
- BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
|
|
75
75
|
- Files over 238,328 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
|
|
76
76
|
|
|
77
77
|
## The replace tool
|
|
78
78
|
|
|
79
|
-
The built-in `edit` tool is disabled. `replace` and `insert` are the only edit paths, and both take the
|
|
79
|
+
The built-in `edit` tool is disabled. `replace` and `insert` are the only edit paths, and both take the anchors from `read` output.
|
|
80
80
|
|
|
81
81
|
One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
|
|
82
82
|
|
|
@@ -91,17 +91,17 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
|
|
|
91
91
|
|
|
92
92
|
| Field | Description |
|
|
93
93
|
| --- | --- |
|
|
94
|
-
| `remove_from` | 3-char
|
|
95
|
-
| `remove_to` | 3-char
|
|
94
|
+
| `remove_from` | 3-char anchor from `read` output marking the FIRST line to remove (inclusive). |
|
|
95
|
+
| `remove_to` | 3-char anchor from `read` output marking the LAST line to remove (inclusive). |
|
|
96
96
|
| `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line. |
|
|
97
97
|
|
|
98
98
|
Notes:
|
|
99
99
|
|
|
100
100
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
101
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `
|
|
102
|
-
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `
|
|
103
|
-
- Every line in the removed range must match what was last shown to you. The extension records the `
|
|
104
|
-
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
101
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `anchor│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file. The whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all five tools.
|
|
102
|
+
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `anchor│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
|
|
103
|
+
- Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, the auto-read block after `write`, the `+anchor│`/` anchor│` rows of post-edit diffs (replace, insert, and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback) and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
|
|
104
|
+
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading. The diff is capped at 50KB: a row longer than 50KB is shown as a marker that keeps the row's anchor (so the line stays editable via the diff), and when the total cap is hit the diff ends with a truncation note. Only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch` (which is flagged with `details.patchTruncated` when it was cut and can no longer be applied as-is).
|
|
105
105
|
- Do not issue multiple replace or insert calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
|
|
106
106
|
|
|
107
107
|
## The insert tool
|
|
@@ -119,7 +119,7 @@ Notes:
|
|
|
119
119
|
|
|
120
120
|
| Field | Description |
|
|
121
121
|
| --- | --- |
|
|
122
|
-
| `anchor` | 3-char
|
|
122
|
+
| `anchor` | 3-char anchor from `read` output marking the line next to which the lines go (inclusive; the line is preserved). A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. |
|
|
123
123
|
| `direction` | `"after"` to insert below the anchor line, `"before"` to insert above it. |
|
|
124
124
|
| `lines` | Lines to insert as an array of strings, one element per line. Mirror `replacement_lines` semantics: use `[""]` for a blank line and do not embed `\n` inside an element. The anchor line is never part of `lines`. |
|
|
125
125
|
|
|
@@ -127,13 +127,13 @@ Notes:
|
|
|
127
127
|
|
|
128
128
|
- The anchor line must have been shown to you (read output, a post-edit diff row, grep output, or stale-range feedback). The same verification as `replace` applies: a stale or unshown anchor is rejected with `[E_STALE_ANCHOR]`, `[E_AMBIGUOUS_ANCHOR]`, or `[E_RANGE_STALE]` and the retry needs no `read`.
|
|
129
129
|
- Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`.
|
|
130
|
-
- To seed an empty file, read it and insert after the `
|
|
130
|
+
- To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
131
131
|
- The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), line endings and BOMs survive, and an applied insert clears a pending boundary bypass.
|
|
132
132
|
- Inserting nothing (`lines: []`) reports a noop and leaves the file unchanged; inserted lines are never deduplicated.
|
|
133
133
|
|
|
134
134
|
## The grep tool
|
|
135
135
|
|
|
136
|
-
`grep` replaces the built-in grep with
|
|
136
|
+
`grep` replaces the built-in grep with an anchored search. Every matching line (and each requested context line) is returned as an `anchor│content` row, and those rows are recorded in the served state exactly like `read` output, so you can target them with `replace` or `insert` immediately without a separate `read`.
|
|
137
137
|
|
|
138
138
|
| Field | Description |
|
|
139
139
|
| --- | --- |
|
|
@@ -148,7 +148,7 @@ Notes:
|
|
|
148
148
|
Notes:
|
|
149
149
|
- Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
|
|
150
150
|
- Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
|
|
151
|
-
- Output is capped at `limit` matched lines
|
|
151
|
+
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
|
|
152
152
|
- `file_path` works as an alias for `path`.
|
|
153
153
|
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
154
154
|
- Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
|
|
@@ -167,10 +167,10 @@ Notes:
|
|
|
167
167
|
|
|
168
168
|
## Auto-read
|
|
169
169
|
|
|
170
|
-
Enabled by default. After a successful `write
|
|
170
|
+
Enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
171
171
|
|
|
172
|
-
- After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. The `+
|
|
173
|
-
- Auto-read keeps
|
|
172
|
+
- After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, so you can see exactly which anchors were deleted (those anchors are stale after the edit). When the context line touching a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
173
|
+
- Auto-read keeps the same 50KB / 2000-line budget as `read`. Lines over 50KB are shown as markers that keep the line's anchor (use `grep` for a fragment around a match).
|
|
174
174
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
175
175
|
|
|
176
176
|
## Tool result details
|
|
@@ -178,9 +178,9 @@ Enabled by default. After a successful `write` that changes the file, the extens
|
|
|
178
178
|
All five tools return machine-readable metadata in `details` alongside the model-visible text:
|
|
179
179
|
|
|
180
180
|
- `read`: `details.truncation` (set when the output was truncated), `details.snapshotId` (a `v2|path|ino|mtime|ctime|size` fingerprint of the file), `details.nextOffset` (use as the next `offset`), and `details.metrics` with `truncated` and `next_offset`.
|
|
181
|
-
- `replace` and `insert`: `details.diff` (the post-edit diff; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
|
|
182
|
-
- `undo_last_change`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes), and `details.metrics` (same shape as `replace`).
|
|
183
|
-
- `grep`: `details.metrics` with `matches
|
|
181
|
+
- `replace` and `insert`: `details.diff` (the post-edit diff, capped at 50KB with markers for oversized rows; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools, capped at 50KB like the diff), `details.patchTruncated` (true when the patch was cut to fit the cap and cannot be applied as-is), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
|
|
182
|
+
- `undo_last_change`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes, capped at 50KB like `replace`), `details.patchTruncated` (true when the patch was cut), and `details.metrics` (same shape as `replace`).
|
|
183
|
+
- `grep`: `details.metrics` with `matches` (matched lines found, capped at `limit`), `files`, and `truncated` (true when the row, byte, file-scan, or `limit` cap cut the results), plus `details.truncation` (the standard pi truncation report — `truncatedBy`, `totalLines`, `outputLines`, `maxLines`, `maxBytes`, … — when the output was cut) and `details.linesTruncated` (true when long lines were shown as fragments).
|
|
184
184
|
|
|
185
185
|
## Settings
|
|
186
186
|
|
|
@@ -198,7 +198,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
198
198
|
|
|
199
199
|
## How anchors work
|
|
200
200
|
|
|
201
|
-
Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9`, which gives 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace.
|
|
201
|
+
Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9`, which gives 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace. A line longer than 500 bytes is hashed from its first 500 bytes; uniqueness is still guaranteed by the collision-resolution below.
|
|
202
202
|
|
|
203
203
|
The alphabet is sized for an LLM consumer: the model reads the hashes as tokens rather than inspecting glyph shapes, so letters and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded. A hash starting with `-` looks like a diff-preview deletion row, and `-`/`_` at the start of a line are markdown-active, which invites mis-copying and false autocorrections.
|
|
204
204
|
|
|
@@ -220,11 +220,11 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
220
220
|
| Code | Meaning |
|
|
221
221
|
| --- | --- |
|
|
222
222
|
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
|
|
223
|
-
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char
|
|
223
|
+
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char anchor. |
|
|
224
224
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
225
225
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
226
|
-
| `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+
|
|
227
|
-
| `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with
|
|
226
|
+
| `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
227
|
+
| `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix (the anchor plus the separator). The prefix is stripped automatically with a warning. |
|
|
228
228
|
| `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
|
|
229
229
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
230
230
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { initHasher } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
@@ -9,7 +9,6 @@ import { regRead, fmtReadPreview } from "./src/read";
|
|
|
9
9
|
import type { RMetrics } from "./src/replace-response";
|
|
10
10
|
import { extractWarnings } from "./src/replace-render";
|
|
11
11
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
12
|
-
import { AUTO_READ_MAX } from "./src/constants";
|
|
13
12
|
import {
|
|
14
13
|
readConfig,
|
|
15
14
|
toggleAutoRead,
|
|
@@ -93,7 +92,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
93
92
|
fileHashes,
|
|
94
93
|
absolutePath,
|
|
95
94
|
DEFAULT_MAX_BYTES,
|
|
96
|
-
|
|
95
|
+
DEFAULT_MAX_LINES,
|
|
97
96
|
);
|
|
98
97
|
await recordServedSafe(absolutePath, preview.servedHashes, "auto-read", new Set(fileHashes));
|
|
99
98
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
- `grep`: results carry
|
|
1
|
+
- `grep`: results carry anchors recorded like read output, so you can target them with replace or insert immediately.
|
|
2
2
|
- `grep`: pass `path` for a single file or directory; the default is the current working directory. Directory searches skip node_modules, .git, .tmp, and coverage.
|
|
3
3
|
- `grep`: use `literal: true` when the pattern contains regex metacharacters you want matched literally.
|
|
4
4
|
- `grep`: use `context` to see surrounding lines; context rows carry anchors too.
|
|
5
5
|
- `grep`: use `glob` to filter files; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`.
|
|
6
|
-
- `grep`: results are capped at `limit` matches (default 100)
|
|
6
|
+
- `grep`: results are capped at `limit` matches (default 100), 2000 rows, and 50KB; refine the pattern or raise limit to see more.
|
|
7
|
+
- `grep`: a matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides; the row keeps its anchor and is editable with replace (which replaces the whole line). Use read to see the full line.
|
package/prompts/grep-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search file contents; matching lines carry
|
|
1
|
+
Search file contents; matching lines carry anchors usable in replace/insert without a re-read
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files for a pattern. Returns every matching line (and the requested context lines) as `
|
|
1
|
+
Search text files for a pattern. Returns every matching line (and the requested context lines) as `anchor│content` rows, so the results can be used directly as replace and insert anchors without a separate read. Directory searches skip node_modules, .git, .tmp, and coverage. Binary, image, and oversized files are skipped silently. Output is capped at `limit` matches (default 100), 2000 rows, and 50KB; a matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, and the row keeps its anchor (long lines are hashed from their first 500 bytes), so the match is still editable.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
- `insert`: anchor takes ONLY the bare 3-char
|
|
1
|
+
- `insert`: anchor takes ONLY the bare 3-char anchor of the line next to which the new lines go: read row `ve7│function hello() {` means `"anchor": "ve7"`. Never paste the line content or the whole `anchor│content` row.
|
|
2
2
|
- `insert`: the anchor line is preserved. Include only the new lines in `lines`, never the anchor line itself.
|
|
3
3
|
- `insert`: use `direction: "after"` to add lines after the anchor line, `"before"` to add them before it.
|
|
4
4
|
- `insert`: read the file first, so the anchor line was shown to you. Use a post-edit diff row or grep output for follow-up inserts.
|
|
5
|
-
- `insert`: to seed an empty file, read it and insert after the `
|
|
5
|
+
- `insert`: to seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
6
6
|
- `insert`: lines are applied literally — never deduplicated — so restating a neighbor is safe and has no effect on the result.
|
|
7
|
+
- `insert`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after or before a line in a text file via bare 3-char
|
|
1
|
+
Insert lines after or before a line in a text file via bare 3-char anchor from read: the anchor line stays, new lines go after/before it, applied literally
|
package/prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after or before an existing line in a text file, targeted by
|
|
1
|
+
Insert lines after or before an existing line in a text file, targeted by 3-character anchors from read output. The anchor line is preserved; the new lines are added after it (direction "after") or before it (direction "before"). Each element of lines is exactly one line; do not embed \n inside an element: use separate elements. Use [""] for a blank line. The lines you send are added literally: nothing is removed, and lines that duplicate their neighbors are kept. The post-edit diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
- `read`: call before `replace` when you need fresh
|
|
1
|
+
- `read`: call before `replace` when you need fresh anchors for a file.
|
|
2
2
|
- `read`: call again after an edit when you need anchors you do not have. The post-edit diff after replace/insert and the anchored rows after grep already carry fresh anchors for the changed range.
|
package/prompts/read-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a file; each line returned as
|
|
1
|
+
Read a file; each line returned as anchor│content
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file; each line returned as
|
|
1
|
+
Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor. No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
- `replace`: remove_from and remove_to take ONLY the bare 3-char
|
|
1
|
+
- `replace`: remove_from and remove_to take ONLY the bare 3-char anchor: read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `anchor│content` row into these fields.
|
|
2
2
|
- `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_lines is deleted from the file.
|
|
3
3
|
- `replace`: keep the range as tight as the change: anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
|
|
4
|
-
- `replace`: to replace a single line, use the same
|
|
5
|
-
- `replace`: when copying a line from read output, remove its
|
|
4
|
+
- `replace`: to replace a single line, use the same anchor for both remove_from and remove_to (e.g. remove_from: "<ANCHOR>", remove_to: "<ANCHOR>").
|
|
5
|
+
- `replace`: when copying a line from read output, remove its `anchor│` prefix and keep the leading whitespace exactly as shown.
|
|
6
6
|
- `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line.
|
|
7
|
-
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file: `+
|
|
7
|
+
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file: `+anchor│` and ` anchor│` rows carry current anchors and unchanged lines keep their previous anchors, so you can anchor follow-up edits on the diff without re-reading.
|
|
8
8
|
- `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
|
|
9
|
+
- `replace`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines in a text file via bare 3-char
|
|
1
|
+
Replace lines in a text file via bare 3-char anchors from read: anchor only, never line content; anchor exactly the lines that change; one edit per tool call
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. remove_from and remove_to must each be a BARE anchor: copy only the anchor from the leftmost column of a read row (row `ve7│function hello() {` means `"remove_from": "ve7"`). Never pass the line content, a code line, or a paragraph into these fields. The post-edit diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
- `undo_last_change`: reverts only the most recent replace or insert on the file: any write to the file clears the undo history, so call it immediately after a bad edit. An edit is bad when its post-edit diff shows `-
|
|
2
|
-
- `undo_last_change`: when auto-read shows the post-edit diff, its `+
|
|
1
|
+
- `undo_last_change`: reverts only the most recent replace or insert on the file: any write to the file clears the undo history, so call it immediately after a bad edit. An edit is bad when its post-edit diff shows `-anchor│` rows for lines you meant to keep (a closing brace, import, or declaration).
|
|
2
|
+
- `undo_last_change`: when auto-read shows the post-edit diff, its `+anchor│` and ` anchor│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
|
|
3
3
|
- `undo_last_change`: if the file was deleted since the edit, the undo restores it from the recorded pre-edit content; if the file was modified since the edit, the undo is refused with `[E_UNDO_STALE]` and the record is kept, so reverting the external change makes the undo succeed.
|
|
4
|
+
- `undo_last_change`: the undo diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). If the file was deleted since the edit, the undo restores it from the recorded content. If the file was modified since the edit, the undo is refused and the record is kept until the file matches the edited state again.
|
|
1
|
+
Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). If the file was deleted since the edit, the undo restores it from the recorded content. If the file was modified since the edit, the undo is refused and the record is kept until the file matches the edited state again. The undo diff is capped at 50KB: rows longer than 50KB are shown as markers that keep the row's anchor, and the diff ends with a truncation note when the cap is hit; call read for anything not shown.
|
package/src/constants.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export const AUTO_READ_MAX = 2000;
|
|
2
1
|
export const SNIFF_BYTES = 8192;
|
|
3
2
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
|
-
export const MAX_READ_LINE_BYTES = 200 * 1024;
|
|
5
3
|
export const MAX_RANGE_STALE_LINES = 100;
|
|
4
|
+
export const MAX_OVERSIZED_WARNING_LINES = 100;
|
|
5
|
+
|
|
6
|
+
export const MAX_HASH_SOURCE_BYTES = 500;
|
|
7
|
+
export const MAX_GREP_LINE_BYTES = 500;
|
|
6
8
|
|
|
7
9
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
8
10
|
export const HASH_STORE_VERSION = 5;
|
package/src/grep.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import { readdir, stat } from "fs/promises";
|
|
4
5
|
import { dirname, join, relative } from "path";
|
|
5
6
|
import { loadFileKindAndText } from "./file-kind";
|
|
6
7
|
import { readNormFile } from "./file-reader";
|
|
7
|
-
import { MAX_HASH_LINES, fmtRow } from "./hashline";
|
|
8
|
+
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
|
|
9
|
+
import { MAX_GREP_LINE_BYTES } from "./constants";
|
|
8
10
|
import { toCwd } from "./paths";
|
|
9
11
|
import { loadP, loadGuide } from "./prompts";
|
|
10
12
|
import { normReq } from "./replace-normalize";
|
|
11
13
|
import { recordServedSafe } from "./served";
|
|
12
|
-
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
|
|
14
|
+
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
13
15
|
|
|
14
16
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
15
17
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
16
18
|
const MAX_SCAN_FILES = 4000;
|
|
17
|
-
const MAX_SHOWN_ROWS = 2000;
|
|
18
19
|
|
|
19
20
|
export interface GrepReq {
|
|
20
21
|
pattern: string;
|
|
@@ -92,6 +93,42 @@ interface FileHit {
|
|
|
92
93
|
hashes: string[];
|
|
93
94
|
matchCount: number;
|
|
94
95
|
totalMatchCount: number;
|
|
96
|
+
fragmented: boolean[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const GREP_ROW_OVERHEAD_BYTES = HASH_LEN + Buffer.byteLength(HASH_SEP, "utf-8");
|
|
100
|
+
const GREP_ROW_CONTENT_BYTES = MAX_GREP_LINE_BYTES - GREP_ROW_OVERHEAD_BYTES;
|
|
101
|
+
|
|
102
|
+
function snapCharBoundaries(line: string, start: number, end: number): [number, number] {
|
|
103
|
+
let s = start;
|
|
104
|
+
let e = end;
|
|
105
|
+
if (s > 0 && s < line.length) {
|
|
106
|
+
const c = line.charCodeAt(s);
|
|
107
|
+
if (c >= 0xdc00 && c <= 0xdfff && line.charCodeAt(s - 1) >= 0xd800 && line.charCodeAt(s - 1) <= 0xdbff) s -= 1;
|
|
108
|
+
}
|
|
109
|
+
if (e > 0 && e < line.length) {
|
|
110
|
+
const c = line.charCodeAt(e - 1);
|
|
111
|
+
if (c >= 0xd800 && c <= 0xdbff && line.charCodeAt(e) >= 0xdc00 && line.charCodeAt(e) <= 0xdfff) e += 1;
|
|
112
|
+
}
|
|
113
|
+
return [s, e];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function grepMatchFragment(line: string, regex: RegExp): string {
|
|
117
|
+
const m = regex.exec(line);
|
|
118
|
+
const matchStart = m?.index ?? 0;
|
|
119
|
+
const matchLen = m?.[0].length ?? 0;
|
|
120
|
+
const budget = GREP_ROW_CONTENT_BYTES - 6;
|
|
121
|
+
const half = Math.floor((budget - Math.min(matchLen, budget)) / 2);
|
|
122
|
+
const [start, end] = snapCharBoundaries(line, Math.max(0, matchStart - half), Math.min(line.length, matchStart + matchLen + half));
|
|
123
|
+
const content = truncateToBytes(line.slice(start, end), budget);
|
|
124
|
+
const lead = start > 0 ? "..." : "";
|
|
125
|
+
const tail = end < line.length ? "..." : "";
|
|
126
|
+
return truncateToBytes(`${lead}${content}${tail}`, GREP_ROW_CONTENT_BYTES);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function grepHeadFragment(line: string): string {
|
|
130
|
+
const head = truncateToBytes(line, GREP_ROW_CONTENT_BYTES - 3);
|
|
131
|
+
return head.length < line.length ? `${head}...` : head;
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
interface ScanState {
|
|
@@ -172,11 +209,24 @@ async function searchFile(
|
|
|
172
209
|
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
173
210
|
}
|
|
174
211
|
const sorted = [...shown].sort((a, b) => a - b);
|
|
212
|
+
const matchSet = new Set(matchLines);
|
|
175
213
|
const rows: string[] = [];
|
|
176
214
|
const hashes: string[] = [];
|
|
215
|
+
const fragmented: boolean[] = [];
|
|
177
216
|
for (const idx of sorted) {
|
|
178
|
-
|
|
179
|
-
|
|
217
|
+
const hash = norm.fileHashes[idx]!;
|
|
218
|
+
const line = lines[idx]!;
|
|
219
|
+
const row = fmtRow(hash, line);
|
|
220
|
+
if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
|
|
221
|
+
const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
|
|
222
|
+
rows.push(fmtRow(hash, content));
|
|
223
|
+
hashes.push(hash);
|
|
224
|
+
fragmented.push(true);
|
|
225
|
+
} else {
|
|
226
|
+
rows.push(row);
|
|
227
|
+
hashes.push(hash);
|
|
228
|
+
fragmented.push(false);
|
|
229
|
+
}
|
|
180
230
|
}
|
|
181
231
|
return {
|
|
182
232
|
path: norm.absolutePath,
|
|
@@ -186,6 +236,7 @@ async function searchFile(
|
|
|
186
236
|
hashes,
|
|
187
237
|
matchCount: keptMatches.length,
|
|
188
238
|
totalMatchCount: matchLines.length,
|
|
239
|
+
fragmented,
|
|
189
240
|
};
|
|
190
241
|
}
|
|
191
242
|
|
|
@@ -274,8 +325,29 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
274
325
|
let limitTruncated = false;
|
|
275
326
|
let rowTruncated = false;
|
|
276
327
|
let rowCount = 0;
|
|
277
|
-
|
|
328
|
+
let byteCount = 0;
|
|
329
|
+
let totalRows = 0;
|
|
330
|
+
let totalBytes = 0;
|
|
331
|
+
let truncatedBy: "lines" | "bytes" | null = null;
|
|
332
|
+
let linesReplaced = 0;
|
|
333
|
+
let countOnly = false;
|
|
334
|
+
for (let f = 0; f < files.length; f++) {
|
|
278
335
|
abortIf(signal);
|
|
336
|
+
const absPath = files[f]!;
|
|
337
|
+
if (countOnly) {
|
|
338
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER);
|
|
339
|
+
if (!hit) continue;
|
|
340
|
+
totalRows += hit.rows.length;
|
|
341
|
+
for (const row of hit.rows) totalBytes += Buffer.byteLength(row, "utf-8") + 1;
|
|
342
|
+
const remaining = limit - matches;
|
|
343
|
+
if (remaining > 0) {
|
|
344
|
+
matches += Math.min(hit.matchCount, remaining);
|
|
345
|
+
if (hit.matchCount > remaining) limitTruncated = true;
|
|
346
|
+
} else {
|
|
347
|
+
limitTruncated = true;
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
279
351
|
const remaining = limit - matches;
|
|
280
352
|
if (remaining <= 0) {
|
|
281
353
|
limitTruncated = true;
|
|
@@ -283,18 +355,32 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
283
355
|
}
|
|
284
356
|
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
|
|
285
357
|
if (!hit) continue;
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
358
|
+
const keptRows: string[] = [];
|
|
359
|
+
const keptHashes: string[] = [];
|
|
360
|
+
for (let i = 0; i < hit.rows.length; i++) {
|
|
361
|
+
const row = hit.rows[i]!;
|
|
362
|
+
const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
|
|
363
|
+
if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
|
|
364
|
+
rowTruncated = true;
|
|
365
|
+
if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
|
|
366
|
+
for (let j = i; j < hit.rows.length; j++) {
|
|
367
|
+
totalRows += 1;
|
|
368
|
+
totalBytes += Buffer.byteLength(hit.rows[j]!, "utf-8") + 1;
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
keptRows.push(row);
|
|
373
|
+
keptHashes.push(hit.hashes[i]);
|
|
374
|
+
if (hit.fragmented[i]) linesReplaced += 1;
|
|
375
|
+
rowCount += 1;
|
|
376
|
+
byteCount += rowBytes;
|
|
377
|
+
totalRows += 1;
|
|
378
|
+
totalBytes += rowBytes;
|
|
290
379
|
}
|
|
291
|
-
const keptRows = hit.rows.slice(0, rowBudget);
|
|
292
|
-
const keptHashes = hit.hashes.slice(0, rowBudget);
|
|
293
|
-
rowCount += keptRows.length;
|
|
294
380
|
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
295
|
-
if (keptRows.length < hit.rows.length) rowTruncated = true;
|
|
296
381
|
matches += hit.matchCount;
|
|
297
382
|
hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
|
|
383
|
+
if (rowTruncated) countOnly = true;
|
|
298
384
|
}
|
|
299
385
|
for (const hit of hits) {
|
|
300
386
|
await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
|
|
@@ -303,14 +389,32 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
303
389
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
304
390
|
.join("\n");
|
|
305
391
|
const notes: string[] = [];
|
|
306
|
-
if (rowTruncated) notes.push(`[grep: output truncated at ${
|
|
392
|
+
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
307
393
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
308
394
|
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
395
|
+
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
309
396
|
const truncated = limitTruncated || rowTruncated;
|
|
397
|
+
const truncation: TruncationResult | undefined = rowTruncated
|
|
398
|
+
? {
|
|
399
|
+
content: blocks,
|
|
400
|
+
truncated: true,
|
|
401
|
+
truncatedBy,
|
|
402
|
+
totalLines: totalRows,
|
|
403
|
+
totalBytes,
|
|
404
|
+
outputLines: rowCount,
|
|
405
|
+
outputBytes: byteCount,
|
|
406
|
+
lastLinePartial: false,
|
|
407
|
+
firstLineExceedsLimit: false,
|
|
408
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
409
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
410
|
+
}
|
|
411
|
+
: undefined;
|
|
310
412
|
const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
|
|
311
413
|
return {
|
|
312
414
|
content: [{ type: "text", text }],
|
|
313
415
|
details: {
|
|
416
|
+
...(truncation ? { truncation } : {}),
|
|
417
|
+
...(linesReplaced > 0 ? { linesTruncated: true as const } : {}),
|
|
314
418
|
metrics: {
|
|
315
419
|
matches,
|
|
316
420
|
files: hits.length,
|
package/src/hash-store.ts
CHANGED
|
@@ -570,7 +570,11 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
|
|
|
570
570
|
try {
|
|
571
571
|
await stat(row.path);
|
|
572
572
|
return undefined;
|
|
573
|
-
} catch {
|
|
573
|
+
} catch (error: unknown) {
|
|
574
|
+
if (errCode(error) !== "ENOENT") {
|
|
575
|
+
console.error("Failed to stat hash store path:", row.path, error);
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
574
578
|
return row.path;
|
|
575
579
|
}
|
|
576
580
|
}),
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { splitLines } from "../utils";
|
|
1
|
+
import { splitLines, truncateToBytes } from "../utils";
|
|
2
|
+
import { MAX_HASH_SOURCE_BYTES } from "../constants";
|
|
2
3
|
import {
|
|
3
4
|
loadHashStore,
|
|
4
5
|
type HashStore,
|
|
@@ -75,6 +76,10 @@ export function canon(line: string): string {
|
|
|
75
76
|
return line.replace(/\r/g, "").trimEnd();
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
export function hashSource(line: string): string {
|
|
80
|
+
return truncateToBytes(canon(line), MAX_HASH_SOURCE_BYTES);
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
|
|
79
84
|
|
|
80
85
|
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
@@ -117,7 +122,7 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
117
122
|
const hint = { value: 0 };
|
|
118
123
|
|
|
119
124
|
for (let i = 0; i < lines.length; i++) {
|
|
120
|
-
const c =
|
|
125
|
+
const c = hashSource(lines[i]!);
|
|
121
126
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
122
127
|
hashes[i] = assignHash(used, baseIdx, hint);
|
|
123
128
|
}
|
|
@@ -255,7 +260,7 @@ function mapStableHashes(
|
|
|
255
260
|
|
|
256
261
|
const newByContent = new Map<string, number[]>();
|
|
257
262
|
for (let i = 0; i < newLines.length; i++) {
|
|
258
|
-
const key =
|
|
263
|
+
const key = hashSource(newLines[i]!);
|
|
259
264
|
const list = newByContent.get(key);
|
|
260
265
|
if (list) list.push(i);
|
|
261
266
|
else newByContent.set(key, [i]);
|
|
@@ -270,7 +275,7 @@ function mapStableHashes(
|
|
|
270
275
|
};
|
|
271
276
|
|
|
272
277
|
for (const entry of survivors) {
|
|
273
|
-
const candidates = newByContent.get(
|
|
278
|
+
const candidates = newByContent.get(hashSource(oldLines[entry.index]!));
|
|
274
279
|
if (!candidates || candidates.length === 0) continue;
|
|
275
280
|
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
276
281
|
const pos = nearestNew(candidates, target);
|
|
@@ -301,7 +306,7 @@ function mapStableHashes(
|
|
|
301
306
|
|
|
302
307
|
for (let i = 0; i < newLines.length; i++) {
|
|
303
308
|
if (newHashes[i]) continue;
|
|
304
|
-
const c =
|
|
309
|
+
const c = hashSource(newLines[i]!);
|
|
305
310
|
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
306
311
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
307
312
|
}
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -14,11 +14,11 @@ function diagRef(ref: string): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
if (/^\d+/.test(trimmed)) {
|
|
17
|
-
return `[E_BAD_REF] Invalid anchor. Use the
|
|
17
|
+
return `[E_BAD_REF] Invalid anchor. Use the anchor alone (e.g. "aB3"): no line numbers or trailing content.`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (trimmed.includes("│")) {
|
|
21
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char
|
|
21
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char anchor, drop everything from "│" onward.`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -152,12 +152,12 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
152
152
|
|
|
153
153
|
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
154
154
|
throw new Error(
|
|
155
|
-
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char
|
|
155
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (3-char anchor).`,
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
159
159
|
throw new Error(
|
|
160
|
-
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char
|
|
160
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char anchor).`,
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
163
|
if (!("replacement_lines" in edit)) {
|
|
@@ -168,7 +168,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
168
168
|
}
|
|
169
169
|
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
170
170
|
throw new Error(
|
|
171
|
-
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char
|
|
171
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (3-char anchors from read output).`,
|
|
172
172
|
);
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -187,7 +187,7 @@ export function stripAnchorRow(
|
|
|
187
187
|
? "diff-preview marker"
|
|
188
188
|
: match[1] === "-"
|
|
189
189
|
? 'leading "-" marker'
|
|
190
|
-
: '"
|
|
190
|
+
: '"anchor│" prefix';
|
|
191
191
|
warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
|
|
192
192
|
return match[2]!;
|
|
193
193
|
}
|
|
@@ -239,7 +239,7 @@ export function stripBarePrefixes(
|
|
|
239
239
|
? " Verify it was pasted from read output."
|
|
240
240
|
: "";
|
|
241
241
|
warnings.push(
|
|
242
|
-
`[E_BARE_HASH_PREFIX] Stripped "
|
|
242
|
+
`[E_BARE_HASH_PREFIX] Stripped "anchor│" prefix from ${locations}.${guidance}`
|
|
243
243
|
);
|
|
244
244
|
return { ...edit, content_lines: contentLines };
|
|
245
245
|
}
|
|
@@ -546,7 +546,7 @@ export function assertRangeServed(
|
|
|
546
546
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
547
547
|
const hash = fileHashes[line - 1]!;
|
|
548
548
|
shownHashes.push(hash);
|
|
549
|
-
rows.push(fmtRow(hash, fileLines[line - 1]));
|
|
549
|
+
rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
|
|
550
550
|
}
|
|
551
551
|
const location = filePath ? ` in ${filePath}` : "";
|
|
552
552
|
const first = mismatchLines[0]!;
|
package/src/insert.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
34
34
|
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
35
35
|
}
|
|
36
36
|
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
37
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char
|
|
37
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char anchor from read output).');
|
|
38
38
|
}
|
|
39
39
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
40
40
|
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
@@ -52,7 +52,7 @@ const insertToolSchema = Type.Object(
|
|
|
52
52
|
}),
|
|
53
53
|
anchor: Type.String({
|
|
54
54
|
description:
|
|
55
|
-
'Bare 3-char
|
|
55
|
+
'Bare 3-char anchor only (e.g. "aB3"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
|
|
56
56
|
}),
|
|
57
57
|
direction: Type.Union(
|
|
58
58
|
[
|
package/src/read.ts
CHANGED
|
@@ -3,14 +3,15 @@ import {
|
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
5
|
truncateHead,
|
|
6
|
+
DEFAULT_MAX_BYTES,
|
|
6
7
|
DEFAULT_MAX_LINES,
|
|
7
8
|
type TruncationResult,
|
|
8
9
|
} from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { Type } from "typebox";
|
|
10
|
-
import { MAX_READ_LINE_BYTES } from "./constants";
|
|
11
11
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
|
+
import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
|
|
12
13
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
13
|
-
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
|
+
import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
15
|
import { toCwd } from "./paths";
|
|
15
16
|
import { abortIf, makePrepareArguments, visLines } from "./utils";
|
|
16
17
|
import { recordServedSafe } from "./served";
|
|
@@ -56,7 +57,7 @@ export async function fmtReadPreview(
|
|
|
56
57
|
options: { offset?: number; limit?: number },
|
|
57
58
|
precomputedHashes?: string[],
|
|
58
59
|
path?: string,
|
|
59
|
-
maxLineBytes =
|
|
60
|
+
maxLineBytes = DEFAULT_MAX_BYTES,
|
|
60
61
|
maxTruncLines = DEFAULT_MAX_LINES,
|
|
61
62
|
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number; servedHashes: string[] }> {
|
|
62
63
|
const allLines = visLines(text);
|
|
@@ -100,21 +101,27 @@ export async function fmtReadPreview(
|
|
|
100
101
|
const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
|
|
101
102
|
const rows = rowSizes.map((row, index) =>
|
|
102
103
|
row.bytes > maxBytes
|
|
103
|
-
? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
|
|
104
|
+
? fmtRow(selectedHashes[index]!, `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`)
|
|
104
105
|
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
105
106
|
);
|
|
106
107
|
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
|
|
107
108
|
const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
|
|
108
109
|
const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
|
|
109
|
-
const oversizedIndexes = new Set(rowSizes.map((row, index) => row.bytes > maxBytes ? index : -1).filter((index) => index >= 0));
|
|
110
110
|
const servedHashes: string[] = [];
|
|
111
111
|
for (let index = 0; index < Math.min(shownRowCount, rows.length); index++) {
|
|
112
|
-
|
|
112
|
+
servedHashes.push(selectedHashes[index]!);
|
|
113
113
|
}
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
114
|
+
const listed = oversized.slice(0, MAX_OVERSIZED_WARNING_LINES);
|
|
115
|
+
const hiddenCount = oversized.length - listed.length;
|
|
116
|
+
const lineLabel = oversized.length === 1
|
|
117
|
+
? `Line ${oversized[0]!.lineNumber}`
|
|
118
|
+
: `Lines ${listed.map((row) => row.lineNumber).join(', ')}${hiddenCount > 0 ? `, ... (+${hiddenCount} more)` : ''}`;
|
|
119
|
+
const verb = oversized.length === 1 ? 'exceeds' : 'exceed';
|
|
120
|
+
const addresses = listed.map((row) => `${row.lineNumber}p`).join(';');
|
|
121
|
+
const moreHint = hiddenCount > 0
|
|
122
|
+
? ` ${hiddenCount} more oversized line(s). Use read with offset to inspect them.`
|
|
123
|
+
: '';
|
|
124
|
+
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}${moreHint}]`;
|
|
118
125
|
let preview = skippedTruncation.content;
|
|
119
126
|
let nextOffset: number | undefined;
|
|
120
127
|
if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
package/src/replace-diff.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Diff from "diff";
|
|
2
|
+
import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
_lineHashesPure,
|
|
4
5
|
ANCHOR_LEN,
|
|
@@ -55,14 +56,23 @@ const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
|
55
56
|
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
56
57
|
line === ELLIPSIS_MARKER;
|
|
57
58
|
|
|
59
|
+
export interface DiffLimits {
|
|
60
|
+
maxLineBytes?: number;
|
|
61
|
+
maxBytes?: number;
|
|
62
|
+
unlimited?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
export function genDiff(
|
|
59
66
|
oldContent: string,
|
|
60
67
|
newContent: string,
|
|
61
68
|
contextLines = 2,
|
|
62
69
|
newContentHashes?: string[],
|
|
63
70
|
oldContentHashes?: string[],
|
|
71
|
+
limits?: DiffLimits,
|
|
64
72
|
): { diff: string; firstChangedLine: number | undefined } {
|
|
65
73
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
74
|
+
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
75
|
+
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
66
76
|
|
|
67
77
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
68
78
|
const output: string[] = [];
|
|
@@ -70,8 +80,42 @@ export function genDiff(
|
|
|
70
80
|
let oldLineNum = 1;
|
|
71
81
|
let lastWasChange = false;
|
|
72
82
|
let firstChangedLine: number | undefined;
|
|
83
|
+
let outBytes = 0;
|
|
84
|
+
let stopped = false;
|
|
85
|
+
let diffTruncated = false;
|
|
86
|
+
|
|
87
|
+
const emitPlain = (line: string): void => {
|
|
88
|
+
if (stopped) return;
|
|
89
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
90
|
+
if (outBytes + lineBytes > maxBytes) {
|
|
91
|
+
stopped = true;
|
|
92
|
+
diffTruncated = true;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
outBytes += lineBytes;
|
|
96
|
+
output.push(line);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined): void => {
|
|
100
|
+
if (stopped) return;
|
|
101
|
+
const full = fmtDiffLine(prefix, line, hash);
|
|
102
|
+
const rowBytes = Buffer.byteLength(full, "utf-8");
|
|
103
|
+
if (rowBytes > maxLineBytes) {
|
|
104
|
+
const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
105
|
+
emitPlain(fmtDiffLine(prefix, marker, hash));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (outBytes + rowBytes + 1 > maxBytes) {
|
|
109
|
+
stopped = true;
|
|
110
|
+
diffTruncated = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
outBytes += rowBytes + 1;
|
|
114
|
+
output.push(full);
|
|
115
|
+
};
|
|
73
116
|
|
|
74
117
|
for (let i = 0; i < parts.length; i++) {
|
|
118
|
+
if (stopped) break;
|
|
75
119
|
const part = parts[i]!;
|
|
76
120
|
const raw = part.value.split("\n");
|
|
77
121
|
if (raw[raw.length - 1] === "") raw.pop();
|
|
@@ -80,16 +124,18 @@ export function genDiff(
|
|
|
80
124
|
if (part.added || part.removed) {
|
|
81
125
|
if (firstChangedLine === undefined) firstChangedLine = newLineNum;
|
|
82
126
|
for (let k = 0; k < displayLines.length; k++) {
|
|
127
|
+
if (stopped) break;
|
|
83
128
|
if (part.added) {
|
|
84
129
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
85
|
-
|
|
130
|
+
emitRow("+", displayLines[k]!, hash);
|
|
86
131
|
newLineNum++;
|
|
87
132
|
} else {
|
|
88
133
|
const hash = oldContentHashes?.[oldLineNum - 1];
|
|
89
|
-
|
|
134
|
+
emitRow("-", displayLines[k]!, hash);
|
|
90
135
|
oldLineNum++;
|
|
91
136
|
}
|
|
92
137
|
}
|
|
138
|
+
if (stopped) break;
|
|
93
139
|
lastWasChange = true;
|
|
94
140
|
continue;
|
|
95
141
|
}
|
|
@@ -152,24 +198,25 @@ export function genDiff(
|
|
|
152
198
|
}
|
|
153
199
|
|
|
154
200
|
if (skipStart > 0) {
|
|
155
|
-
|
|
201
|
+
emitPlain(" ...");
|
|
156
202
|
newLineNum += skipStart;
|
|
157
203
|
oldLineNum += skipStart;
|
|
158
204
|
}
|
|
159
205
|
for (const line of linesToShow) {
|
|
206
|
+
if (stopped) break;
|
|
160
207
|
if (isEllipsisMarker(line)) {
|
|
161
|
-
|
|
208
|
+
emitPlain(" ...");
|
|
162
209
|
newLineNum += skipMiddle;
|
|
163
210
|
oldLineNum += skipMiddle;
|
|
164
211
|
continue;
|
|
165
212
|
}
|
|
166
213
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
167
|
-
|
|
214
|
+
emitRow(" ", line, hash);
|
|
168
215
|
newLineNum++;
|
|
169
216
|
oldLineNum++;
|
|
170
217
|
}
|
|
171
218
|
if (skipTail > 0) {
|
|
172
|
-
|
|
219
|
+
emitPlain(" ...");
|
|
173
220
|
}
|
|
174
221
|
} else {
|
|
175
222
|
newLineNum += displayLines.length;
|
|
@@ -178,6 +225,11 @@ export function genDiff(
|
|
|
178
225
|
lastWasChange = false;
|
|
179
226
|
}
|
|
180
227
|
|
|
228
|
+
if (diffTruncated) {
|
|
229
|
+
output.push(" ...");
|
|
230
|
+
output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
|
|
231
|
+
}
|
|
232
|
+
|
|
181
233
|
return { diff: output.join("\n"), firstChangedLine };
|
|
182
234
|
}
|
|
183
235
|
|
|
@@ -185,9 +237,41 @@ export function genPatch(
|
|
|
185
237
|
path: string,
|
|
186
238
|
oldContent: string,
|
|
187
239
|
newContent: string,
|
|
188
|
-
|
|
189
|
-
|
|
240
|
+
limits?: DiffLimits,
|
|
241
|
+
): { patch: string; truncated: boolean } {
|
|
242
|
+
const full = Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
|
|
190
243
|
context: 4,
|
|
191
244
|
headerOptions: Diff.FILE_HEADERS_ONLY,
|
|
192
245
|
});
|
|
246
|
+
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
247
|
+
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
248
|
+
const out: string[] = [];
|
|
249
|
+
let outBytes = 0;
|
|
250
|
+
let truncated = false;
|
|
251
|
+
for (const line of full.split("\n")) {
|
|
252
|
+
const lineBytes = Buffer.byteLength(line, "utf-8");
|
|
253
|
+
if (lineBytes > maxLineBytes) {
|
|
254
|
+
truncated = true;
|
|
255
|
+
const prefix = /^[ +-]/.test(line) ? line[0]! : "";
|
|
256
|
+
const marker = `${prefix}[Patch line is ${formatSize(lineBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
|
|
257
|
+
const markerBytes = Buffer.byteLength(marker, "utf-8") + 1;
|
|
258
|
+
if (outBytes + markerBytes > maxBytes) {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
outBytes += markerBytes;
|
|
262
|
+
out.push(marker);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (outBytes + lineBytes + 1 > maxBytes) {
|
|
266
|
+
truncated = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
outBytes += lineBytes + 1;
|
|
270
|
+
out.push(line);
|
|
271
|
+
}
|
|
272
|
+
if (truncated) {
|
|
273
|
+
out.push("...");
|
|
274
|
+
out.push(`[patch truncated at ${formatSize(maxBytes)}; the patch cannot be applied as-is. Use read to see the full file.]`);
|
|
275
|
+
}
|
|
276
|
+
return { patch: out.join("\n"), truncated };
|
|
193
277
|
}
|
package/src/replace-render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
|
-
import type
|
|
2
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { normReq } from "./replace-normalize";
|
|
4
4
|
import type { ReqParams, ReplaceDetails } from "./replace";
|
|
5
5
|
import { isRec } from "./utils";
|
|
@@ -145,20 +145,46 @@ export function isApplied(
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const RESULT_PREVIEW_LINES = 16;
|
|
149
|
+
|
|
150
|
+
function expandHint(): string {
|
|
151
|
+
try {
|
|
152
|
+
return keyHint("app.tools.expand", "to expand");
|
|
153
|
+
} catch {
|
|
154
|
+
return "ctrl+o to expand";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function extractSummary(text: string | undefined): string | undefined {
|
|
159
|
+
if (!text) return undefined;
|
|
160
|
+
if (text.includes("│")) return undefined;
|
|
161
|
+
const warningsIdx = text.indexOf("\n\nWarnings:");
|
|
162
|
+
const summary = warningsIdx >= 0 ? text.slice(0, warningsIdx) : text;
|
|
163
|
+
return summary.length > 0 ? summary : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
148
166
|
export function buildAppliedText(
|
|
149
167
|
text: string | undefined,
|
|
150
168
|
details: ReplaceDetails | undefined,
|
|
151
169
|
theme: FgT,
|
|
170
|
+
expanded: boolean,
|
|
152
171
|
): string | undefined {
|
|
153
172
|
const sections: string[] = [];
|
|
154
|
-
|
|
173
|
+
const summary = extractSummary(text);
|
|
174
|
+
if (summary) sections.push(summary);
|
|
155
175
|
if (details?.diff) {
|
|
156
|
-
|
|
176
|
+
const diffLines = details.diff.split("\n");
|
|
177
|
+
const diffSection = expanded
|
|
178
|
+
? fmtResult(details.diff, theme)
|
|
179
|
+
: fmtPreview(details.diff, false, theme);
|
|
180
|
+
const hint =
|
|
181
|
+
!expanded && diffLines.length > RESULT_PREVIEW_LINES
|
|
182
|
+
? ` (${expandHint()})`
|
|
183
|
+
: "";
|
|
184
|
+
sections.push(`${diffSection}${hint}`);
|
|
157
185
|
}
|
|
158
|
-
|
|
159
186
|
const warnings = extractWarnings(text);
|
|
160
187
|
if (warnings) sections.push(warnings);
|
|
161
|
-
|
|
162
188
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
163
189
|
}
|
|
164
190
|
|
|
@@ -317,7 +343,7 @@ export function renderEditResult(
|
|
|
317
343
|
: new Text("", 0, 0);
|
|
318
344
|
}
|
|
319
345
|
if (isApplied(result.details)) {
|
|
320
|
-
const appliedText = buildAppliedText(renderedText, result.details, theme);
|
|
346
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme, context.expanded === true);
|
|
321
347
|
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
322
348
|
}
|
|
323
349
|
if (!renderedText) return new Text("", 0, 0);
|
package/src/replace-response.ts
CHANGED
|
@@ -153,11 +153,13 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
|
153
153
|
removedLines,
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
const patchResult = genPatch(path, originalNormalized, result);
|
|
156
157
|
return {
|
|
157
158
|
content: [{ type: "text", text }],
|
|
158
159
|
details: {
|
|
159
160
|
diff: diffResult.diff,
|
|
160
|
-
patch:
|
|
161
|
+
patch: patchResult.patch,
|
|
162
|
+
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
161
163
|
firstChangedLine:
|
|
162
164
|
editMeta.firstChangedLine ?? diffResult.firstChangedLine,
|
|
163
165
|
snapshotId,
|
package/src/replace-undo.ts
CHANGED
|
@@ -149,7 +149,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
149
149
|
|
|
150
150
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
|
151
151
|
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
|
|
152
|
-
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes);
|
|
152
|
+
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
|
|
153
153
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
154
154
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
155
155
|
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
@@ -180,6 +180,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
180
180
|
"Call read for fresh anchors.",
|
|
181
181
|
);
|
|
182
182
|
|
|
183
|
+
const patchResult = genPatch(path, currentNormalized, undo.content);
|
|
183
184
|
return {
|
|
184
185
|
content: [
|
|
185
186
|
{
|
|
@@ -189,7 +190,8 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
189
190
|
],
|
|
190
191
|
details: {
|
|
191
192
|
diff: undoDiff,
|
|
192
|
-
patch:
|
|
193
|
+
patch: patchResult.patch,
|
|
194
|
+
...(patchResult.truncated ? { patchTruncated: true as const } : {}),
|
|
193
195
|
metrics: buildMetrics({
|
|
194
196
|
classification: "applied",
|
|
195
197
|
editsAttempted: 1,
|
package/src/replace.ts
CHANGED
|
@@ -49,11 +49,11 @@ const replacementLinesSchema = Type.Array(
|
|
|
49
49
|
);
|
|
50
50
|
|
|
51
51
|
const removeFromSchema = Type.String({
|
|
52
|
-
description: "Bare 3-char
|
|
52
|
+
description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
const removeToSchema = Type.String({
|
|
56
|
-
description: "Bare 3-char
|
|
56
|
+
description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
export const editToolSchema = Type.Object(
|
|
@@ -75,6 +75,7 @@ export type ReqParams = {
|
|
|
75
75
|
export type ReplaceDetails = {
|
|
76
76
|
diff: string;
|
|
77
77
|
patch?: string;
|
|
78
|
+
patchTruncated?: boolean;
|
|
78
79
|
firstChangedLine?: number;
|
|
79
80
|
snapshotId?: string;
|
|
80
81
|
classification?: "noop";
|
package/src/utils.ts
CHANGED
|
@@ -93,6 +93,19 @@ export function firstNonEmpty(lines: string[]): string | undefined {
|
|
|
93
93
|
return idx >= 0 ? lines[idx] : undefined;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
export function truncateToBytes(s: string, maxBytes: number): string {
|
|
97
|
+
if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
|
|
98
|
+
let out = "";
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
for (const ch of s) {
|
|
101
|
+
const chBytes = Buffer.byteLength(ch, "utf-8");
|
|
102
|
+
if (bytes + chBytes > maxBytes) break;
|
|
103
|
+
out += ch;
|
|
104
|
+
bytes += chBytes;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
export function clipLine(line: string, maxLen = 200): string {
|
|
97
110
|
const flat = line.replace(/\n/g, "\\n");
|
|
98
111
|
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|