pi-hashline-edit-pro 2.6.4 → 2.7.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,18 +2,20 @@
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
- Hash-anchored `read` and `replace` 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 hash, and you edit by hash. No line numbers, no fuzzy matching, no edits landing on the wrong line.
5
+ Hash-anchored `read`, `replace`, `insert`, and `grep` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character hash, and you edit by hash. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
6
6
 
7
7
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character hashes and collision resolution.
8
8
 
9
- ## What you get
9
+ ## Features
10
10
 
11
- - **Read with anchors.** Every line comes back as `HASH│content`. The hash is the line's address.
12
- - **Edit by hash.** `replace` targets a range of hashes, so edits always land on the lines you meant.
13
- - **Anchors that stay put.** Edit one part of a file and the hashes of the rest stay the same. Read once, keep editing.
14
- - **Fresh anchors, automatically.** After every `write` you get the new anchors. After every `replace` you get the diff with the new hashes.
15
- - **Undo when you need it.** The last replace on a file can be reverted, even after a restart.
16
- - **Safe writes.** Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
11
+ - `read` returns every line as `HASH│content`. The hash is the line's address.
12
+ - `replace` targets a range of hashes, so edits land on the lines you meant.
13
+ - `insert` adds lines after or before a line by hash: the anchor line is preserved and the new lines are applied literally, never deduplicated.
14
+ - `grep` returns matching lines (and requested context) with `HASH│content` rows that are served like read output, so search results are immediately editable.
15
+ - Editing one part of a file leaves the hashes of the rest unchanged, so anchors from an earlier read stay valid across edits.
16
+ - After a `write` you get the new anchors. After a `replace` or `insert` you get the diff with the new hashes.
17
+ - The most recent replace or insert on a file can be reverted, even after a restart.
18
+ - Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
17
19
 
18
20
  ## Quick start
19
21
 
@@ -36,7 +38,7 @@ kQm│}
36
38
  }
37
39
  ```
38
40
 
39
- 3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read hands you fresh anchors after each change.
41
+ 3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read returns fresh anchors after each change.
40
42
 
41
43
  ## Installation
42
44
 
@@ -74,7 +76,7 @@ Edge cases:
74
76
 
75
77
  ## The replace tool
76
78
 
77
- The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
79
+ The built-in `edit` tool is disabled. `replace` and `insert` are the only edit paths, and both take the hash anchors from `read` output.
78
80
 
79
81
  One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
80
82
 
@@ -91,50 +93,98 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
91
93
  | --- | --- |
92
94
  | `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
93
95
  | `remove_to` | 3-char hash from `read` output marking the LAST line to remove (inclusive). |
94
- | `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. |
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. |
95
97
 
96
98
  Notes:
97
99
 
98
100
  - The request is checked before any file I/O, so a bad request never touches the file.
99
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
100
- - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the exact same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally the duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `HASH│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
101
- - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
101
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file. The whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
102
+ - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `HASH│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
103
+ - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves (`read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace, insert, and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback) and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
102
104
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
103
- - Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
105
+ - Do not issue multiple replace or insert calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
106
+
107
+ ## The insert tool
108
+
109
+ `insert` adds lines after or before an existing line without removing anything. The anchor line is preserved, and the new lines go after it (`direction: "after"`) or before it (`direction: "before"`):
110
+
111
+ ```json
112
+ {
113
+ "path": "src/main.ts",
114
+ "anchor": "szJ",
115
+ "direction": "after",
116
+ "lines": [" console.log('hi');"]
117
+ }
118
+ ```
119
+
120
+ | Field | Description |
121
+ | --- | --- |
122
+ | `anchor` | 3-char hash 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 a `HASH│` prefix is stripped automatically with a warning. |
123
+ | `direction` | `"after"` to insert below the anchor line, `"before"` to insert above it. |
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
+
126
+ Notes:
127
+
128
+ - The anchor line must have been shown to you (read output, a post-edit diff row, grep output, or stale-range feedback). The same verification as `replace` applies: a stale or unshown anchor is rejected with `[E_STALE_ANCHOR]`, `[E_AMBIGUOUS_ANCHOR]`, or `[E_RANGE_STALE]` and the retry needs no `read`.
129
+ - Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`.
130
+ - To seed an empty file, read it and insert after the `HASH│` empty-line row.
131
+ - The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), line endings and BOMs survive, and an applied insert clears a pending boundary bypass.
132
+ - Inserting nothing (`lines: []`) reports a noop and leaves the file unchanged; inserted lines are never deduplicated.
133
+
134
+ ## The grep tool
135
+
136
+ `grep` replaces the built-in grep with a hash-anchored search. Every matching line (and each requested context line) is returned as a `HASH│content` row, and those rows are recorded in the served state exactly like `read` output, so you can target them with `replace` or `insert` immediately without a separate `read`.
137
+
138
+ | Field | Description |
139
+ | --- | --- |
140
+ | `pattern` | Search pattern (regex, or literal text when `literal` is true). |
141
+ | `path` | File or directory to search (default: the current working directory). |
142
+ | `glob` | Filter files by glob pattern; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`. |
143
+ | `ignoreCase` | Case-insensitive search (default: false). |
144
+ | `literal` | Treat the pattern as literal text instead of a regex (default: false). |
145
+ | `context` | Lines of context before and after each match; context rows carry anchors too (default: 0). |
146
+ | `limit` | Maximum number of matched lines to return (default: 100). |
147
+
148
+ Notes:
149
+ - Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
150
+ - Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
151
+ - Output is capped at `limit` matched lines and 2000 rows, with a hint when the cap cut results. Directory scans stop after 4000 files with a hint; results may be incomplete.
152
+ - `file_path` works as an alias for `path`.
104
153
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
105
154
  - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
