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 +16 -16
- package/index.ts +2 -2
- package/package.json +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-replace-guidelines.md +1 -1
- package/src/constants.ts +1 -2
- package/src/file-kind.ts +1 -1
- package/src/file-reader.ts +1 -1
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/parse.ts +3 -3
- package/src/hashline/resolve.ts +15 -17
- package/src/read.ts +2 -2
- package/src/replace-response.ts +7 -1
- package/src/replace-undo.ts +5 -5
- package/src/replace.ts +13 -9
- package/src/validation.ts +1 -1
package/README.md
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-hashline-edit-pro) [](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.
|
|
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
|
-
##
|
|
9
|
+
## Features
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
101
|
-
- Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4
|
-
- `replace`: to replace a single line,
|
|
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
|
|
7
|
-
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file
|
|
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
|
|
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
|
package/prompts/replace.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/file-reader.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -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]
|
|
58
|
+
"[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -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]
|
|
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
|
|
package/src/hashline/parse.ts
CHANGED
|
@@ -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")
|
|
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}"
|
|
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
|
|
55
|
+
"[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
return out;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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
|
|
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()
|
|
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"
|
|
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]
|
|
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]
|
|
179
|
+
message = `[E_BAD_REF] Stripped leading "-" marker from remove_from/remove_to entry "${trimmed}".`;
|
|
180
180
|
} else {
|
|
181
|
-
message = `[E_BAD_REF]
|
|
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.
|
|
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
|
|
225
|
+
? " Verify it was pasted from read output."
|
|
230
226
|
: "";
|
|
231
227
|
warnings.push(
|
|
232
|
-
`[E_BARE_HASH_PREFIX]
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)) {
|
package/src/replace-response.ts
CHANGED
|
@@ -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",
|
package/src/replace-undo.ts
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
|
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)
|
|
185
|
+
`Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
parts.push(
|
|
189
|
-
"
|
|
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
|
|
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\")
|
|
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\")
|
|
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
|
|
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"
|
|
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]}
|
|
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(", ")}
|
|
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
|
|
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]
|
|
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}).
|
|
41
|
+
`[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). For very large files, use write.`,
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
}
|