pi-hashline-edit-pro 2.8.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro)
4
4
 
5
- Anchor-based `read`, `replace`, `insert`, and `anchor_grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
5
+ Anchor-based `read`, `replace`, `insert`, and `anchor_grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 4-character anchor, and you edit by anchor. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
6
6
 
7
- Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character anchors and collision resolution.
7
+ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 4-character tokenizer-friendly anchors and collision resolution.
8
8
 
9
9
  ## Features
10
10
 
@@ -22,9 +22,9 @@ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by Rimur
22
22
  1. Read a file:
23
23
 
24
24
  ```text
25
- ve7│function hello() {
26
- szJ│ console.log("world");
27
- kQm│}
25
+ Dafo│function hello() {
26
+ Emno│ console.log("world");
27
+ HDtm│}
28
28
  ```
29
29
 
30
30
  2. Replace a line by its anchor:
@@ -32,8 +32,8 @@ kQm│}
32
32
  ```json
33
33
  {
34
34
  "path": "src/main.ts",
35
- "remove_from": "szJ",
36
- "remove_to": "szJ",
35
+ "remove_from": "Emno",
36
+ "remove_to": "Emno",
37
37
  "replacement_lines": [" console.log('hi');"]
38
38
  }
39
39
  ```
@@ -54,7 +54,7 @@ pi install /path/to/pi-hashline-edit-pro
54
54
 
55
55
  ## The read tool
56
56
 
57
- `read` returns a text file with every line prefixed by `anchor│content`. The anchor is 3 characters from `A-Za-z0-9` (for example `aB3`).
57
+ `read` returns a text file with every line prefixed by `anchor│content`. The anchor is 4 characters from `A-Za-z0-9` (for example `Hasu`), drawn from a curated table of two-character tokens so every anchor costs 2 tokens in the major tokenizers.
58
58
 
59
59
  | Parameter | Description |
60
60
  | --- | --- |
@@ -72,7 +72,7 @@ Edge cases:
72
72
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
73
73
  - Empty files come back as a single empty-line anchor (`anchor│`); use `replace` on that anchor to insert content.
74
74
  - BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
75
- - Files over 238,328 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
75
+ - Files over 257,795 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
76
76
 
77
77
  ## The replace tool
78
78
 
@@ -83,16 +83,16 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
83
83
  ```json
84
84
  {
85
85
  "path": "src/main.ts",
86
- "remove_from": "szJ",
87
- "remove_to": "kQm",
86
+ "remove_from": "Emno",
87
+ "remove_to": "HDtm",
88
88
  "replacement_lines": [" console.log('hi');", "}"]
89
89
  }
90
90
  ```
91
91
 
92
92
  | Field | Description |
93
93
  | --- | --- |
94
- | `remove_from` | 3-char anchor from `read` output marking the FIRST line to remove (inclusive). |
95
- | `remove_to` | 3-char anchor from `read` output marking the LAST line to remove (inclusive). |
94
+ | `remove_from` | 4-char anchor from `read` output marking the FIRST line to remove (inclusive). |
95
+ | `remove_to` | 4-char anchor from `read` output marking the LAST line to remove (inclusive). |
96
96
  | `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line. |
97
97
 
98
98
  Notes:
@@ -111,7 +111,7 @@ Notes:
111
111
  ```json
112
112
  {
113
113
  "path": "src/main.ts",
114
- "anchor": "szJ",
114
+ "anchor": "Emno",
115
115
  "direction": "after",
116
116
  "lines": [" console.log('hi');"]
117
117
  }
@@ -119,7 +119,7 @@ Notes:
119
119
 
120
120
  | Field | Description |
121
121
  | --- | --- |
122
- | `anchor` | 3-char anchor from `read` output marking the line next to which the lines go (inclusive; the line is preserved). A pasted diff row like `+aB3│x` or an `anchor│` prefix is stripped automatically with a warning. |
122
+ | `anchor` | 4-char anchor from `read` output marking the line next to which the lines go (inclusive; the line is preserved). A pasted diff row like `+Hasu│x` or an `anchor│` prefix is stripped automatically with a warning. |
123
123
  | `direction` | `"after"` to insert below the anchor line, `"before"` to insert above it. |