106
155
 
107
156
  ## Undo
108
157
 
109
- `undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
158
+ `undo_last_change` reverts the most recent successful `replace` or `insert` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
110
159
 
111
- - History is per-file and single-level: only the most recent replace can be reverted.
160
+ - History is per-file and single-level: only the most recent replace or insert can be reverted.
112
161
  - History is persisted and survives session restarts. A failed `write` does not clear it.
113
- - Every applied replace is undoable: the undo record is saved before the edit is written.
162
+ - Every applied replace or insert is undoable: the undo record is saved before the edit is written.
114
163
  - A successful `write` clears the history for that file.
115
- - If the file was modified or deleted since the last replace, the undo is refused rather than overwriting those changes.
164
+ - If the file was modified or deleted since the last replace or insert, the undo is refused rather than overwriting those changes.
116
165
 
117
166
  ## Auto-read
118
167
 
119
168
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
120
169
 
121
- - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
170
+ - After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). When the context line touching a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
122
171
  - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
123
172
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
124
173
 
125
174
  ## Tool result details
126
175
 
127
- All three tools return machine-readable metadata in `details` alongside the model-visible text:
176
+ All five tools return machine-readable metadata in `details` alongside the model-visible text:
128
177
 
129
178
  - `read`: `details.truncation` (set when the output was truncated), `details.snapshotId` (a `v2|path|ino|mtime|ctime|size` fingerprint of the file), `details.nextOffset` (use as the next `offset`), and `details.metrics` with `truncated` and `next_offset`.
130
- - `replace`: `details.diff` (the post-edit diff; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
131
- - `undo_last_replace`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes), and `details.metrics` (same shape as `replace`).
179
+ - `replace` and `insert`: `details.diff` (the post-edit diff; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
180
+ - `undo_last_change`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes), and `details.metrics` (same shape as `replace`).
181
+ - `grep`: `details.metrics` with `matches`, `files`, and `truncated`.
132
182
 
133
183
  ## Settings
134
184
 
135
185
  | Command | Description |
136
186
  | --- | --- |
137
- | `/toggle-auto-read` | Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations. Persists across sessions. |
187
+ | `/toggle-auto-read` | Toggle auto-read anchors after write and post-edit diffs after replace, insert, and undo_last_change. Persists across sessions. |
138
188
 
139
189
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled. On non-Windows platforms, the config directory honors `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows it always uses `~/.config`:
140
190
 
@@ -148,9 +198,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
148
198
 
149
199
  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.
150
200
 
151
- The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded. A hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
201
+ 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.
152
202
 
