pi-hashline-edit-pro 4.5.3 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ HDtm│}
17
17
 
18
18
  replace one line by its anchor:
19
19
 
20
- { "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": [" console.log('hi');"] }
20
+ { "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": " console.log('hi');" }
21
21
 
22
22
  the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
23
23
  ```
@@ -30,6 +30,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
30
30
  - [Tools](#tools)
31
31
  - [read](#read)
32
32
  - [replace](#replace)
33
+ - [replace_within](#replace_within)
33
34
  - [insert](#insert)
34
35
  - [copy](#copy)
35
36
  - [move](#move)
@@ -89,6 +90,7 @@ pi install /path/to/pi-hashline-edit-pro
89
90
  | `edit` | disabled |
90
91
  | `grep` | disabled while `anchor_grep` is enabled |
91
92
  | `copy`, `move` | disabled while Copy/move is off |
93
+ | `replace_within` | disabled while Replace within is off |
92
94
  | `write` | kept; an auto-read block with fresh anchors is appended to its result |
93
95
  | `bash` | untouched |
94
96
 
@@ -128,7 +130,7 @@ pi uninstall npm:pi-hashline-edit-pro
128
130
  {
129
131
  "remove_from": "Emno",
130
132
  "remove_to": "Emno",
131
- "replacement_lines": [" console.log('hi');"]
133
+ "replacement_lines": " console.log('hi');"
132
134
  }
133
135
  ```
134
136
 
@@ -153,7 +155,7 @@ Nothing commits until an edit call returns: the extension validates the request
153
155
 
154
156
  ## Tools
155
157
 
156
- The extension registers seven tools: `read`, `replace`, `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`, `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`, `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_within`, `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_within` is enabled by default; turn Replace within off in `/hashline-config` to remove it. `replace`, `replace_within`, `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_within`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
157
159
 
158
160
  ### read
159
161
 
@@ -186,7 +188,7 @@ Edge cases:
186
188
  | --- | --- |
187
189
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
188
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
189
- | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` sets that line's ending to LF, `["b\r\n"]` to CRLF, and on a file without a final newline it adds it. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
191
+ | `replacement_lines` | 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. |
190
192
 
191
193
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
192
194
 
@@ -194,24 +196,33 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
194
196
  {
195
197
  "remove_from": "Hasu",
196
198
  "remove_to": "arvm",
197
- "replacement_lines": ["new line 1", "new line 2"]
199
+ "replacement_lines": "new line 1\nnew line 2"
198
200
  }
199
201
  ```
200
202
 
201
203
  Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
204
+ A deletion keeps blank lines at the edges of the removed range, so the separators around a block survive the edit; target a blank line on its own to delete it.
202
205
 
203
206
  The extension checks the request before any file I/O, so a bad request never touches the file.
204
207
 
205
- Auto-fixable slips fall into two groups. Fixed silently: a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines. Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
208
+ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embedded newlines, and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
206
209
 
207
210
  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 `replace`'s `replacement_lines` and `insert`'s `lines`.
208
211
 
209
- 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, `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`.
212
+ 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.
210
213
 
211
214
  An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
212
215
 
213
216
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and 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`.
214
217
 
218
+ ### replace_within
219
+
220
+ `replace_within` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `replace_old` is the exact text to find inside that range, and `replace_new` replaces just that match; every other character stays untouched. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_within` leaves everything the request did not name untouched. It is enabled by default; turn Replace within off in `/hashline-config` to remove the tool.
221
+
222
+ `replace_old` is matched against the range's text (LF line breaks, no final terminator) and must occur exactly once. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows; a repeated match is refused with `[E_SUBSTRING_AMBIGUOUS]` and the matching line numbers. Both refusals carry enough to retry without a `read`.
223
+
224
+ A `replace_within` call is never grouped into a batch; it commits on its own like `copy` and `move`. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
225
+
215
226
  ### insert
216
227
 
217
228
  `insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
@@ -220,21 +231,21 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
220
231
  | --- | --- |
221
232
  | `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. |
