pi-hashline-edit-pro 0.12.2 → 0.13.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 +5 -5
- package/package.json +1 -1
- package/prompts/read.md +1 -0
- package/prompts/replace-guidelines.md +2 -1
- package/prompts/replace.md +41 -20
- package/src/hashline/apply.ts +62 -13
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +18 -18
- package/src/replace.ts +3 -3
package/README.md
CHANGED
|
@@ -66,18 +66,18 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
66
66
|
{
|
|
67
67
|
"path": "src/main.ts",
|
|
68
68
|
"changes": [
|
|
69
|
-
{ "
|
|
69
|
+
{ "hash_range_inclusive": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
|
|
70
70
|
]
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
| Field | Description |
|
|
75
75
|
| --- | --- |
|
|
76
|
-
| `
|
|
76
|
+
| `hash_range_inclusive` | Inclusive line range `[start_hash, end_hash]` (required). |
|
|
77
77
|
| `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
|
|
78
78
|
|
|
79
79
|
- **Request structure validation.** The request envelope (`path`, `changes`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
|
|
80
|
-
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{
|
|
80
|
+
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
|
|
81
81
|
- **Batched atomicity.** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
|
|
82
82
|
|
|
83
83
|
### Chained edits
|
|
@@ -101,12 +101,12 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
|
|
|
101
101
|
|
|
102
102
|
## Design Decisions
|
|
103
103
|
|
|
104
|
-
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `
|
|
104
|
+
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
105
105
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
106
106
|
- **Strict patch content.** If `content_lines` contains `+HASH│` display prefixes (or `-N ` numbered deletion rows), the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
|
|
107
107
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
108
108
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
109
|
-
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning
|
|
109
|
+
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it — and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
110
110
|
|
|
111
111
|
## Hashing
|
|
112
112
|
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -7,6 +7,7 @@ HASH format:
|
|
|
7
7
|
|
|
8
8
|
Pagination:
|
|
9
9
|
- Large files return a truncated preview with a pagination hint (e.g. `[Showing lines 1-100 of 500. Use offset=101 to continue.]`). Call `read` again with `offset=N` to continue.
|
|
10
|
+
- Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; output exceeding either is truncated. Pass `limit` to read fewer lines.
|
|
10
11
|
|
|
11
12
|
File kinds:
|
|
12
13
|
- Text files are returned as `HASH│content` lines.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
2
|
- After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
|
|
3
|
-
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
|
|
4
|
+
- `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
|
package/prompts/replace.md
CHANGED
|
@@ -9,10 +9,10 @@ How to use:
|
|
|
9
9
|
read({ path: "src/main.ts" })
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
2. Copy the 3-character HASH (before `│`) into `
|
|
12
|
+
2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
|
|
13
13
|
```json
|
|
14
14
|
{ "path": "src/main.ts", "changes": [
|
|
15
|
-
{ "
|
|
15
|
+
{ "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
|
|
16
16
|
] }
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -21,14 +21,14 @@ Examples:
|
|
|
21
21
|
1. Single line replace:
|
|
22
22
|
```json
|
|
23
23
|
{ "path": "src/main.ts", "changes": [
|
|
24
|
-
{ "
|
|
24
|
+
{ "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
|
|
25
25
|
] }
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
2. Range replace (3 lines → 3 new lines):
|
|
29
29
|
```json
|
|
30
30
|
{ "path": "src/main.ts", "changes": [
|
|
31
|
-
{ "
|
|
31
|
+
{ "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
|
|
32
32
|
"function greet(name) {",
|
|
33
33
|
" return `Hello, ${name}`;",
|
|
34
34
|
"}"
|
|
@@ -39,8 +39,8 @@ Examples:
|
|
|
39
39
|
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
40
40
|
```json
|
|
41
41
|
{ "path": "src/server.ts", "changes": [
|
|
42
|
-
{ "
|
|
43
|
-
{ "
|
|
42
|
+
{ "hash_range_inclusive": ["aB3", "xY7"], "content_lines": [] },
|
|
43
|
+
{ "hash_range_inclusive": ["MQX", "ZPM"], "content_lines": [] }
|
|
44
44
|
] }
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ Examples:
|
|
|
48
48
|
|
|
49
49
|
```json
|
|
50
50
|
{ "path": "src/main.ts", "changes": [
|
|
51
|
-
{ "
|
|
51
|
+
{ "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
|
|
52
52
|
] }
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -56,7 +56,7 @@ Examples:
|
|
|
56
56
|
|
|
57
57
|
```json
|
|
58
58
|
{ "path": "src/main.ts", "changes": [
|
|
59
|
-
{ "
|
|
59
|
+
{ "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
|
|
60
60
|
] }
|
|
61
61
|
```
|
|
62
62
|
|
|
@@ -64,48 +64,69 @@ Examples:
|
|
|
64
64
|
|
|
65
65
|
Wrong:
|
|
66
66
|
```json
|
|
67
|
-
{ "
|
|
67
|
+
{ "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Right:
|
|
71
71
|
```json
|
|
72
|
-
{ "
|
|
72
|
+
{ "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
`
|
|
75
|
+
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
76
76
|
|
|
77
|
-
⚠️ Common mistake: `
|
|
77
|
+
⚠️ Common mistake: `hash_range_inclusive` is only the 3-character HASH, not the full `HASH│content` line.
|
|
78
78
|
|
|
79
79
|
Wrong:
|
|
80
80
|
```json
|
|
81
|
-
{ "
|
|
81
|
+
{ "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
Right:
|
|
85
85
|
```json
|
|
86
|
-
{ "
|
|
86
|
+
{ "hash_range_inclusive": ["F4T", "F4T"], "content_lines": [...] }
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Rules:
|
|
90
|
-
- `
|
|
90
|
+
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
91
91
|
- To delete a range, use `content_lines: []`.
|
|
92
|
-
- `
|
|
92
|
+
- `hash_range_inclusive` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
93
93
|
- `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
|
|
94
94
|
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
95
95
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
96
96
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
97
97
|
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
98
|
-
- The `
|
|
98
|
+
- The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
|
|
99
99
|
On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
|
|
100
100
|
|
|
101
|
+
⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
|
|
102
|
+
|
|
103
|
+
Wrong: To replace lines 2-3 in this function:
|
|
104
|
+
```
|
|
105
|
+
function greet() {
|
|
106
|
+
const x = 1;
|
|
107
|
+
return x;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
A model might write:
|
|
111
|
+
```json
|
|
112
|
+
{ "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;", "}"] }
|
|
113
|
+
```
|
|
114
|
+
This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
|
|
115
|
+
|
|
116
|
+
Right: Only include the new lines that belong in the range:
|
|
117
|
+
```json
|
|
118
|
+
{ "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;"] }
|
|
119
|
+
```
|
|
120
|
+
The `}` on line 4 is outside the range and stays in place.
|
|
121
|
+
|
|
101
122
|
Error recovery:
|
|
102
|
-
- `[E_STALE_ANCHOR]` — the anchored line's content changed since the last 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 `
|
|
123
|
+
- `[E_STALE_ANCHOR]` — the anchored line's content changed since the last 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 `hash_range_inclusive` and retry. (Staleness is per-line: editing or appending lines does not invalidate anchors for lines whose content is unchanged, so anchors for untouched regions stay valid across edits.)
|
|
103
124
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
104
125
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
105
126
|
- `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
|
|
106
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{
|
|
127
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_inclusive, content_lines}` instead.
|
|
107
128
|
- `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
|
|
108
129
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
109
|
-
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `
|
|
130
|
+
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
|
|
110
131
|
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
111
132
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
package/src/hashline/apply.ts
CHANGED
|
@@ -76,8 +76,8 @@ function resToSpan(
|
|
|
76
76
|
): RESpan | null {
|
|
77
77
|
const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
|
|
78
78
|
|
|
79
|
-
const startLine = edit.
|
|
80
|
-
const endLine = edit.
|
|
79
|
+
const startLine = edit.hash_range_inclusive[0].line;
|
|
80
|
+
const endLine = edit.hash_range_inclusive[1].line;
|
|
81
81
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
82
82
|
if (
|
|
83
83
|
originalLines.length === edit.content_lines.length &&
|
|
@@ -87,7 +87,7 @@ function resToSpan(
|
|
|
87
87
|
) {
|
|
88
88
|
noopEdits.push({
|
|
89
89
|
editIndex: index,
|
|
90
|
-
loc: edit.
|
|
90
|
+
loc: edit.hash_range_inclusive[0].hash,
|
|
91
91
|
currentContent: originalLines.join("\n"),
|
|
92
92
|
});
|
|
93
93
|
return null;
|
|
@@ -211,6 +211,56 @@ function assemble(
|
|
|
211
211
|
return result;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Builds the boundary-duplication warning shown after an edit. A minimal header
|
|
216
|
+
* is followed by a hashline-anchored window: 2 lines of context before the
|
|
217
|
+
* duplicated pair, the pair itself, and 2 lines after. The window carries the
|
|
218
|
+
* post-edit hashes the model needs to remove the duplicate in a follow-up
|
|
219
|
+
* `replace` (no `read` round-trip required, since the hashes are current and
|
|
220
|
+
* staleness is per-line). Rows are plain `HASH│content` — no annotations.
|
|
221
|
+
*/
|
|
222
|
+
function fmtBoundaryWarning(params: {
|
|
223
|
+
kind: "trailing" | "leading";
|
|
224
|
+
survivingContent: string;
|
|
225
|
+
matchIndex: number;
|
|
226
|
+
resultLines: string[];
|
|
227
|
+
resultHashes: string[];
|
|
228
|
+
}): string {
|
|
229
|
+
const header =
|
|
230
|
+
params.kind === "trailing"
|
|
231
|
+
? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
|
|
232
|
+
: "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
|
|
233
|
+
|
|
234
|
+
// Locate the adjacent duplicated pair (two identical neighboring lines). The
|
|
235
|
+
// occurrence-based matchIndex usually lands inside the pair; when a file has
|
|
236
|
+
// several identical lines we pick the pair nearest matchIndex so the window
|
|
237
|
+
// frames the duplication this edit introduced, not an unrelated one.
|
|
238
|
+
let pairStart = -1;
|
|
239
|
+
let bestDist = Infinity;
|
|
240
|
+
for (let i = 0; i < params.resultLines.length - 1; i++) {
|
|
241
|
+
if (
|
|
242
|
+
params.resultLines[i] === params.survivingContent &&
|
|
243
|
+
params.resultLines[i + 1] === params.survivingContent
|
|
244
|
+
) {
|
|
245
|
+
const dist = Math.abs(i - params.matchIndex);
|
|
246
|
+
if (dist < bestDist) {
|
|
247
|
+
bestDist = dist;
|
|
248
|
+
pairStart = i;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (pairStart < 0) pairStart = params.matchIndex;
|
|
253
|
+
|
|
254
|
+
const winStart = Math.max(0, pairStart - 2);
|
|
255
|
+
const winEnd = Math.min(params.resultLines.length - 1, pairStart + 3);
|
|
256
|
+
|
|
257
|
+
const rows: string[] = [];
|
|
258
|
+
for (let i = winStart; i <= winEnd; i++) {
|
|
259
|
+
rows.push(`${params.resultHashes[i]}${HASH_SEP}${params.resultLines[i]}`);
|
|
260
|
+
}
|
|
261
|
+
return `${header}\n\n${rows.join("\n")}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
214
264
|
export function applyEdits(
|
|
215
265
|
content: string,
|
|
216
266
|
edits: import("./resolve").HEdit[],
|
|
@@ -284,16 +334,15 @@ export function applyEdits(
|
|
|
284
334
|
}
|
|
285
335
|
}
|
|
286
336
|
if (matchIndex >= 0) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
337
|
+
warnings.push(
|
|
338
|
+
fmtBoundaryWarning({
|
|
339
|
+
kind: bw.kind,
|
|
340
|
+
survivingContent: bw.survivingLineContent,
|
|
341
|
+
matchIndex,
|
|
342
|
+
resultLines,
|
|
343
|
+
resultHashes,
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
297
346
|
}
|
|
298
347
|
}
|
|
299
348
|
|
package/src/hashline/parse.ts
CHANGED
|
@@ -20,7 +20,7 @@ function diagRef(ref: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (trimmed.includes("│")) {
|
|
23
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}".
|
|
23
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_inclusive must contain the 3-char hash only — remove everything from "│" onward.`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char base64 anchor (e.g. "aB3").`;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -9,9 +9,9 @@ export type RAnchor = {
|
|
|
9
9
|
hashMatched: boolean;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export type HEdit = {
|
|
12
|
+
export type HEdit = { hash_range_inclusive: [Anchor, Anchor]; content_lines: string[] };
|
|
13
13
|
export type RHEdit = {
|
|
14
|
-
|
|
14
|
+
hash_range_inclusive: [RAnchor, RAnchor];
|
|
15
15
|
content_lines: string[];
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -37,7 +37,7 @@ export interface NEdit {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export type HTEdit = {
|
|
40
|
-
|
|
40
|
+
hash_range_inclusive: [string, string];
|
|
41
41
|
content_lines: string[];
|
|
42
42
|
};
|
|
43
43
|
|
|
@@ -90,13 +90,13 @@ export function fmtMismatch(
|
|
|
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}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into
|
|
93
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
if (ambiguous.length > 0) {
|
|
97
97
|
if (out.length > 0) out.push("");
|
|
98
98
|
out.push(
|
|
99
|
-
`[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
|
|
99
|
+
`[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 hash_range_inclusive of your next replace call.`
|
|
100
100
|
);
|
|
101
101
|
for (const m of ambiguous) {
|
|
102
102
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -119,7 +119,7 @@ export function fmtMismatch(
|
|
|
119
119
|
return out.join("\n");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
const ITEM_KS = new Set(["
|
|
122
|
+
const ITEM_KS = new Set(["hash_range_inclusive", "content_lines"]);
|
|
123
123
|
|
|
124
124
|
function isStrArr(value: unknown): value is string[] {
|
|
125
125
|
return (
|
|
@@ -136,11 +136,11 @@ function isStrPair(value: unknown): value is [string, string] {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
139
|
-
rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only {
|
|
139
|
+
rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { hash_range_inclusive, content_lines }.");
|
|
140
140
|
|
|
141
|
-
if ("
|
|
141
|
+
if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
|
|
142
142
|
throw new Error(
|
|
143
|
-
`[E_BAD_SHAPE] Edit ${index} field "
|
|
143
|
+
`[E_BAD_SHAPE] Edit ${index} field "hash_range_inclusive" must be a pair of anchor strings [start, end].`,
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
if (!("content_lines" in edit)) {
|
|
@@ -149,9 +149,9 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
|
149
149
|
if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
|
|
150
150
|
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
|
|
151
151
|
}
|
|
152
|
-
if (!isStrPair(edit.
|
|
152
|
+
if (!isStrPair(edit.hash_range_inclusive)) {
|
|
153
153
|
throw new Error(
|
|
154
|
-
`[E_BAD_SHAPE] Edit ${index} requires an "
|
|
154
|
+
`[E_BAD_SHAPE] Edit ${index} requires an "hash_range_inclusive" pair of anchor strings [start, end].`,
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -163,7 +163,7 @@ export function resEdits(edits: HTEdit[]): HEdit[] {
|
|
|
163
163
|
|
|
164
164
|
const replaceLines = parseText(edit.content_lines);
|
|
165
165
|
result.push({
|
|
166
|
-
|
|
166
|
+
hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
|
|
167
167
|
content_lines: replaceLines,
|
|
168
168
|
});
|
|
169
169
|
}
|
|
@@ -214,12 +214,12 @@ export function assertNoBarePrefix(
|
|
|
214
214
|
: `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
|
|
215
215
|
|
|
216
216
|
throw new Error(
|
|
217
|
-
`[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember:
|
|
217
|
+
`[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: hash_range_inclusive uses hash anchors, content_lines uses file content only.`
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
export function descEdit(edit: RHEdit): string {
|
|
222
|
-
return `replace ${edit.
|
|
222
|
+
return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
export function valEdits(
|
|
@@ -245,14 +245,14 @@ export function valEdits(
|
|
|
245
245
|
|
|
246
246
|
for (const edit of edits) {
|
|
247
247
|
abortIf(signal);
|
|
248
|
-
const startResolved = tryResolve(edit.
|
|
249
|
-
const endResolved = tryResolve(edit.
|
|
248
|
+
const startResolved = tryResolve(edit.hash_range_inclusive[0]);
|
|
249
|
+
const endResolved = tryResolve(edit.hash_range_inclusive[1]);
|
|
250
250
|
if (!startResolved || !endResolved) {
|
|
251
251
|
continue;
|
|
252
252
|
}
|
|
253
253
|
if (startResolved.line > endResolved.line) {
|
|
254
254
|
throw new Error(
|
|
255
|
-
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.
|
|
255
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
const endLine = endResolved.line;
|
|
@@ -291,7 +291,7 @@ export function valEdits(
|
|
|
291
291
|
});
|
|
292
292
|
}
|
|
293
293
|
resolved.push({
|
|
294
|
-
|
|
294
|
+
hash_range_inclusive: [startResolved, endResolved],
|
|
295
295
|
content_lines: edit.content_lines,
|
|
296
296
|
});
|
|
297
297
|
}
|
package/src/replace.ts
CHANGED
|
@@ -59,7 +59,7 @@ const hashRangeInclSchema = Type.Array(
|
|
|
59
59
|
|
|
60
60
|
const changeItemSchema = Type.Object(
|
|
61
61
|
{
|
|
62
|
-
|
|
62
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
63
63
|
content_lines: contentLinesSchema,
|
|
64
64
|
},
|
|
65
65
|
{ additionalProperties: false },
|
|
@@ -101,7 +101,7 @@ export function assertReq(
|
|
|
101
101
|
for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
|
|
102
102
|
if (has(request, legacyKey)) {
|
|
103
103
|
throw new Error(
|
|
104
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {
|
|
104
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -113,7 +113,7 @@ export function assertReq(
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
if (!Array.isArray(request.changes)) {
|
|
116
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is {
|
|
116
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_inclusive: ["<START>", "<END>"], content_lines: [...] }.');
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|