153
- Unique anchors 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` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
203
+ 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).
154
204
 
155
205
  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.
156
206
 
@@ -178,8 +228,8 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
178
228
  | `[E_NOT_FOUND]` | The path does not exist. |
179
229
  | `[E_ACCESS]` | The file is not readable or writable. |
180
230
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
181
- | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
182
- | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
231
+ | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified or deleted after the last edit. |
232
+ | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
183
233
  | `[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. |
184
234
  | `[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. |
185
235
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
package/index.ts CHANGED
@@ -2,7 +2,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
4
  import { regReplace } from "./src/replace";
5
- import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
+ import { regInsert } from "./src/insert";
6
+ import { regGrep } from "./src/grep";
7
+ import { regUndo, clearUndo } from "./src/replace-undo";
6
8
  import { regRead, fmtReadPreview } from "./src/read";
7
9
  import type { RMetrics } from "./src/replace-response";
8
10
  import { extractWarnings } from "./src/replace-render";
@@ -25,7 +27,9 @@ export default function (pi: ExtensionAPI): void {
25
27
  regRead(pi);
26
28
 
27
29
  regReplace(pi);
28
- regReplaceUndo(pi);
30
+ regInsert(pi);
31
+ regGrep(pi);
32
+ regUndo(pi);
29
33
 
30
34
  let autoRead = true;
31
35
 
@@ -48,11 +52,11 @@ export default function (pi: ExtensionAPI): void {
48
52
  });
49
53
 
50
54
  pi.registerCommand("toggle-auto-read", {
51
- description: "Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations",
55
+ description: "Toggle auto-read anchors after write and post-edit diffs after replace, insert, and undo_last_change",
52
56
  handler: async (_args, ctx) => {
53
57
  autoRead = await toggleAutoRead();
54
58
  const state = autoRead ? "enabled" : "disabled";
55
- ctx.ui.notify(`Auto-read anchors (write) and post-edit diffs (replace/undo): ${state}`, "info");
59
+ ctx.ui.notify(`Auto-read anchors after write and post-edit diffs after replace/undo: ${state}`, "info");
56
60
  },
57
61
  });
58
62
 
@@ -111,7 +115,8 @@ export default function (pi: ExtensionAPI): void {
111
115
 
112
116
  if (
113
117
  event.toolName !== "replace" &&
114
- event.toolName !== "undo_last_replace"
118
+ event.toolName !== "insert" &&
119
+ event.toolName !== "undo_last_change"
115
120
  ) return;
116
121
  if (!autoRead) return;
117
122
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.6.4",
3
+ "version": "2.7.0",
4
4
  "type": "module",
5
- "description": "Hash-anchored read/replace/undo 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 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
7
7
  "repository": {
8
8
  "type": "git",
@@ -0,0 +1,6 @@
1
+ - `grep`: results carry `HASH│` anchors recorded like read output, so you can target them with replace or insert immediately.
2
+ - `grep`: pass `path` for a single file or directory; the default is the current working directory. Directory searches skip node_modules, .git, .tmp, and coverage.
3
+ - `grep`: use `literal: true` when the pattern contains regex metacharacters you want matched literally.
4
+ - `grep`: use `context` to see surrounding lines; context rows carry anchors too.
5
+ - `grep`: use `glob` to filter files; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`.
6
+ - `grep`: results are capped at `limit` matches (default 100) and 2000 rows; refine the pattern or raise limit to see more.
@@ -0,0 +1 @@
1
+ Search file contents; matching lines carry HASH│ anchors usable in replace/insert without a re-read
@@ -0,0 +1 @@
1
+ Search text files for a pattern. Returns every matching line (and the requested context lines) as `HASH│content` rows, so the results can be used directly as replace and insert anchors without a separate read. Directory searches skip node_modules, .git, .tmp, and coverage. Binary, image, and oversized files are skipped silently.
@@ -0,0 +1,6 @@
1
+ - `insert`: anchor takes ONLY the bare 3-char hash of the line next to which the new lines go: read row `ve7│function hello() {` means `"anchor": "ve7"`. Never paste the line content or the whole `HASH│content` row.
2
+ - `insert`: the anchor line is preserved. Include only the new lines in `lines`, never the anchor line itself.
3
+ - `insert`: use `direction: "after"` to add lines after the anchor line, `"before"` to add them before it.
4
+ - `insert`: read the file first, so the anchor line was shown to you. Use a post-edit diff row or grep output for follow-up inserts.
5
+ - `insert`: to seed an empty file, read it and insert after the `HASH│` empty-line row.
6
+ - `insert`: lines are applied literally — never deduplicated — so restating a neighbor is safe and has no effect on the result.
@@ -0,0 +1 @@
1
+ Insert lines after or before a line in a text file via bare 3-char HASH anchor from read: the anchor line stays, new lines go after/before it, applied literally
@@ -0,0 +1 @@
1
+ Insert lines after or before an existing line in a text file, targeted by the 3-char HASH anchors from read output. The anchor line is preserved; the new lines are added after it (direction "after") or before it (direction "before"). Each element of lines is exactly one line; do not embed \n inside an element: use separate elements. Use [""] for a blank line. The lines you send are added literally: nothing is removed, and lines that duplicate their neighbors are kept.
@@ -1,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
- - `read`: call again after any edit to that file changed lines get new anchors (the post-edit diff rows from replace/undo also carry fresh anchors for the changed range).
2
+ - `read`: call again after an edit when you need anchors you do not have. The post-edit diff after replace/insert and the anchored rows after grep already carry fresh anchors for the changed range.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
1
+ Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers: use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
@@ -1,8 +1,8 @@
1
- - `replace`: remove_from and remove_to take ONLY the bare 3-char hash read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
1
+ - `replace`: remove_from and remove_to take ONLY the bare 3-char hash: read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
2
2
  - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_lines is deleted from the file.
3
- - `replace`: keep the range as tight as the change anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
4
- - `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
3
+ - `replace`: keep the range as tight as the change: anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
4
+ - `replace`: to replace a single line, use the same hash for both remove_from and remove_to (e.g. remove_from: "<HASH>", remove_to: "<HASH>").
5
5
  - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element each element is exactly one line.
7
- - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
6
+ - `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line.
7
+ - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file: `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
8
8
  - `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
@@ -1 +1 @@
1
- Replace lines in a text file via bare 3-char HASH anchors from read hash only, never line content; anchor exactly the lines that change; one edit per tool call
1
+ Replace lines in a text file via bare 3-char HASH anchors from read: hash only, never line content; anchor exactly the lines that change; one edit per tool call
@@ -1 +1 @@
1
- Replace a range of lines in a text file, targeted by the 3-char HASH anchors from read output. remove_from and remove_to must each be a BARE 3-character hash: copy only the hash from the leftmost column of a read row (row `ve7│function hello() {` means `"remove_from": "ve7"`). Never pass the line content, a code line, or a paragraph into these fields.
1
+ Replace a range of lines (or a single line) in a text file, targeted by the 3-char HASH anchors from read output. remove_from and remove_to must each be a BARE 3-character hash: copy only the hash from the leftmost column of a read row (row `ve7│function hello() {` means `"remove_from": "ve7"`). Never pass the line content, a code line, or a paragraph into these fields.
@@ -0,0 +1,2 @@
1
+ - `undo_last_change`: reverts only the most recent replace or insert on the file: any write to the file clears the undo history, so call it immediately after a bad edit. An edit is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
2
+ - `undo_last_change`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
@@ -0,0 +1 @@
1
+ Undo the last change (replace or insert) on a file
@@ -0,0 +1 @@
1
+ Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax).
package/src/commit.ts ADDED
@@ -0,0 +1,107 @@
1
+ import type { PipelineResult } from "./replace";
2
+ import { abortIf } from "./utils";
3
+ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
4
+ import { saveUndo } from "./replace-undo";
5
+ import { safeSnapId } from "./file-reader";
6
+ import { writeAtomic } from "./fs-write";
7
+ import { recordServedDiffSafe } from "./served";
8
+ import { restoreEndings } from "./replace-diff";
9
+
10
+ export interface CommitMeta {
11
+ path: string;
12
+ absolutePath: string;
13
+ mutationTargetPath: string;
14
+ signal?: AbortSignal;
15
+ verb?: string;
16
+ noopNoun?: string;
17
+ prefixWarnings?: string[];
18
+ appliedWarnings?: string[];
19
+ foldedAnchorLines?: number;
20
+ onApplied?: () => void;
21
+ onNoopDedup?: () => void;
22
+ }
23
+
24
+ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
25
+ const { path, absolutePath, mutationTargetPath, signal } = meta;
26
+ const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
27
+ const editsAttempted = 1;
28
+
29
+ if (pipe.result === pipe.originalNormalized) {
30
+ const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
31
+ if (pipe.hadBoundaryDedup) meta.onNoopDedup?.();
32
+ return buildNoop(
33
+ {
34
+ path,
35
+ noopEdit: pipe.noopEdit,
36
+ snapshotId: noopSnapshotId,
37
+ editMeta: {
38
+ editsAttempted,
39
+ noopEditsCount: pipe.noopEdit ? 1 : 0,
40
+ addedLines: 0,
41
+ removedLines: 0,
42
+ },
43
+ warnings,
44
+ boundaryRemovedLines: pipe.boundaryRemovedLines,
45
+ },
46
+ meta.noopNoun,
47
+ );
48
+ }
49
+
50
+ warnings.push(...(meta.appliedWarnings ?? []));
51
+ if (pipe.hadUtf8DecodeErrors) {
52
+ warnings.push(
53
+ "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
54
+ );
55
+ }
56
+
57
+ abortIf(signal);
58
+ const undo = await saveUndo(mutationTargetPath, {
59
+ content: pipe.originalNormalized,
60
+ bom: pipe.bom,
61
+ originalEnding: pipe.originalEnding,
62
+ hashes: pipe.originalHashes,
63
+ resultContent: pipe.result,
64
+ });
65
+ if (!undo.persisted) {
66
+ throw new Error(
67
+ `[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${path} is unchanged.`
68
+ );
69
+ }
70
+ try {
71
+ abortIf(signal);
72
+ await writeAtomic(
73
+ absolutePath,
74
+ pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
75
+ );
76
+ } catch (error) {
77
+ await undo.restore();
78
+ throw error;
79
+ }
80
+ meta.onApplied?.();
81
+ const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
82
+
83
+ const editMeta: RMeta = {
84
+ editsAttempted,
85
+ noopEditsCount: pipe.noopEdit ? 1 : 0,
86
+ firstChangedLine: pipe.firstChangedLine,
87
+ lastChangedLine: pipe.lastChangedLine,
88
+ addedLines: Math.max(0, pipe.totalAddedLines - (meta.foldedAnchorLines ?? 0)),
89
+ removedLines: pipe.totalRemovedLines,
90
+ };
91
+
92
+ const successInput = {
93
+ path,
94
+ originalNormalized: pipe.originalNormalized,
95
+ originalHashes: pipe.originalHashes,
96
+ result: pipe.result,
97
+ resultHashes: pipe.resultHashes,
98
+ warnings,
99
+ snapshotId: updatedSnapshotId,
100
+ editMeta,
101
+ };
102
+ const changed = buildChanged(successInput, meta.verb);
103
+ if (changed.details.diff) {
104
+ await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(pipe.resultHashes));
105
+ }
106
+ return changed;
107
+ }
package/src/constants.ts CHANGED
@@ -7,5 +7,4 @@ export const MAX_RANGE_STALE_LINES = 100;
7
7
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
8
8
  export const HASH_STORE_VERSION = 5;