222
233
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
223
- | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` inserts `b` with an LF ending, and on a file without a final newline it adds it. A lone string is split on newlines, and stringified array text is unwrapped. |
234
+ | `lines` | 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. |
224
235
 
225
- Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
236
+ Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `lines` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
226
237
 
227
238
  Example: add a line after `Emno│`:
228
239
 
229
240
  ```json
230
- { "anchor": "Emno", "direction": "after", "lines": [" // log the greeting"] }
241
+ { "anchor": "Emno", "direction": "after", "lines": " // log the greeting" }
231
242
  ```
232
243
 
233
244
  The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
234
245
 
235
246
  ### copy
236
247
 
237
- `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 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. With `requirePath` on, `path` must match the source or the destination file.
248
+ `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.
238
249
 
239
250
  | Field | Description |
240
251
  | --- | --- |
@@ -280,11 +291,11 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
280
291
 
281
292
  ### undo_last_change
282
293
 
283
- `undo_last_change` reverts the most recent successful `replace`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
294
+ `undo_last_change` reverts the most recent successful `replace`, `replace_within`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
284
295
 
285
- - History is per-file and single-level: only the most recent `replace`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
296
+ - History is per-file and single-level: only the most recent `replace`, `replace_within`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
286
297
  - History is persisted and survives session restarts. A failed `write` does not clear it.
287
- - Every applied `replace`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
298
+ - Every applied `replace`, `replace_within`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
288
299
  - A cross-file `move` stores one undo entry per file; `undo_last_change` reverts the file you name, so revert both sides to undo the whole move.
289
300
  - A successful `write` clears the history for that file.
290
301
  - If the file was modified since the last edit, the undo is refused with `[E_UNDO_STALE]` rather than overwriting those changes, and the record is kept. Once the file matches the edited state again, `undo_last_change` succeeds.
@@ -297,9 +308,9 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
297
308
  Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run on their own, one after another.
298
309
 
299
310
  - A call outside a batch commits before its result returns.
300
- - A `copy` or `move` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
301
- - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
302
- - If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
311
+ - A `copy`, `move`, or `replace_within` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
312
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
313
+ - If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
303
314
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
304
315
 
305
316
  Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. 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 aborts its batch-mates with `[E_OP_ABORTED]`.
@@ -312,7 +323,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
312
323
 
313
324
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
314
325
 
315
- After `replace`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. 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, which are stale after the edit. When the context line next to 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.
326
+ After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. 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, which are stale after the edit. When the context line next to 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.
316
327
 
317
328
  An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
318
329
 
@@ -334,7 +345,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
334
345
 
335
346
  | Command | Description |
336
347
  | --- | --- |
337
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, required `path`, and strict input. Persists across sessions. |
348
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_within tool, required `path`, and strict input. Persists across sessions. |
338
349
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
339
350
 
340
351
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -346,6 +357,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
346
357
  "autoReadAllIgnore": [],
347
358
  "anchorGrepEnabled": true,
348
359
  "copyMoveEnabled": true,
360
+ "replaceWithinEnabled": true,
349
361
  "requirePath": false,
350
362
  "strictInput": false,
351
363
  "diffContextLines": 1
@@ -359,7 +371,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
359
371
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
360
372
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
361
373
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
362
- | `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
374
+ | `replaceWithinEnabled` | Replace within | `true` | Offer the `replace_within` tool; when off, it is removed from the active tools. |
375
+ | `requirePath` | Require path | `false` | `replace`, `replace_within`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
363
376
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
364
377
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
365
378
 
@@ -371,7 +384,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
371
384
  | --- | --- | --- |
372
385
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
373
386
  | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
374
- | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
387
+ | Line cap | 1,353,139 lines per file | `read`, `replace`, `replace_within`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
375
388
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
376
389
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
377
390
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -387,12 +400,13 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
387
400
 
388
401
  ## Tool result details
389
402
 
390
- All seven tools return machine-readable metadata in `details` alongside the model-visible text.
403
+ All eight tools return machine-readable metadata in `details` alongside the model-visible text.
391
404
 
