pi-hashline-edit-pro 2.6.4 → 2.6.5

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,18 @@
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` 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. 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
+ - Editing one part of a file leaves the hashes of the rest unchanged, so anchors from an earlier read stay valid across edits.
14
+ - After a `write` you get the new anchors. After a `replace` you get the diff with the new hashes.
15
+ - The most recent replace on a file can be reverted, even after a restart.
16
+ - Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
17
17
 
18
18
  ## Quick start
19
19
 
@@ -36,7 +36,7 @@ kQm│}
36
36
  }
37
37
  ```
38
38
 
39
- 3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read hands you fresh anchors after each change.
39
+ 3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read returns fresh anchors after each change.
40
40
 
41
41
  ## Installation
42
42
 
@@ -91,14 +91,14 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
91
91
  | --- | --- |
92
92
  | `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
93
93
  | `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. |
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. |
95
95
 
96
96
  Notes:
97
97
 
98
98
  - 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.
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 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.
102
102
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
103
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.
104
104
  - 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.
@@ -134,7 +134,7 @@ All three tools return machine-readable metadata in `details` alongside the mode
134
134
 
135
135
  | Command | Description |
136
136
  | --- | --- |
137
- | `/toggle-auto-read` | Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations. Persists across sessions. |
137
+ | `/toggle-auto-read` | Toggle auto-read anchors after write and post-edit diffs after replace and undo_last_replace. Persists across sessions. |
138
138
 
139
139
  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
140
 
@@ -148,9 +148,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
148
148
 
149
149
  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
150
 
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.
151
+ 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
152
 
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).
153
+ 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` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
154
154
 
155
155
  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
156
 
package/index.ts CHANGED
@@ -48,11 +48,11 @@ export default function (pi: ExtensionAPI): void {
48
48
  });
49
49
 
50
50
  pi.registerCommand("toggle-auto-read", {
51
- description: "Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations",
51
+ description: "Toggle auto-read anchors after write and post-edit diffs after replace and undo_last_replace",
52
52
  handler: async (_args, ctx) => {
53
53
  autoRead = await toggleAutoRead();
54
54
  const state = autoRead ? "enabled" : "disabled";
55
- ctx.ui.notify(`Auto-read anchors (write) and post-edit diffs (replace/undo): ${state}`, "info");
55
+ ctx.ui.notify(`Auto-read anchors after write and post-edit diffs after replace/undo: ${state}`, "info");
56
56
  },
57
57
  });
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.6.4",
3
+ "version": "2.6.5",
4
4
  "type": "module",
5
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.",
6
6
  "main": "index.ts",
@@ -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/undo already carries 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.
@@ -1,2 +1,2 @@
1
- - `undo_last_replace`: reverts only the most recent replace on the file any write to the file clears the undo history, so call it immediately after a bad replace. A replace is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
1
+ - `undo_last_replace`: reverts only the most recent replace on the file: any write to the file clears the undo history, so call it immediately after a bad replace. A replace is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
2
2
  - `undo_last_replace`: 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.
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
  }
@@ -91,7 +91,7 @@ export async function readNormFile(
91
91
  const lineCount = visLines(normalized).length;
92
92
  if (lineCount > options.maxLines) {
93
93
  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.`,
94
+ `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
95
95
  );
96
96
  }
97
97
  }
@@ -55,7 +55,7 @@ type NoopSpan = {
55
55
  function assertNotEmpty(originalContent: string, result: string): void {
56
56
  if (originalContent.length > 0 && result.length === 0) {
57
57
  throw new Error(
58
- "[E_WOULD_EMPTY] Cannot empty a non-empty file via edit. Use `write` if you need to clear the file."
58
+ "[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
59
59
  );
60
60
  }
61
61
  }
@@ -70,7 +70,7 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
70
70
  if (idx >= totalBits) idx -= totalBits;
71
71
  }
72
72
  throw new Error(
73
- `[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
73
+ `[E_FILE_TOO_LARGE] File exceeds the ${HASH_SPACE}-line hashline limit; use write for very large files.`,
74
74
  );
75
75
  }
76
76
 
@@ -14,11 +14,11 @@ function diagRef(ref: string): string {
14
14
  }
15
15
 
16
16
  if (/^\d+/.test(trimmed)) {
17
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3") no line numbers or trailing content.`;
17
+ return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3"): no line numbers or trailing content.`;
18
18
  }
19
19
 