9
9
  export const NEW_CONTENT_NOT_ARRAY_MSG =
10
- `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one element per line, not a single string.`
11
- + ` Do not pass one string with \\n separators — pass an array of lines: ["line1", "line2"]. Use [] to delete a range.`;
10
+ `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
package/src/file-kind.ts CHANGED
@@ -143,7 +143,7 @@ export async function loadFileKindAndText(
143
143
  }
144
144
  if (newlineCount > options.maxLines) {
145
145
  throw new Error(
146
- `[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
146
+ `[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
147
147
  );
148
148
  }
149
149
  }
@@ -61,6 +61,7 @@ export interface ReadNormOptions {
61
61
  signal?: AbortSignal;
62
62
  accessMode?: number;
63
63
  preloadedFile?: LFile;
64
+ preloadedNorm?: NormFile;
64
65
  maxLines?: number;
65
66
  store?: HashStore;
66
67
  noPersist?: boolean;
@@ -80,6 +81,9 @@ export async function readNormFile(
80
81
  await valAccess(resolvedPath, path, accessMode);
81
82
 
82
83
  abortIf(signal);
84
+ const preloadedNorm = options?.preloadedNorm;
85
+ if (preloadedNorm) return preloadedNorm;
86
+
83
87
  const file = options?.preloadedFile ?? (await loadFileKindAndText(resolvedPath, { maxLines: options?.maxLines, displayPath: path }));
84
88
  valKind(file, path);
85
89
  abortIf(signal);
@@ -91,7 +95,7 @@ export async function readNormFile(
91
95
  const lineCount = visLines(normalized).length;
92
96
  if (lineCount > options.maxLines) {
93
97
  throw new Error(
94
- `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${options.maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
98
+ `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
95
99
  );
96
100
  }
97
101
  }