124
124
  | `lines` | Lines to insert as an array of strings, one element per line. Mirror `replacement_lines` semantics: use `[""]` for a blank line and do not embed `\n` inside an element. The anchor line is never part of `lines`. |
125
125
 
@@ -133,7 +133,7 @@ Notes:
133
133
 
134
134
  ## The anchor_grep tool
135
135
 
136
- `anchor_grep` replaces the built-in grep with an anchored search backed by ripgrep. While `anchor_grep` is enabled, the built-in grep is disabled; disabling `anchor_grep` restores it if it was active before the extension loaded. 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`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
136
+ When enabled, `anchor_grep` replaces the built-in grep with an anchored search backed by ripgrep; it is disabled by default (see below). While `anchor_grep` is enabled, the built-in grep is disabled; disabling `anchor_grep` restores it if it was active before the extension loaded. 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`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
137
137
 
138
138
  | Field | Description |
139
139
  | --- | --- |
@@ -153,7 +153,7 @@ Notes:
153
153
  - `file_path` works as an alias for `path`.
154
154
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
155
155
  - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
156
- - The anchor_grep tool is enabled by default. Disable it with `/toggle-anchor-grep` (or set `anchorGrepEnabled` to `false` in the config file); the setting persists across sessions. When disabled, anchor_grep is removed from the model's toolset and the built-in grep is restored only if it was active before the extension loaded — a grep tool that was never enabled stays off.
156
+ - The anchor_grep tool is disabled by default. Enable it with `/toggle-anchor-grep` (or set `anchorGrepEnabled` to `true` in the config file); the setting persists across sessions. While enabled, the built-in grep is disabled; disabling anchor_grep removes it from the model's toolset and restores the built-in grep only if it was active before the extension loaded — a grep tool that was never enabled stays off.
157
157
 
158
158
  ## Undo
159
159
 
@@ -196,17 +196,17 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
196
196
  ```json
197
197
  {
198
198
  "autoRead": true,
199
- "anchorGrepEnabled": true
199
+ "anchorGrepEnabled": false
200
200
  }
201
201
  ```
202
202
 
203
203
  ## How anchors work
204
204
 
205
- Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9`, which gives 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace. A line longer than 500 bytes is hashed from its first 500 bytes; uniqueness is still guaranteed by the collision-resolution below.
205
+ Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 4-character anchor from a frozen table of 257,795 anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace. A line longer than 500 bytes is hashed from its first 500 bytes; uniqueness is still guaranteed by the collision-resolution below.
206
206
 
207
- The alphabet is sized for an LLM consumer: the model reads the hashes as tokens rather than inspecting glyph shapes, so letters and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded. A hash starting with `-` looks like a diff-preview deletion row, and `-`/`_` at the start of a line are markdown-active, which invites mis-copying and false autocorrections.
207
+ The table is curated for tokenizers, not for humans: every anchor is the concatenation of two 2-character sequences that are single tokens in the o200k, cl100k, GPT-2, Llama, and Mistral vocabularies (verified offline against each full vocabulary), and the 4-character concatenation itself is verified to encode as exactly 2 tokens in all of them. An anchor therefore costs 2 tokens on a read row and 2 tokens in an edit call, with the `│` separator as the only overhead. Anchors are letters only; the digits dropped out because SentencePiece vocabularies have no two-digit tokens. The table is generated by `scripts/generate-anchor-table.py` and shipped as `src/hashline/anchor-table.json`.
208
208
 
209
- Anchors are unique by construction. If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions, runs of blank lines, repeated `}`, land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor; two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
209
+ Anchors are unique by construction. If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride advances both anchor halves, so consecutive collisions runs of blank lines, repeated `}` never share their first two characters. Every line in a file therefore gets a unique anchor; two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 257,795 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
210
210
 
211
211
  Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
212
212
 
@@ -226,10 +226,10 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
226
226
  | Code | Meaning |
227
227
  | --- | --- |
228
228
  | `[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). |
229
- | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char anchor. |
229
+ | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
230
230
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
231
231
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
232
- | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
232
+ | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
233
233
  | `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix (the anchor plus the separator). The prefix is stripped automatically with a warning. |
234
234
  | `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