20
20
  if (trimmed.includes("│")) {
21
- return `[E_BAD_REF] Invalid anchor "${trimmed}". remove_from and remove_to must contain the 3-char hash only — remove everything from "│" onward.`;
21
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char hash, drop everything from "│" onward.`;
22
22
  }
23
23
 
24
24
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
@@ -52,7 +52,7 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
52
52
  }
53
53
  if (split) {
54
54
  warnings?.push(
55
- "[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
55
+ "[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
56
56
  );
57
57
  }
58
58
  return out;
@@ -90,7 +90,7 @@ export function fmtMismatchWithHashes(
90
90
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
91
91
  if (notFound.length > 0) {
92
92
  out.push(
93
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into remove_from and remove_to of your next replace call.`
93
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
94
94
  );
95
95
  for (const m of notFound) {
96
96
  const ctx = m.context;
@@ -109,7 +109,7 @@ export function fmtMismatchWithHashes(
109
109
  if (ambiguous.length > 0) {
110
110
  if (out.length > 0) out.push("");
111
111
  out.push(
112
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into remove_from and remove_to of your next replace call.`
112
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() for fresh anchors.`
113
113
  );
114
114
  for (const m of ambiguous) {
115
115
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -150,7 +150,7 @@ function assertItem(edit: Record<string, unknown>): void {
150
150
  );
151
151
  }
152
152
  if (!("replacement_lines" in edit)) {
153
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
153
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
154
154
  }
155
155
  if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
156
156
  throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
@@ -174,11 +174,11 @@ export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
174
174
  if (match) {
175
175
  let message: string;
176
176
  if (match[1] === "+") {
177
- message = `[E_BAD_REF] Autocorrected: stripped diff-preview marker copied from the diff preview in remove_from/remove_to entry "${trimmed}".`;
177
+ message = `[E_BAD_REF] Stripped diff-preview marker from remove_from/remove_to entry "${trimmed}".`;
178
178
  } else if (match[1] === "-") {
179
- message = `[E_BAD_REF] Autocorrected: stripped leading "-" marker in remove_from/remove_to entry "${trimmed}".`;
179
+ message = `[E_BAD_REF] Stripped leading "-" marker from remove_from/remove_to entry "${trimmed}".`;
180
180
  } else {
181
- message = `[E_BAD_REF] Autocorrected: stripped "HASH│" prefix copied from read output in remove_from/remove_to entry "${trimmed}".`;
181
+ message = `[E_BAD_REF] Stripped "HASH│" prefix from remove_from/remove_to entry "${trimmed}".`;
182
182
  }
183
183
  warnings?.push(message);
184
184
  return match[2]!;
@@ -197,7 +197,7 @@ function warnUnicodeEsc(
197
197
  ): void {
198
198
  if (edit.content_lines.some((line) => /\\uDDDD/i.test(line))) {
199
199
  warnings.push(
200
- "Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
200
+ "Detected literal \\uDDDD in edit content; no autocorrection applied.",
201
201
  );
202
202
  }
203
203
  }
@@ -220,16 +220,12 @@ export function stripBarePrefixes(
220
220
  .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
221
221
  .join(", ");
222
222
  const matchedCount = stripped.filter((s) => s.matched).length;
223
- const evidence =
224
- matchedCount === 0
225
- ? "none of the stripped hashes match current file lines"
226
- : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
227
223
  const guidance =
228
224
  matchedCount === 0
229
- ? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
225
+ ? " Verify it was pasted from read output."
230
226
  : "";
231
227
  warnings.push(
232
- `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
228
+ `[E_BARE_HASH_PREFIX] Stripped "HASH│" prefix from ${locations}.${guidance}`
233
229
  );
234
230
  return { ...edit, content_lines: contentLines };
235
231
  }
@@ -255,7 +251,7 @@ export function stripDiffPrefixes(
255
251
  if (stripped.length === 0) return edit;
256
252
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
257
253
  warnings.push(
258
- `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
254
+ `[E_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
259
255
  );
260
256
  return { ...edit, content_lines: contentLines };
261
257
  }
@@ -280,7 +276,7 @@ export function swapReversedRanges(
280
276
  return edit;
281
277
  }
282
278
  warnings.push(
283
- `[E_BAD_OP] Autocorrected: remove_from and remove_to were reversed (remove_from ${startRef.hash} is after remove_to ${endRef.hash}); swapped the pair.`
279
+ `[E_BAD_OP] Autocorrected: remove_from/remove_to were reversed; swapped them.`
284
280
  );
285
281
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
286
282
  }
@@ -345,6 +341,7 @@ function firstNewAfterDups(
345
341
  let runLen = 0;
346
342
  while (
347
343
  runLen < maxK &&
344
+ canonLines[endLine + runLen]!.length > 0 &&
348
345
  canon(contentLines[firstNew.index + runLen]!) === canonLines[endLine + runLen]!
349
346
  ) {
350
347
  runLen++;
@@ -369,6 +366,7 @@ function lastNewBeforeDups(
369
366
  let runLen = 0;
370
367
  while (
371
368
  runLen < maxK &&
369
+ canonLines[startLine - 2 - runLen]!.length > 0 &&
372
370
  canon(contentLines[lastNew.index - runLen]!) === canonLines[startLine - 2 - runLen]!
373
371
  ) {
374
372
  runLen++;
@@ -402,8 +400,8 @@ export function findNewEdge(
402
400
  const start = fromEnd ? contentLines.length - 1 : 0;
403
401
  for (let i = start; i >= 0 && i < contentLines.length; i += step) {
404
402
  const line = contentLines[i]!;
405
- if (line.length === 0) continue;
406
403
  const key = canon(line);
404
+ if (key.length === 0) continue;
407
405
  const count = multiset.get(key) ?? 0;
408
406
  if (count > 0) {
409
407
  multiset.set(key, count - 1);
@@ -531,7 +529,7 @@ export function assertRangeServed(
531
529
  ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
532
530
  : "";
533
531
  const message =
534
- `[E_RANGE_STALE] ${mismatchText} what was previously shown: the file changed on disk after the anchors were read, or the line(s) were never shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
532
+ `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
535
533
  throw new RangeStaleError(message, first, shownHashes);
536
534
  }
537
535
 
package/src/read.ts CHANGED
@@ -72,7 +72,7 @@ export async function fmtReadPreview(
72
72
  };
73
73
  }
74
74
  return {
75
- text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
75
+ text: `Offset ${startLine} is beyond end of file (0 lines). Use replace to insert content.`,
76
76
  servedHashes: [],
77
77
  };
78
78
  }
@@ -114,7 +114,7 @@ export async function fmtReadPreview(
114
114
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
115
115
  const verb = oversized.length === 1 ? "exceeds" : "exceed";
116
116
  const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
117
- const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
117
+ const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
118
118
  let preview = skippedTruncation.content;
119
119
  let nextOffset: number | undefined;
120
120
  if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
@@ -38,6 +38,7 @@ export interface NoopInput {
38
38
  snapshotId?: string;
39
39
  editMeta: RMeta;
40
40
  warnings: string[] | undefined;
41
+ boundaryRemovedLines?: number;
41
42
  }
42
43
 
43
44
  export interface SuccessInput {
@@ -95,13 +96,18 @@ export function buildNoop(input: NoopInput): TResult {
95
96
  snapshotId,
96
97
  editMeta,
97
98
  warnings,
99
+ boundaryRemovedLines,
98
100
  } = input;
99
101
 
100
102
  const noopDetailsText = noopEdit
101
103
  ? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
102
104
  : "The edit produced identical content.";
105
+ const dedupNote =
106
+ boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
107
+ ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) from the replacement. Send the same edit again to apply it literally.`
108
+ : "";
103
109
 
104
- const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
110
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
105
111
 
106
112
  const metrics = buildMetrics({
107
113
  classification: "noop",
@@ -110,7 +110,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
110
110
  content: [
111
111
  {
112
112
  type: "text",
113
- text: `No undo history for ${path}. There is no previous replace to revert.`,
113
+ text: `No undo history for ${path}.`,
114
114
  },
115
115
  ],
116
116
  isError: true,
@@ -132,7 +132,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
132
132
  content: [
133
133
  {
134
134
  type: "text",
135
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists. Call read() to inspect the current state.`
135
+ text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists.`
136
136
  },
137
137
  ],
138
138
  isError: true,
@@ -145,7 +145,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
145
145
  content: [
146
146
  {
147
147
  type: "text",
148
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file was modified after the replace, so undoing would overwrite those changes. Call read() to inspect the current state.`
148
+ text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file changed after the replace. Call read() to inspect the current state.`
149
149
  },
150
150
  ],
151
151
  isError: true,
@@ -182,11 +182,11 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
182
182
  ];
183
183
  if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
184
184
  parts.push(
185
- `Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
185
+ `Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
186
186
  );
187
187
  }
188
188
  parts.push(
189
- "File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
189
+ "Call read for fresh anchors.",
190
190
  );
191
191
 
192
192
  return {
package/src/replace.ts CHANGED
@@ -52,7 +52,7 @@ import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryB
52
52
  const replacementLinesSchema = Type.Array(
53
53
  Type.String({
54
54
  description:
55
- "One replacement line. Each element is exactly one line; do not embed \\n inside an element use separate elements.",
55
+ "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
56
56
  }),
57
57
  {
58
58
  description:
@@ -61,16 +61,16 @@ const replacementLinesSchema = Type.Array(
61
61
  );
62
62
 
63
63
  const removeFromSchema = Type.String({
64
- description: "Bare 3-char HASH only (e.g. \"aB3\") copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
64
+ description: "Bare 3-char HASH only (e.g. \"aB3\"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
65
65
  });
66
66
 
67
67
  const removeToSchema = Type.String({
68
- description: "Bare 3-char HASH only (e.g. \"aB3\") copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
68
+ description: "Bare 3-char HASH only (e.g. \"aB3\"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
69
69
  });
70
70
 
71
71
  export const editToolSchema = Type.Object(
72
72
  {
73
- path: Type.Optional(Type.String({ description: "Path to edit. Required always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
73
+ path: Type.Optional(Type.String({ description: "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
74
74
  remove_from: removeFromSchema,
75
75
  remove_to: removeToSchema,
76
76
  replacement_lines: replacementLinesSchema,
@@ -109,6 +109,7 @@ interface PipelineResult {
109
109
  totalAddedLines: number;
110
110
  totalRemovedLines: number;
111
111
  hadBoundaryDedup: boolean;
112
+ boundaryRemovedLines: number;
112
113
  }
113
114
 
114
115
  const PREVIEW_DEBOUNCE_MS = 150;
@@ -135,7 +136,7 @@ export function assertReq(
135
136
  request.replacement_lines.some((line) => typeof line !== "string")
136
137
  ) {
137
138
  throw new Error(
138
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
139
+ '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
139
140
  );
140
141
  }
141
142
  }
@@ -165,12 +166,12 @@ async function resolveMissingPath(
165
166
  if (matches.length === 1) {
166
167
  return {
167
168
  path: matches[0]!,
168
- warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]} — the only file whose stored hashes contain both anchors.`,
169
+ warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]}.`,
169
170
  };
170
171
  }
171
172
  if (matches.length > 1) {
172
173
  throw new Error(
173
- `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}. Include the intended path.`,
174
+ `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}.`,
174
175
  );
175
176
  }
176
177
  return undefined;
@@ -303,6 +304,7 @@ export async function execPipeline(
303
304
  totalAddedLines,
304
305
  totalRemovedLines,
305
306
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
307
+ boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
306
308
  };
307
309
  }
308
310
 
@@ -500,6 +502,7 @@ export function buildToolDef(): ToolDef {
500
502
  lastChangedLine,
501
503
  resultHashes,
502
504
  hadBoundaryDedup,
505
+ boundaryRemovedLines,
503
506
  totalAddedLines,
504
507
  totalRemovedLines,
505
508
  } = await execPipeline(
@@ -512,7 +515,7 @@ export function buildToolDef(): ToolDef {
512
515
  warnings.unshift(resolution.warning);
513
516
  }
514
517
  if (boundaryBypass && originalNormalized !== result) {
515
- warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
518
+ warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on.");
516
519
  }
517
520
 
518
521
  const editsAttempted = 1;
@@ -532,6 +535,7 @@ export function buildToolDef(): ToolDef {
532
535
  removedLines: 0,
533
536
  },
534
537
  warnings,
538
+ boundaryRemovedLines,
535
539
  });
536
540
  }
537
541
 
@@ -551,7 +555,7 @@ export function buildToolDef(): ToolDef {
551
555
  });
552
556
  if (!undo.persisted) {
553
557
  throw new Error(
554
- `[E_UNDO_UNAVAILABLE] Cannot persist undo history to the hash store; the edit was NOT applied and ${path} is unchanged. Retry the replace, or use write if the store cannot be recovered.`
558
+ `[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${path} is unchanged.`
555
559
  );
556
560
  }
557
561
  try {
package/src/validation.ts CHANGED
@@ -38,7 +38,7 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
38
38
  }
39
39
  if (file.kind === "too_large") {
40
40
  throw new Error(
41
- `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
41
+ `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). For very large files, use write.`,
42
42
  );
43
43
  }
44
44
  }