pi-hashline-edit-pro 6.3.0 → 6.4.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.
Files changed (41) hide show
  1. package/README.md +32 -31
  2. package/index.ts +32 -16
  3. package/package.json +1 -1
  4. package/src/auto-read-all.ts +1 -1
  5. package/src/batch.ts +6 -4
  6. package/src/config-ui.ts +1 -1
  7. package/src/constants.ts +5 -2
  8. package/src/copy-move.ts +90 -65
  9. package/src/edit-common.ts +93 -51
  10. package/src/edit-fidelity.ts +16 -6
  11. package/src/grep.ts +22 -11
  12. package/src/insert.ts +5 -4
  13. package/src/model-gate.ts +1 -1
  14. package/src/payload-contract.ts +13 -9
  15. package/src/prompts.ts +32 -0
  16. package/src/read.ts +5 -10
  17. package/src/replace-match.ts +2 -2
  18. package/src/replace-render.ts +4 -4
  19. package/src/replace-response.ts +1 -0
  20. package/src/replace-undo.ts +2 -2
  21. package/src/replace.ts +2 -2
  22. package/src/write-hook.ts +1 -1
  23. package/tool-prompts/copy-snippet.md +1 -1
  24. package/tool-prompts/copy.md +2 -2
  25. package/tool-prompts/grep-guidelines.md +2 -1
  26. package/tool-prompts/grep-snippet.md +1 -1
  27. package/tool-prompts/grep.md +1 -1
  28. package/tool-prompts/insert-guidelines.md +1 -1
  29. package/tool-prompts/insert.md +1 -1
  30. package/tool-prompts/move-snippet.md +1 -1
  31. package/tool-prompts/move.md +2 -2
  32. package/tool-prompts/read-guidelines.md +3 -3
  33. package/tool-prompts/read.md +1 -3
  34. package/tool-prompts/replace-match-guidelines.md +1 -0
  35. package/tool-prompts/replace-match-snippet.md +1 -1
  36. package/tool-prompts/replace-match.md +1 -1
  37. package/tool-prompts/replace.md +3 -2
  38. package/tool-prompts/transfer-guidelines.md +3 -3
  39. package/tool-prompts/undo-last-change-guidelines.md +1 -1
  40. package/tool-prompts/undo-last-change-snippet.md +1 -1
  41. package/tool-prompts/undo-last-change.md +1 -1