235
235
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
@@ -240,7 +240,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
240
240
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
241
241
  | `[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. |
242
242
  | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
243
- | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
243
+ | `[E_FILE_TOO_LARGE]` | The file exceeds the 257,795-line hashline limit or the 100MB size limit. |
244
244
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
245
245
  | `[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. |
246
246
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.8.3",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
- "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,3 +1,3 @@
1
- - `anchor_grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
2
- - `anchor_grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
- - `anchor_grep`: `.git` is always skipped; `node_modules`/`.tmp`/`coverage` are skipped only when a `.gitignore` lists them; binary/image files are skipped.
1
+ - `anchor_grep`: hits and `context` rows come back as `lineNumber │ anchor│content` — target them with `replace`/`insert` directly; `lineNumber` enables jump-to-line.
2
+ - `anchor_grep`: `path` takes a file or folder (default cwd), `glob` like `*.ts` filters, `literal:true` for literal text, `context:N` for surrounding lines.
3
+ - `anchor_grep`: binary/image files are skipped silently.
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; `.git` is always skipped, and `node_modules`/`.tmp`/`coverage` are skipped only when a `.gitignore` lists them. Binary and image files are skipped silently. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
1
+ Search text files with ripgrep. Every hit and `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part works directly in `replace`/`insert` without a new `read`; `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore`; `.git` is always skipped, `node_modules`/`.tmp`/`coverage` are skipped only when a `.gitignore` lists them; binary/image files are skipped silently. Matches over 500 bytes are shown as a `...` fragment around the match, but the anchor still covers the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
@@ -1,3 +1 @@
1
- - `insert`: `anchor` is bare `aB3` from `aB3│content` (never the content), direction `after` adds below, `before` adds above. Do not put the anchor line in `lines`; `[""]` is a blank line.
2
- - `insert`: `lines` is bare content, one element per line, kept literally even if it duplicates neighbors — nothing is removed.
3
- - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or `anchor_grep`. For an empty file, `read` shows one `anchor│` empty row — insert `after` it.
1
+ - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row insert `after` it.
@@ -1 +1 @@
1
- Insert `lines` after/before bare anchor `aB3` from `aB3│content`: anchor stays, lines are bare without `│`, one per element
1
+ Insert `lines` after/before bare anchor `Hasu` from `Hasu│content`: anchor stays, lines are bare without `│`, one per element
package/prompts/insert.md CHANGED
@@ -1 +1 @@
1
- Insert lines after or before one existing line in a text file, using a bare anchor like `aB3` from `aB3│content`. The anchor line stays; your `lines` are added after (`"after"`) or before (`"before"`) it, one string per line. Use `[""]` for a blank line, never put `\n` inside an element and never include the anchor line in `lines`. Lines are added literally, even if they duplicate neighbors.
1
+ Insert lines after or before one existing line in a text file, by a bare anchor like `Hasu` from `Hasu│content`. The anchor line stays; `lines` are added after (`"after"`) or before (`"before"`) it. Lines are added literally, even if they duplicate neighbors.
@@ -1,2 +1,2 @@
1
- - `read`: call before `replace` when you need fresh anchors for a file.
2
- - `read`: call again after an edit when you need anchors you do not have — post-edit diff `+anchor│`/` anchor│` rows and `anchor_grep` hits are already fresh anchors for the changed range, so no new `read` needed for those lines.
1
+ - `read`: call before `replace` when you need fresh anchors.
2
+ - `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor (e.g. `aB3│hello` is anchor `aB3` plus content `hello`). No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD. If output says truncated, use `offset`/`limit` as hinted.
1
+ Read a text file; each line returned as `anchor│content` a 4-character alphanumeric anchor plus the content (e.g. `Hasu│hello`: anchor `Hasu`). No line numbers: edit by anchor in replace/insert. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD. If output says truncated, use `offset`/`limit` as hinted.
@@ -1,5 +1,4 @@
1
- - `replace`: `remove_from` and `remove_to` are bare anchors like `aB3` (the 3 chars before `│` in `aB3│content`), never the full row or file content. Use the same anchor for both to change one line.
2
- - `replace`: `replacement_lines` is bare content without `│`, one element per line. Use `[]` to delete, `[""]` for one blank line, never put `\n` inside an element and never include the `anchor│` prefix.
3
- - `replace`: the range from `remove_from` to `remove_to` is exactly deleted and replaced in order make it tight, only the lines that actually change, and copy leading spaces exactly.
4
- - `replace`: diff markers like `+aB3│` or `-aB3│` are stripped automatically if pasted, but always try to send bare content.
5
- - `replace`: `+anchor│` and ` anchor│` rows in a post-edit diff are fresh anchors — you can use them for the next edit without a new `read`. Do one `replace`/`insert` per turn and check the diff before the next edit.
1
+ - `replace`: use the same anchor for `remove_from` and `remove_to` to change one line.
2
+ - `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically.
3
+ - `replace`: keep the range tight — only lines that actually change and copy leading spaces exactly.
4
+ - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit no new `read` needed. One edit per turn; check the diff before the next edit on that file.
@@ -1 +1 @@
1
- Replace lines via bare 3-char anchor `aB3` from `aB3│content`: `remove_from`/`remove_to` are `aB3` only, `replacement_lines` is bare lines without `│`; one edit per call
1
+ Replace lines via bare 4-char anchor `Hasu` from `Hasu│content`: `remove_from`/`remove_to` are `Hasu` only, `replacement_lines` is bare lines without `│`; one edit per call
@@ -1 +1 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. Saw `aB3│content` in read/diff/anchor_grep: `aB3` is the bare HASH before `│`, `aB3│content` is the full row. `remove_from` and `remove_to` are bare HASH only (e.g. `"aB3"`), never the full row or file content. `replacement_lines` is bare content without `│`, one string per line — use `[]` to delete. Example: read showed `aB3│old` and `kQm│old2`, to replace both use `{"remove_from":"aB3","remove_to":"kQm","replacement_lines":["new line 1","new line 2"]}`. Single line → same anchor for both.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. In `Hasu│content`, `Hasu` is the bare anchor before `│` and the full row is `Hasu│content`. `remove_from`/`remove_to` are bare anchors only (e.g. `"Hasu"`), never the row or file content. `replacement_lines` is bare content without `│`, one string per line — `[]` deletes. Example: read showed `Hasu│old` and `arvm│old2`; to replace both use `{"remove_from":"Hasu","remove_to":"arvm","replacement_lines":["new line 1","new line 2"]}`. Single line → same anchor for both.
@@ -1,2 +1 @@
1
- - `undo_last_change`: only the last `replace`/`insert` per file can be undone; a successful `write` clears it, so call immediately after a bad diff (look for `-anchor│` lines you wanted to keep).
2
- - `undo_last_change`: if the file changed after the edit you get `[E_UNDO_STALE]` — the record is kept, so undo again after the file matches the edited state; if the file was deleted it is restored.
1
+ - `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff (check the `-anchor│` lines you wanted to keep).
@@ -1 +1 @@
1
- Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit when the diff showed wrong lines removed. If the file was deleted, it is restored; if changed elsewhere, you get `[E_UNDO_STALE]` and the record is kept so you can retry after restoring the edited state. If output says truncated, use `read` to see full file.
1
+ Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit removed wrong lines. Deleted file restored; changed elsewhere `[E_UNDO_STALE]`, nothing reverted, record kept do not modify the file to force an undo; call `read` to verify the current state, then stop. If output says truncated, use `read` to see full file.
package/src/commit.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PipelineResult } from "./replace";
2
- import { abortIf } from "./utils";
2
+ import { abortIf, clipLine } from "./utils";
3
3
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
4
4
  import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