392
405
  | Tool | `details` |
393
406
  | --- | --- |
394
407
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
395
408
  | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), `hints` (informative `[H_*]` notices, for example literal escaped text written as sent), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
409
+ | `replace_within` | 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. |
396
410
  | `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. |
397
411
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
398
412
  | `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). |
@@ -406,7 +420,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
406
420
  Most common, with the fix:
407
421
 
408
422
  - `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
409
- - `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown. The error already returns the current range with fresh anchors; retry with those.
423
+ - `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown (a pure deletion checks only its first and last line). The error already returns the current range with fresh anchors; retry with those.
410
424
  - `[E_FILE_TOO_LARGE]`: the file exceeds the 1,353,139-line hashline limit or the 100MB size limit. Use `write` for very large files.
411
425
  - `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
412
426
  - `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
@@ -417,22 +431,30 @@ Full reference:
417
431
  | Code | Meaning |
418
432
  | --- | --- |
419
433
  | `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
420
- | `[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), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
421
- | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
434
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
435
+ | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
422
436
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
437
+ | `[E_SUBSTRING_NOT_FOUND]` | `replace_within` did not find `replace_old` in the selected range. The current `anchor│content` rows are returned; copy `replace_old` exactly from the served row and retry. |
438
+ | `[E_SUBSTRING_AMBIGUOUS]` | `replace_within` found `replace_old` more than once in the selected range. Narrow `replace_from`/`replace_to` or extend `replace_old` so it matches exactly once. |
423
439
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
424
440
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
425
- | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
426
- | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
441
+ | `[W_INVALID_PATCH]` | A `replacement_lines` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
442
+ | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
427
443
  | `[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. |
428
- | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
444
+ | `[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: `lines: "\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. |
445
+ | `[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. |
446
+ | `[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`. |
447
+ | `[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.` |
448
+ | `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
449
+ | `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
450
+ | `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
429
451
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
430
452
  | `[E_NOT_FOUND]` | The path does not exist. |
431
453
  | `[E_ACCESS]` | The file is not readable or writable. |
432
454
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
433
455
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
434
456
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
435
- | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
457
+ | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
436
458
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
437
459
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
438
460
  | `[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. |
@@ -448,13 +470,13 @@ Full reference:
448
470
  ## Troubleshooting
449
471
 
450
472
  - Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
451
- - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
473
+ - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown; a pure deletion only needs its first and last line shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
452
474
  - Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
453
475
  - Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
454
476
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
455
477
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
456
478
  - Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
457
- - Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `insert`, and `undo_last_change` still write the edit.
479
+ - Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `replace_within`, `insert`, and `undo_last_change` still write the edit.
458
480
  - Not sure what the extension changed. `read` returns anchored rows and the built-in `edit` is gone; that is expected. See [What changes in your session](#what-changes-in-your-session).
459
481
 
460
482
  ## Privacy and on-disk state
@@ -481,7 +503,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
481
503
 
482
504
  ### Allocation
483
505
 
484
- 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`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
506
+ 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_within`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
485
507
 
486
508
  ### Ownership and mapping across edits
487
509
 
@@ -508,13 +530,7 @@ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline
508
530
 
509
531
  ## Benchmark
510
532
 
511
- pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks across many edit shapes and file types. A verifier compares the result file tree byte by byte, so nothing is graded on compilation or behavioral equivalence. Many harnesses are scored on it.
512
-
513
- The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
514
-
515
- First exact means the first attempt, with no recovery round, reproduced the expected bytes exactly. Final exact is the same after the benchmark's allowed recovery rounds. Quality is `0.75 × first exact + 0.25 × final exact`, so first-attempt accuracy dominates. The family rollup is the median quality across the complete, eligible model-route configurations scored with this harness; partial and quarantined configurations stay visible in the dataset but do not enter the median.
516
-
517
- Per-model and per-version numbers, plus the spread behind the headline value, are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest); raw observations and scoring rules are in the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark) and the [methodology](https://github.com/alexshpunt/explicit-edit-benchmark/blob/main/docs/methodology.md). Treat the score as one measurement of this suite at one point in time.
533
+ pi-hashline-edit-pro is scored on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party suite of 226 deterministic, byte-exact editing tasks. Per-model results and the scoring rules are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest) and the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark).
518
534
 
519
535
  ## Development
520
536
 
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
+ import { regReplaceWithin } from "./src/replace-within";
4
5
  import { regReplace } from "./src/replace";
5
6
  import { regInsert } from "./src/insert";
6
7
  import { regCopy, regMove } from "./src/copy-move";
@@ -20,6 +21,7 @@ import {
20
21
  cycleAutoReadAllMode,
21
22
  toggleAnchorGrep,
22
23
  toggleCopyMove,
24
+ toggleReplaceWithin,
23
25
  toggleRequirePath,
24
26
  toggleStrictInput,
25
27
  adjustDiffContextLines,
@@ -43,6 +45,7 @@ export default function (pi: ExtensionAPI): void {
43
45
  regRead(pi);
44
46
 
45
47
  regReplace(pi);
48
+ regReplaceWithin(pi);
46
49
  regInsert(pi);
47
50
  regCopy(pi);
48
51
  regMove(pi);
@@ -61,9 +64,11 @@ export default function (pi: ExtensionAPI): void {
61
64
  const flags = await currentEditFlags();
62
65
  regRead(pi, flags);
63
66
  regReplace(pi, flags);
67
+ regReplaceWithin(pi, flags);
64
68
  regInsert(pi, flags);
65
69
  regCopy(pi, flags);
66
70
  regMove(pi, flags);
71
+ regGrep(pi, flags);
67
72
  regUndo(pi, flags);
68
73
  } catch (error) {
69
74
  console.error("Failed to refresh edit tools:", error);
@@ -99,6 +104,7 @@ export default function (pi: ExtensionAPI): void {
99
104
  pi.getActiveTools().filter((t) => {
100
105
  if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
101
106
  if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
107
+ if (config.replaceWithinEnabled === false && t === "replace_within") return false;
102
108
  return true;
103
109
  }),
104
110
  );
@@ -133,7 +139,7 @@ export default function (pi: ExtensionAPI): void {
133
139
  }));
134
140
 
135
141
  pi.registerCommand("hashline-config", {
136
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, path, strict input)",
142
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_within, path, strict input)",
137
143
  handler: async (_args, ctx) => {
138
144
  if (!ctx.hasUI) {
139
145
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -159,6 +165,11 @@ export default function (pi: ExtensionAPI): void {
159
165
  const active = pi.getActiveTools();
160
166
  pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
161
167
  }
168
+ else if (key === "replaceWithinEnabled") {
169
+ const enabled = await toggleReplaceWithin();
170
+ const active = pi.getActiveTools();
171
+ pi.setActiveTools(enabled ? [...new Set([...active, "replace_within"])] : active.filter((t) => t !== "replace_within"));
172
+ }
162
173
  else if (key === "requirePath") await toggleRequirePath();
163
174
  else if (key === "strictInput") await toggleStrictInput();
164
175
  await refreshEditTools();
@@ -251,6 +262,7 @@ export default function (pi: ExtensionAPI): void {
251
262
 
252
263
  if (
253
264
  event.toolName !== "replace" &&
265
+ event.toolName !== "replace_within" &&
254
266
  event.toolName !== "insert" &&
255
267
  event.toolName !== "copy" &&
256
268
  event.toolName !== "move" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.5.3",
3
+ "version": "5.1.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",
@@ -1 +1,2 @@
1
1
  - `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
2
+ - `copy`: for a block copy, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior transfers verbatim, and the block lands in one commit. Rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
@@ -1 +1 @@
1
-
1
+ - `insert`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
@@ -1 +1 @@
1
- Insert `lines` after or before an anchor line: the anchor line stays, `lines` are bare without `│`, one per element
1
+ Insert a text block after or before an anchor line: the anchor line stays; `lines` is the exact text without anchor prefixes
package/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
1
+ Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert (an empty string inserts one blank line); 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. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `lines`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `lines`).
2
2
 
3
- Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
3
+ Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1 +1,2 @@
1
- - `move`: a cross-file move records one undo entry per file — undo both sides or the moved lines stay duplicated or missing. Lines between source and target may be re-anchored.
1
+ - `move`: a cross-file move records one undo entry per file — undo both sides. Lines between source and target may be re-anchored.
2
+ - `move`: for a block move, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior moves verbatim, and rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
@@ -1,4 +1,4 @@
1
- - `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
2
- - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
3
- - `replace`: use `replace`/`insert` for structural or multi-line edits — anchored, verified, undoable.
4
- - `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
1
+ - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone.
2
+ - `replace`: a pure deletion (`replacement_lines: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
3
+ - `replace`: to delete several blocks, `anchor_grep` serves each block's first line and a short `read` around each block serves its closing line; then one message of pure-deletion `replace` calls (`remove_from`/`remove_to` per block, `replacement_lines: ""`) removes them all as one batch.
4
+ - `replace`: `replacement_lines` is one string; a pasted `anchor│` prefix is stripped; single line: same anchor for `remove_from` and `remove_to`.
@@ -1 +1 @@
1
- Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, bare lines in `replacement_lines` without `│`, one edit per call
1
+ Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `replacement_lines` (one edit per call)
@@ -0,0 +1,3 @@
1
+ - `replace_within`: use it instead of `replace` to change part of a line, so every character the request does not name is preserved as served.
2
+ - `replace_within`: `replace_old` must be copied exactly from the served row and occur once in the range; a missing or repeated match is refused and returns the current rows.
3
+ - `replace_within`: when the same `replace_old` occurs many times, `anchor_grep` with `literal: true` serves every matching row in one call, and one `replace_within` per row is cheap — only the named bytes change, so a wrong match costs one line, not a rewritten file.
@@ -0,0 +1 @@
1
+ Replace part of a line or range by anchor: `replace_old` is matched exactly once inside `replace_from`/`replace_to` and replaced by `replace_new`, leaving the rest of the text untouched
@@ -0,0 +1,5 @@
1
+ Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `replace_old` is the exact text to find inside that range, copied from the served row; it must occur exactly once. `replace_new` replaces just that match, and every other character stays untouched.
2
+
3
+ Example: read served `Hasu│ {"name": "widget", "size": "small"},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "replace_old": "small", "replace_new": "large" }. The line becomes ` {"name": "widget", "size": "large"},` and the post-edit diff carries fresh anchors.
4
+
5
+ Both strings are exact text; 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. Matching uses LF line breaks and excludes the last line's terminator. A missing match is refused with the current rows, a repeated match with the matching line numbers, so the retry needs no read. Nothing but the matched text changes.
@@ -1,5 +1,6 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
1
+ Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range. 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. The text is written exactly as given, and nothing else in the file changes. A trailing line break sets the last line's ending instead of adding a blank line: `"x\n"` leaves the next line directly after `x`, and `"x\n\n"` leaves one blank line between them.
2
+ To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name.
2
3
 
3
- Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
+ Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
4
5
 
5
- Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": ["new"] }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
6
+ Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
@@ -1,2 +1,2 @@
1
- - `undo_last_change`: only the last `replace`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
1
+ - `undo_last_change`: only the last `replace`/`replace_within`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
2
  - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
@@ -1 +1 @@
1
- Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
1
+ Single-level undo: reverts a file's last `replace`, `replace_within`, `insert`, `copy`, or `move`
@@ -1 +1 @@
1
- Undo the last replace, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
1
+ Undo the last replace, replace_within, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
package/src/batch.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  changedRange,
13
13
  lineHashes,
14
14
  planEdit,
15
+ preserveDeletionSeparators,
15
16
  MAX_HASH_LINES,
16
17
  type HEdit,
17
18
  type PlannedEdit,
@@ -121,6 +122,7 @@ type NormalizedEditArgs =
121
122
  | { kind: "insert"; anchor: string; path?: string };
122
123
 
123
124
  const MAX_TRACKED_BATCHES = 256;
125
+ const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
124
126
 
125
127
  const plan = new Map<string, PlannedMember>();
126
128
  const batches = new Map<number, BatchState>();
@@ -346,7 +348,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
346
348
  content: [
347
349
  {
348
350
  type: "text",
349
- text: `In batch ${member.display}`,
351
+ text: `In batch ${member.display} (queued)`,
350
352
  },
351
353
  ],
352
354
  details: {
@@ -364,7 +366,8 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
364
366
  export function withAbortSuffix(message: string, display: number): string {
365
367
  const suffix = `Aborts batch ${display}.`;
366
368
  if (message.includes(suffix)) return message;
367
- return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
369
+ const ended = message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
370
+ return `${ended} ${BATCH_DISCARDED_NOTE}`;
368
371
  }
369
372
 
370
373
  const ERROR_CODE_RE = /\[(E_[A-Z0-9_]+)\]/;
@@ -393,7 +396,9 @@ function firstFailureCause(runtime: BatchState): string | undefined {
393
396
  const error = runtime.firstError;
394
397
  if (!(error instanceof Error)) return undefined;
395
398
  const suffix = ` Aborts batch ${runtime.display}.`;
396
- const message = error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
399
+ const discarded = ` ${BATCH_DISCARDED_NOTE}`;
400
+ const withoutDiscarded = error.message.endsWith(discarded) ? error.message.slice(0, -discarded.length) : error.message;
401
+ const message = withoutDiscarded.endsWith(suffix) ? withoutDiscarded.slice(0, -suffix.length) : withoutDiscarded;
397
402
  const firstLine = message.split("\n")[0]?.trim() ?? "";
398
403
  if (firstLine.length === 0) return undefined;
399
404
  if (!firstLine.endsWith(":")) return firstLine;
@@ -404,10 +409,12 @@ function firstFailureCause(runtime: BatchState): string | undefined {
404
409
  function abortedBatchMessage(runtime: BatchState): string {
405
410
  const failure = runtime.failure;
406
411
  if (failure?.code !== undefined) {
407
- return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]`;
412
+ return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]. ${BATCH_DISCARDED_NOTE}`;
408
413
  }
409
414
  const cause = firstFailureCause(runtime);
410
- return cause ? `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${cause}` : `[E_OP_ABORTED] Batch ${runtime.display} aborted.`;
415
+ if (cause === undefined) return `[E_OP_ABORTED] Batch ${runtime.display} aborted. ${BATCH_DISCARDED_NOTE}`;
416
+ const ended = cause.endsWith(".") || cause.endsWith("!") || cause.endsWith("?") ? cause : `${cause}.`;
417
+ return `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${ended} ${BATCH_DISCARDED_NOTE}`;
411
418
  }
412
419
 
413
420
  function batchAbortedError(runtime: BatchState): Error {
@@ -482,9 +489,10 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
482
489
  throw error;
483
490
  }
484
491
  const displayPath = runtime.paths?.displayPath ?? input.targetPath;
492
+ const effectiveHedit = preserveDeletionSeparators(input.hedit, base.baseLines, base.hashes);
485
493
  let planned: PlannedEdit;
486
494
  try {
487
- planned = planEdit(base.content, input.hedit, base.hashes, {
495
+ planned = planEdit(base.content, effectiveHedit, base.hashes, {
488
496
  filePath: displayPath,
489
497
  servedHashes: runtime.served,
490
498
  signal: input.signal,
@@ -519,8 +527,8 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
519
527
  ...(carryIndex !== undefined ? { carryIndex } : {}),
520
528
  start,
521
529
  end,
522
- fromHash: input.hedit.hash_bounds[0].hash,
523
- toHash: input.hedit.hash_bounds[1].hash,
530
+ fromHash: planned.resolved.hash_bounds[0].hash,
531
+ toHash: planned.resolved.hash_bounds[1].hash,
524
532
  newLines: [...newLines],
525
533
  ...(separators !== undefined ? { separators } : {}),
526
534
  warnings: [...input.extraWarnings, ...planned.warnings],