package/README.md CHANGED
@@ -34,7 +34,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
34
34
  - [insert](#insert)
35
35
  - [copy](#copy)
36
36
  - [move](#move)
37
- - [anchor_grep](#anchor_grep)
37
+ - [grep](#grep)
38
38
  - [undo_last_change](#undo_last_change)
39
39
  - [Batching](#batching)
40
40
  - [Auto-read](#auto-read)
@@ -88,7 +88,7 @@ pi install /path/to/pi-hashline-edit-pro
88
88
  | --- | --- |
89
89
  | `read` | overridden, returns `anchor│content` rows |
90
90
  | `edit` | disabled |
91
- | `grep` | disabled while `anchor_grep` is enabled |
91
+ | `grep` | replaced by an anchored `grep`; pi's own `grep` returns when Anchor grep is off |
92
92
  | `copy`, `move` | disabled while Copy/move is off |
93
93
  | `replace_match` | disabled while Replace match is off |
94
94
  | `write` | kept; an auto-read block with fresh anchors is appended to its result |
@@ -155,7 +155,7 @@ Nothing commits until an edit call returns: the extension validates the request
155
155
 
156
156
  ## Tools
157
157
 
158
- The extension registers eight tools: `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `copy` and `move` are enabled by default; turn Copy/move off in `/hashline-config` to remove both. `replace_match` is enabled by default; turn Replace match off in `/hashline-config` to remove it. `replace`, `replace_match`, `insert`, `copy`, and `move` take no `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. Opt in with `/hashline-config` to require `path` in `replace`, `replace_match`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
158
+ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `copy` and `move` are enabled by default; turn Copy/move off in `/hashline-config` to remove both. `replace_match` is enabled by default; turn Replace match off in `/hashline-config` to remove it. `replace`, `replace_match`, `insert`, `copy`, and `move` do not need a `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. A `path` passed anyway is checked against anchor ownership: a matching path is accepted, a mismatch is rejected. Opt in with `/hashline-config` to require `path` in `replace`, `replace_match`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
159
159
 
160
160
  ### read
161
161
 
@@ -188,7 +188,7 @@ Edge cases:
188
188
  | --- | --- |
189
189
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
- | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
191
+ | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
193
  `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
194
 
@@ -211,7 +211,7 @@ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embed
211
211
 
212
212
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to the `text` field of both `replace` and `insert`.
213
213
 
214
- Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
214
+ Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
215
215
 
216
216
  An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
217
217
 
@@ -229,13 +229,13 @@ In a same-message batch it joins the other calls on its file: the batch validate
229
229
 
230
230
  ### insert
231
231
 
232
- `insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
232
+ `insert` adds lines after or before an existing line without removing anything. Like `replace`, `path` is unnecessary and must match anchor ownership when passed.
233
233
 
234
234
  | Field | Description |
235
235
  | --- | --- |
236
236
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
237
237
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
238
- | `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as `"\n"`), and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
238
+ | `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as a lone newline). A trailing line break sets the newline of the last line written instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
239
239
 
240
240
  Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `text` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
241
241
 
@@ -249,33 +249,34 @@ The same safety machinery as `replace` applies: undo is saved before the write (
249
249
 
250
250
  ### copy
251
251
 
252
- `copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source's first and last lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served; the interior of the range is transferred verbatim and does not need to have been shown. With `requirePath` on, `path` must match the source or the destination file.
252
+ `copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `anchor` and `direction` (`"before"` or `"after"`) select the destination line and side, and the anchor may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source's first and last lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served; the interior of the range is transferred verbatim and does not need to have been shown. With `requirePath` on, `path` must match the source or the destination file.
253
253
 
254
254
  | Field | Description |
255
255
  | --- | --- |
256
256
  | `source_from` | 4-char anchor marking the FIRST source line to copy (inclusive). |
257
257
  | `source_to` | 4-char anchor marking the LAST source line to copy (inclusive). |
258
- | `insert_after` | 4-char anchor of the destination line after which the copy goes. Within one file it must sit outside the source range; passing `source_to` duplicates the range right after itself. |
258
+ | `anchor` | 4-char anchor of the destination line the block goes before or after, resolved in the destination file. |
259
+ | `direction` | `"after"` places the block below the anchor line, `"before"` above it. Within one file the insertion point must fall before the source range's first line or after its last. |
259
260
 
260
261
  Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
261
262
 
262
263
  ```json
263
- { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" }
264
+ { "source_from": "Hasu", "source_to": "Hasu", "anchor": "Qwer", "direction": "after" }
264
265
  ```
265
266
 
266
- The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit.
267
+ The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
267
268
 
268
269
  ### move
269
270
 
270
- `move` relocates a range of lines in one call: the range is removed from the source file and written after `insert_after`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, `insert_after` must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
271
+ `move` relocates a range of lines in one call: the range is removed from the source file and written before or after `anchor`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, the destination position must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
271
272
 
272
273
  The same safety machinery as `replace` applies to both tools: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive. A cross-file `move` writes two files and records one undo entry per file; undo both sides to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing. When the move is part of a batch, the destination side is reverted by that batch's undo and the source side by its own entry.
273
274
 
274
- ### anchor_grep
275
+ ### grep
275
276
 
276
- `anchor_grep` is an anchored search backed by ripgrep. It is enabled by default; disable it in `/hashline-config` (or set `anchorGrepEnabled` to `false` in the config file). While it is enabled, the built-in grep is disabled. Disabling it removes the tool and restores the built-in grep only if that was active before the extension loaded.
277
+ `grep` is an anchored search backed by ripgrep, registered under pi's own `grep` name. It is enabled by default; turn Anchor grep off in `/hashline-config` (or set `anchorGrepEnabled` to `false` in the config file) to register pi's plain `grep` in its place.
277
278
 
278
- Every matching line, and each requested context line, is returned as `lineNumber │ anchor│content`. The `anchor│content` part is served exactly like `read` output, so you can target it with `replace` or `insert` without a separate `read`; the line-number gutter and `=== path ===` header give filename and line for navigation.
279
+ Every matching line is returned as `lineNumber:│ anchor│content` and each requested context line as `lineNumber-│ anchor│content`, so the `:`/`-` gutter always tells a hit from a context row. The `anchor│content` part is served exactly like `read` output, so you can target it with `replace` or `insert` without a separate `read`; the line-number gutter and `=== path ===` header give filename and line for navigation.
279
280
 
280
281
  | Field | Description |
281
282
  | --- | --- |
@@ -318,7 +319,7 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
318
319
  - Before committing, the batch validates every call against the pre-batch state. A call whose own request is invalid (`[E_BAD_SHAPE]`, `[E_BAD_REF]`, `[E_STALE_ANCHOR]`, `[E_RANGE_STALE]`, `[E_SUBSTRING_NOT_FOUND]`, or a strict-input rejection) is left out: it fails on its own call without an `Aborts batch N.` suffix, and the remaining calls still commit as one batch with one undo. A failure of the file, the commit, or a cross-file member aborts the whole batch instead: nothing is written, the failing call's error ends with `Aborts batch N.`, and an earlier member's row renders the abort message instead of the queued placeholder.
319
320
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
320
321
 
321
- Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
322
+ Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `anchor` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert`, `copy`, or cross-file `move` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
322
323
 
323
324
  A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
324
325
 
@@ -344,13 +345,13 @@ Each attached file starts with a header (`=== path ===` in `full` mode, `=== pat
344
345
 
345
346
  A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget is not counted as complete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
346
347
 
347
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"outline"`, or `"full"`; older configs with `"on"` or `true` are read as `"full"`, `"git"` as `"full"`, `"outline(git)"` as `"outline"` — both with Git repos only on — and `false` as `"off"`), and as `autoReadAllRequireGit`, the Git repos only switch (on by default). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
348
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"outline"`, or `"full"`; older configs with `"on"` or `true` are read as `"full"`, `"git"` as `"full"`, `"outline(git)"` as `"outline"` — both with Git repos only on — and `false` as `"off"`), and as `autoReadAllRequireGit`, the Git repos only switch (on by default). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
348
349
 
349
350
  ## Configuration
350
351
 
351
352
  | Command | Description |
352
353
  | --- | --- |
353
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
354
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
354
355
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
355
356
 
356
357
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -378,7 +379,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
378
379
  | `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"outline"` (anchor-stamped per-file outlines), or `"full"` (full contents). Legacy `"git"`/`"outline(git)"` read as `"full"`/`"outline"` with Git repos only on; `"on"`/`true` read as `"full"`. |
379
380
  | `autoReadAllRequireGit` | Git repos only | `true` | Run the injection only when the working directory is inside a git repository; when off, auto-read all also attaches files discovered by `ripgrep` or the directory walk. |
380
381
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
381
- | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
382
+ | `anchorGrepEnabled` | Anchor grep | `true` | Register an anchored `grep` in place of pi's own `grep` while it is on. |
382
383
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
383
384
  | `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
384
385
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
@@ -387,7 +388,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
387
388
  | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
388
389
  | `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
389
390
 
390
- `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
391
+ `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
391
392
 
392
393
  When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
393
394
 
@@ -401,15 +402,15 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
401
402
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
402
403
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
403
404
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
404
- | Grep matches | 100 matched lines by default (`limit`) | `anchor_grep` |
405
- | Grep output | 2000 rows and 50KB | `anchor_grep` |
405
+ | Grep matches | 100 matched lines by default (`limit`) | `grep` |
406
+ | Grep output | 2000 rows and 50KB | `grep` |
406
407
  | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
407
408
  | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
408
409
  | Auto-read all budget | 200KB floor, 2MB ceiling | total injection size, derived from the model context window |
409
410
  | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
410
411
  | Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
411
412
 
412
- `anchor_grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
413
+ `grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
413
414
 
414
415
  ## Tool result details
415
416
 
@@ -421,7 +422,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
421
422
 
422
423
  `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
423
424
 
424
- `anchor_grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
425
+ `grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
425
426
 
426
427
  | Tool | `details` |
427
428
  | --- | --- |
@@ -430,7 +431,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
430
431
  | `replace_match` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), and `metrics` with the same counters. |
431
432
  | `copy`, `move` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when a move changes nothing), and `metrics` with the same counters. |
432
433
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
433
- | `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
434
+ | `grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
434
435
 
435
436
  `snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`; hint `[H_*]` notices live in `details.hints` and are not counted.
436
437
 
@@ -461,7 +462,7 @@ Full reference:
461
462
  | `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
462
463
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
463
464
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
464
- | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because JSON decoding happens once, before the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
465
+ | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
465
466
  | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
466
467
  | `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names both code points, the column, and the reference anchor, for example `[H_UNICODE_SWAPPED] U+200D at col 21 where Kq3f│ has U+200B; resend with U+200B if unintended.` The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
467
468
  | `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
@@ -478,12 +479,12 @@ Full reference:
478
479
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
479
480
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
480
481
  | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
481
- | `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
482
+ | `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber:│ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
482
483
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
483
484
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
484
485
  | `[E_OP_ABORTED]` | A batch-level failure aborted the edit before writing (the base read failed, a cross-file member could not be prepared, the file changed or was deleted during the batch, or the composed result exceeded a limit). Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
485
- | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
486
- | `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
486
+ | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
487
+ | `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
487
488
 
488
489
  ## Troubleshooting
489
490
 
@@ -529,7 +530,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
529
530
  ### Request flow
530
531
 
531
532
  ```text
532
- read / anchor_grep / auto-read-all ──► anchor registry (ownership + served records)
533
+ read / grep / auto-read-all ──► anchor registry (ownership + served records)
533
534
  │
534
535
  edit call ──► resolve anchors ──► apply ──► commit ──► post-edit diff with fresh anchors
535
536
  │
@@ -538,7 +539,7 @@ edit call ──► resolve anchors ──► apply ──► commit ──► p
538
539
 
539
540
  ### Allocation
540
541
 
541
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
542
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
542
543
 
543
544
  ### Ownership and mapping across edits
544
545
 
package/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createReadToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
2
+ import { createReadToolDefinition, createGrepToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
4
  import { regReplaceMatch } from "./src/replace-match";
5
5
  import { regReplace } from "./src/replace";
6
6
  import { regInsert } from "./src/insert";
7
7
  import { regCopy, regMove } from "./src/copy-move";
8
- import { regGrep } from "./src/grep";
8
+ import { regGrep, regVanillaGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
11
  import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
@@ -67,7 +67,9 @@ export default function (pi: ExtensionAPI): void {
67
67
  let readFlavor: "anchored" | "vanilla" = "anchored";
68
68
  let readFlavorCwd: string | undefined;
69
69
  let autoReadAllInjected = false;
70
- let grepWasActive = false;
70
+ let grepEnabled = true;
71
+ let grepFlavor: "anchored" | "vanilla" = "anchored";
72
+ let grepFlavorCwd: string | undefined;
71
73
  const baseAnchorTools = new Set<string>();
72
74
  let gateApplied = false;
73
75
 
@@ -80,7 +82,8 @@ export default function (pi: ExtensionAPI): void {
80
82
  regInsert(pi, flags);
81
83
  regCopy(pi, flags);
82
84
  regMove(pi, flags);
83
- regGrep(pi, flags);
85
+ if (grepFlavor === "vanilla" && grepFlavorCwd !== undefined) regVanillaGrep(pi, grepFlavorCwd);
86
+ else regGrep(pi, flags);
84
87
  regUndo(pi, flags);
85
88
  } catch (error) {
86
89
  console.error("Failed to refresh edit tools:", error);
@@ -100,11 +103,22 @@ export default function (pi: ExtensionAPI): void {
100
103
  readFlavorCwd = undefined;
101
104
  await refreshEditTools();
102
105
  }
106
+ const grepFlavorWanted = grepEnabled && !gated ? "anchored" : "vanilla";
107
+ if (grepFlavorWanted === "vanilla") {
108
+ if (grepFlavor !== "vanilla" || grepFlavorCwd !== cwd) {
109
+ pi.registerTool(createGrepToolDefinition(cwd));
110
+ grepFlavor = "vanilla";
111
+ grepFlavorCwd = cwd;
112
+ }
113
+ } else if (grepFlavor === "vanilla") {
114
+ grepFlavor = "anchored";
115
+ grepFlavorCwd = undefined;
116
+ await refreshEditTools();
117
+ }
103
118
  if (gated) {
104
119
  const active = pi.getActiveTools().filter((tool) => tool !== "edit");
105
- const gatedTools = readOnDisabledModels === "vanilla" ? ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read") : ANCHOR_TOOL_NAMES;
120
+ const gatedTools = ANCHOR_TOOL_NAMES.filter((tool) => tool !== "grep" && (readOnDisabledModels !== "vanilla" || tool !== "read"));
106
121
  const next = active.filter((tool) => !gatedTools.includes(tool));
107
- if (grepWasActive && !next.includes("grep")) next.push("grep");
108
122
  pi.setActiveTools(next);
109
123
  gateApplied = true;
110
124
  return;
@@ -122,17 +136,16 @@ export default function (pi: ExtensionAPI): void {
122
136
  if (!baseAnchorTools.has(tool)) return false;
123
137
  if (tool === "replace_match") return config.replaceMatchEnabled !== false;
124
138
  if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
125
- if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
139
+ if (tool === "grep") return config.anchorGrepEnabled === true;
126
140
  return true;
127
141
  });
128
- let next = [...new Set([...pi.getActiveTools().filter((tool) => tool !== "edit"), ...enabled])];
129
- if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
130
- pi.setActiveTools(next);
142
+ const active = pi.getActiveTools().filter((tool) => tool !== "edit");
143
+ const base = enabled.includes("grep") ? active.filter((tool) => tool !== "grep") : active;
144
+ pi.setActiveTools([...new Set([...base, ...enabled])]);
131
145
  }
132
146
 
133
147
  pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
134
148
  const active = pi.getActiveTools();
135
- grepWasActive = active.includes("grep");
136
149
  baseAnchorTools.clear();
137
150
  for (const tool of ANCHOR_TOOL_NAMES) {
138
151
  if (active.includes(tool)) baseAnchorTools.add(tool);
@@ -159,12 +172,14 @@ export default function (pi: ExtensionAPI): void {
159
172
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
160
173
  disableOnModels = config.disableOnModels ?? [];
161
174
  readOnDisabledModels = config.readOnDisabledModels ?? "vanilla";
175
+ grepEnabled = config.anchorGrepEnabled === true;
176
+ grepFlavor = grepEnabled ? "anchored" : "vanilla";
177
+ grepFlavorCwd = grepEnabled ? undefined : ctx.cwd;
162
178
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
163
179
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
164
180
  await refreshEditTools();
165
181
  pi.setActiveTools(
166
182
  pi.getActiveTools().filter((t) => {
167
- if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
168
183
  if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
169
184
  if (config.replaceMatchEnabled === false && t === "replace_match") return false;
170
185
  return true;
@@ -228,10 +243,11 @@ export default function (pi: ExtensionAPI): void {
228
243
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
229
244
  else if (key === "anchorGrepEnabled") {
230
245
  const enabled = await toggleAnchorGrep();
231
- const active = pi.getActiveTools();
232
- pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
233
- if (enabled) baseAnchorTools.add("anchor_grep");
234
- else baseAnchorTools.delete("anchor_grep");
246
+ grepEnabled = enabled;
247
+ grepFlavor = enabled ? "anchored" : "vanilla";
248
+ grepFlavorCwd = enabled ? undefined : ctx.cwd;
249
+ if (enabled) baseAnchorTools.add("grep");
250
+ else baseAnchorTools.delete("grep");
235
251
  }
236
252
  else if (key === "copyMoveEnabled") {
237
253
  const enabled = await toggleCopyMove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -30,7 +30,7 @@ const HEADER =
30
30
  "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
31
 
32
32
  const OUTLINE_HEADER =
33
- "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor and the row's `limit`; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
33
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
34
34
 
35
35
  const IMAGE_EXTENSIONS = new Set([
36
36
  ".avif",
package/src/batch.ts CHANGED
@@ -189,11 +189,12 @@ type NormalizedEditArgs =
189
189
  | { kind: "replace"; removeFrom: string; removeTo?: string; path?: string }
190
190
  | { kind: "insert"; anchor: string; path?: string }
191
191
  | { kind: "replace_match"; replaceFrom: string; replaceTo?: string; path?: string }
192
- | { kind: "copy" | "move"; sourceFrom: string; sourceTo?: string; insertAfter?: string; path?: string };
192
+ | { kind: "copy" | "move"; sourceFrom: string; sourceTo?: string; anchor?: string; direction?: string; path?: string };
193
193
 
194
194
  const MAX_TRACKED_BATCHES = 256;
195
195
  const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
196
196
  const BATCHABLE_CALLS = new Set(["replace", "insert", "replace_match", "copy", "move"]);
197
+ const MERGEABLE_PIECE_KINDS = new Set<BatchKind>(["insert", "copy", "move"]);
197
198
 
198
199
  const plan = new Map<string, PlannedMember>();
199
200
  const batches = new Map<number, BatchState>();
@@ -267,7 +268,8 @@ function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | un
267
268
  kind: name,
268
269
  sourceFrom: normalized.source_from,
269
270
  ...(typeof normalized.source_to === "string" ? { sourceTo: normalized.source_to } : {}),
270
- ...(typeof normalized.insert_after === "string" ? { insertAfter: normalized.insert_after } : {}),
271
+ ...(typeof normalized.anchor === "string" ? { anchor: normalized.anchor } : {}),
272
+ ...(typeof normalized.direction === "string" ? { direction: normalized.direction } : {}),
271
273
  ...(path ? { path } : {}),
272
274
  };
273
275
  }
@@ -332,7 +334,7 @@ async function resolveBatchCallTarget(
332
334
  const sourceTarget = normalized.sourceTo !== undefined
333
335
  ? tryResolveEditTarget(normalized.sourceFrom, normalized.sourceTo)
334
336
  : tryResolveEditTarget(normalized.sourceFrom);
335
- const destinationTarget = normalized.insertAfter !== undefined ? tryResolveEditTarget(normalized.insertAfter) : undefined;
337
+ const destinationTarget = normalized.anchor !== undefined ? tryResolveEditTarget(normalized.anchor) : undefined;
336
338
  if (sourceTarget === undefined) return undefined;
337
339
  if (normalized.kind === "move") {
338
340
  if (sourceTarget === destinationTarget) return { target: sourceTarget, sourceTarget };
@@ -811,7 +813,7 @@ function changesEnding(piece: BatchPiece, baseSeparators: LineEnding[]): boolean
811
813
  function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
812
814
  const byAnchor = new Map<number, BatchPiece[]>();
813
815
  for (const piece of pieces) {
814
- if (piece.kind !== "insert" || piece.start !== piece.end || piece.foldedLines === 0) continue;
816
+ if (!MERGEABLE_PIECE_KINDS.has(piece.kind) || piece.start !== piece.end || piece.foldedLines === 0) continue;
815
817
  const group = byAnchor.get(piece.start) ?? [];
816
818
  group.push(piece);
817
819
  byAnchor.set(piece.start, group);
package/src/config-ui.ts CHANGED
@@ -33,7 +33,7 @@ export function configRows(config: Config): ConfigRow[] {
33
33
  { key: "autoReadAll", group: "Auto-read all", depth: 0, label: "Auto-read all", hint: "Attach project files when a session starts", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "outline", "full"] },
34
34
  { key: "autoReadAllRequireGit", group: "Auto-read all", depth: 1, dependsOn: "autoReadAll", gated: true, label: "Git repos only", hint: "Attach only inside a git repository", enabled: config.autoReadAllRequireGit !== false },
35
35
  { key: "autoReadAllIgnore", group: "Auto-read all", depth: 1, dependsOn: "autoReadAll", label: "Ignore folders/files", hint: "Folders, files, or globs that auto-read all skips (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
36
- { key: "anchorGrepEnabled", group: "Tools", depth: 0, label: "Anchor grep", hint: "Use anchor_grep instead of the built-in grep", enabled: config.anchorGrepEnabled === true },
36
+ { key: "anchorGrepEnabled", group: "Tools", depth: 0, label: "Anchor grep", hint: "Use the anchored grep in place of pi's own grep", enabled: config.anchorGrepEnabled === true },
37
37
  { key: "copyMoveEnabled", group: "Tools", depth: 0, label: "Copy/move", hint: "Enable the copy and move tools", enabled: config.copyMoveEnabled !== false },
38
38
  { key: "replaceMatchEnabled", group: "Tools", depth: 0, label: "Replace match", hint: "Enable the replace_match tool", enabled: config.replaceMatchEnabled !== false },
39
39
  { key: "requirePath", group: "Edit behavior", depth: 0, label: "Require path", hint: "Edit tools must also send a matching path", enabled: config.requirePath === true },
package/src/constants.ts CHANGED
@@ -8,16 +8,19 @@ export const MAX_GREP_LINE_BYTES = 500;
8
8
 
9
9
  export const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
10
10
 
11
+ export const TEXT_ENDING_NOTE =
12
+ "A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.";
13
+
11
14
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
12
15
  export const HASH_STORE_VERSION = 9;
13
16
  export const NEW_CONTENT_NOT_ARRAY_MSG =
14
17
  `[E_BAD_SHAPE] "text" must be an array of strings, one per line (use [] to delete).`;
15
18
 
16
19
  export const NEW_CONTENT_NOT_STRING_MSG =
17
- `[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
20
+ `[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and a lone newline for one blank line; line breaks inside the string separate lines.`;
18
21
 
19
22
  export const TEXT_NOT_STRING_MSG =
20
- `[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use "\\n" for one blank line; line breaks inside the string separate lines.`;
23
+ `[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use a lone newline for one blank line; line breaks inside the string separate lines.`;
21
24
 
22
25
  export const NUL_CONTENT_MSG =
23
26
  `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry.`;