@@ -22,6 +22,12 @@ export interface CommitMeta {
22
22
  onNoopDedup?: () => void;
23
23
  }
24
24
 
25
+ function boundaryDedupWarning(lineTexts: string[]): string {
26
+ const quoted = lineTexts.map((line) => `"${clipLine(line, 80)}"`).join(", ");
27
+ const plural = lineTexts.length > 1;
28
+ return `Boundary dedup: ${quoted} already ${plural ? "exist" : "exists"} next to the edited range, so ${plural ? "they were" : "it was"} not added again.`;
29
+ }
30
+
25
31
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
26
32
  const { path, absolutePath, mutationTargetPath, signal } = meta;
27
33
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
@@ -54,6 +60,9 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
54
60
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
55
61
  );
56
62
  }
63
+ if (pipe.boundaryRemovedLineTexts.length > 0) {
64
+ warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts));
65
+ }
57
66
 
58
67
  abortIf(signal);
59
68
  const undo = await saveUndo(mutationTargetPath, {
package/src/config.ts CHANGED
@@ -10,7 +10,7 @@ export interface Config {
10
10
 
11
11
  const DEFAULT_CONFIG: Config = {
12
12
  autoRead: true,
13
- anchorGrepEnabled: true
13
+ anchorGrepEnabled: false
14
14
  };
15
15
 
16
16
  function parseConfig(content: string): Config {
package/src/constants.ts CHANGED
@@ -7,6 +7,6 @@ export const MAX_HASH_SOURCE_BYTES = 500;
7
7
  export const MAX_GREP_LINE_BYTES = 500;
8
8
 
9
9
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
- export const HASH_STORE_VERSION = 6;
10
+ export const HASH_STORE_VERSION = 7;
11
11
  export const NEW_CONTENT_NOT_ARRAY_MSG =
12
12
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
package/src/grep.ts CHANGED
@@ -412,29 +412,29 @@ const grepToolSchema = Type.Object(
412
412
  ),
413
413
  glob: Type.Optional(
414
414
  Type.String({
415
- description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'. A leading / is ignored; the pattern may be relative to the search root or to the current directory.",
415
+ description: "Filter files by glob; `*` crosses directories, e.g. `*.ts`. A leading `/` is ignored; relative to the search root or cwd.",
416
416
  }),
417
417
  ),
418
418
  ignoreCase: Type.Optional(
419
419
  Type.Boolean({
420
- description: "Case-insensitive search (default: false)",
420
+ description: "Case-insensitive search",
421
421
  }),
422
422
  ),
423
423
  literal: Type.Optional(
424
424
  Type.Boolean({
425
- description: "Treat pattern as literal string instead of regex (default: false)",
425
+ description: "Treat pattern as literal text instead of regex",
426
426
  }),
427
427
  ),
428
428
  context: Type.Optional(
429
429
  Type.Integer({
430
430
  minimum: 0,
431
- description: "Number of lines to show before and after each match (default: 0)",
431
+ description: "Lines of context before and after each match",
432
432
  }),
433
433
  ),
434
434
  limit: Type.Optional(
435
435
  Type.Integer({
436
436
  minimum: 1,
437
- description: "Maximum number of matches to return (default: 100)",
437
+ description: "Maximum number of matched lines to return",
438
438
  }),
439
439
  ),
440
440
  },
@@ -1,14 +1,37 @@
1
- export const HASH_LEN = 3;
1
+ import anchorData from "./anchor-table.json";
2
2
 
3
- export const ALPH =
4
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
3
+ const TABLE: string = anchorData.anchors;
5
4
 
6
- const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
5
+ export const HASH_LEN = 4;
7
6
 
8
- export const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
7
+ export const ANCHOR_COUNT = TABLE.length / HASH_LEN;
9
8
 
10
- export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
9
+ const ALNUM = "A-Za-z0-9";
11
10
 
12
- export const HASH_RUN = `[${ALPH_SAFE}]{1,${HASH_LEN * 2}}`;
11
+ export const ALPH_RE = new RegExp(`^[${ALNUM}]+$`);
12
+
13
+ export const HASH_CLASS = `[${ALNUM}]{${HASH_LEN}}`;
14
+
15
+ export const HASH_RUN = `[${ALNUM}]{1,${HASH_LEN * 2}}`;
13
16
 
14
17
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
18
+
19
+ let indexByAnchor: Map<string, number> | undefined;
20
+
21
+ function reverseIndex(): Map<string, number> {
22
+ if (!indexByAnchor) {
23
+ indexByAnchor = new Map();
24
+ for (let i = 0; i < ANCHOR_COUNT; i++) {
25
+ indexByAnchor.set(TABLE.slice(i * HASH_LEN, i * HASH_LEN + HASH_LEN), i);
26
+ }
27
+ }
28
+ return indexByAnchor;
29
+ }
30
+
31
+ export function anchorAt(idx: number): string {
32
+ return TABLE.slice(idx * HASH_LEN, idx * HASH_LEN + HASH_LEN);
33
+ }
34
+
35
+ export function anchorIndex(hash: string): number {
36
+ return reverseIndex().get(hash) ?? -1;
37
+ }