pi-hashline-edit-pro 0.16.2 → 0.16.3
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 +4 -5
- package/index.ts +3 -1
- package/package.json +1 -1
- package/prompts/replace-bulk.md +28 -34
- package/prompts/replace-flat.md +18 -24
- package/prompts/replace.md +37 -43
- package/src/file-reader.ts +2 -2
- package/src/hashline/resolve.ts +49 -48
- package/src/replace-normalize.ts +16 -101
- package/src/replace.ts +12 -15
- package/src/utils.ts +16 -3
package/README.md
CHANGED
|
@@ -68,8 +68,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
68
68
|
{
|
|
69
69
|
"path": "src/main.ts",
|
|
70
70
|
"changes": [
|
|
71
|
-
{ "
|
|
72
|
-
]
|
|
71
|
+
{ "content_lines": [" console.log('hashline');"], "hash_range_inclusive": ["ve7", "ve7"] }
|
|
73
72
|
}
|
|
74
73
|
```
|
|
75
74
|
|
|
@@ -77,9 +76,9 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
77
76
|
|
|
78
77
|
```json
|
|
79
78
|
{
|
|
80
|
-
"
|
|
79
|
+
"content_lines": [" console.log('hashline');"],
|
|
81
80
|
"hash_range_inclusive": ["ve7", "ve7"],
|
|
82
|
-
"
|
|
81
|
+
"path": "src/main.ts"
|
|
83
82
|
}
|
|
84
83
|
```
|
|
85
84
|
|
|
@@ -89,7 +88,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
89
88
|
| `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
|
|
90
89
|
|
|
91
90
|
- **Request structure validation.** The request envelope (`path`, `changes` in bulk mode; `path`, `hash_range_inclusive`, `content_lines` in flat mode) 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]`.
|
|
92
|
-
- **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>"]
|
|
91
|
+
- **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 `{content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}`.
|
|
93
92
|
- **Batched atomicity (bulk mode).** 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.
|
|
94
93
|
|
|
95
94
|
### Stable hashing across edits
|
package/index.ts
CHANGED
|
@@ -33,7 +33,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
33
33
|
try {
|
|
34
34
|
const store = await loadHashStore();
|
|
35
35
|
await pruneHashStore(store);
|
|
36
|
-
} catch {
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error("Failed to load or prune hash store:", err);
|
|
38
|
+
}
|
|
37
39
|
const mode = await readReplaceMode();
|
|
38
40
|
if (mode === "flat") {
|
|
39
41
|
regReplaceFlat(pi);
|
package/package.json
CHANGED
package/prompts/replace-bulk.md
CHANGED
|
@@ -11,64 +11,62 @@ read({ path: "src/main.ts" })
|
|
|
11
11
|
|
|
12
12
|
2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
|
|
13
13
|
```json
|
|
14
|
-
{ "
|
|
15
|
-
{ "
|
|
16
|
-
] }
|
|
14
|
+
{ "changes": [
|
|
15
|
+
{ "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"] }
|
|
16
|
+
], "path": "src/main.ts" }
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Examples:
|
|
20
20
|
|
|
21
21
|
1. Single line replace:
|
|
22
22
|
```json
|
|
23
|
-
{ "
|
|
24
|
-
{ "
|
|
25
|
-
] }
|
|
23
|
+
{ "changes": [
|
|
24
|
+
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"] }
|
|
25
|
+
], "path": "src/main.ts" }
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
2. Range replace (3 lines → 3 new lines):
|
|
29
29
|
```json
|
|
30
|
-
{ "
|
|
31
|
-
{ "
|
|
30
|
+
{ "changes": [
|
|
31
|
+
{ "content_lines": [
|
|
32
32
|
"function greet(name) {",
|
|
33
33
|
" return `Hello, ${name}`;",
|
|
34
34
|
"}"
|
|
35
|
-
] }
|
|
36
|
-
] }
|
|
35
|
+
], "hash_range_inclusive": ["ZPM", "VRW"] }
|
|
36
|
+
], "path": "src/main.ts" }
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
40
40
|
```json
|
|
41
|
-
{ "
|
|
42
|
-
{ "hash_range_inclusive": ["aB3", "xY7"]
|
|
43
|
-
{ "hash_range_inclusive": ["MQX", "ZPM"]
|
|
44
|
-
] }
|
|
41
|
+
{ "changes": [
|
|
42
|
+
{ "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"] },
|
|
43
|
+
{ "content_lines": [], "hash_range_inclusive": ["MQX", "ZPM"] }
|
|
44
|
+
], "path": "src/server.ts" }
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
4. Append after the last line (include the old last line so the new line is added after it):
|
|
48
48
|
```json
|
|
49
|
-
{ "
|
|
50
|
-
{ "
|
|
51
|
-
] }
|
|
49
|
+
{ "changes": [
|
|
50
|
+
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"] }
|
|
51
|
+
], "path": "src/main.ts" }
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
5. Seed content into an empty file (replace the single empty-line hash returned by read):
|
|
55
55
|
```json
|
|
56
|
-
{ "
|
|
57
|
-
{ "
|
|
58
|
-
] }
|
|
56
|
+
{ "changes": [
|
|
57
|
+
{ "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"] }
|
|
58
|
+
], "path": "src/main.ts" }
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
62
62
|
|
|
63
63
|
Wrong:
|
|
64
64
|
```json
|
|
65
|
-
{ "
|
|
66
|
-
```
|
|
65
|
+
{ "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
67
66
|
|
|
68
67
|
Right:
|
|
69
68
|
```json
|
|
70
|
-
{ "
|
|
71
|
-
```
|
|
69
|
+
{ "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
72
70
|
|
|
73
71
|
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
74
72
|
|
|
@@ -76,13 +74,11 @@ Right:
|
|
|
76
74
|
|
|
77
75
|
Wrong:
|
|
78
76
|
```json
|
|
79
|
-
{ "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"]
|
|
80
|
-
```
|
|
77
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
|
|
81
78
|
|
|
82
79
|
Right:
|
|
83
80
|
```json
|
|
84
|
-
{ "
|
|
85
|
-
```
|
|
81
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
86
82
|
|
|
87
83
|
Rules:
|
|
88
84
|
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
@@ -107,14 +103,12 @@ function greet() {
|
|
|
107
103
|
```
|
|
108
104
|
A model might write:
|
|
109
105
|
```json
|
|
110
|
-
{ "
|
|
111
|
-
```
|
|
106
|
+
{ "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
|
|
112
107
|
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`.
|
|
113
108
|
|
|
114
109
|
Right: Only include the new lines that belong in the range:
|
|
115
110
|
```json
|
|
116
|
-
{ "
|
|
117
|
-
```
|
|
111
|
+
{ "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
|
|
118
112
|
The `}` on line 4 is outside the range and stays in place.
|
|
119
113
|
|
|
120
114
|
Error recovery:
|
|
@@ -122,10 +116,10 @@ Error recovery:
|
|
|
122
116
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
123
117
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
124
118
|
- `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
|
|
125
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{
|
|
119
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
|
|
126
120
|
- `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
|
|
127
121
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
128
|
-
- `[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. `
|
|
122
|
+
- `[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. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
|
|
129
123
|
- `[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.)
|
|
130
124
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
131
125
|
- `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
|
package/prompts/replace-flat.md
CHANGED
|
@@ -9,51 +9,49 @@ read({ path: "src/main.ts" })
|
|
|
9
9
|
|
|
10
10
|
2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
|
|
11
11
|
```json
|
|
12
|
-
{ "
|
|
12
|
+
{ "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Examples:
|
|
16
16
|
|
|
17
17
|
1. Single line replace:
|
|
18
18
|
```json
|
|
19
|
-
{ "
|
|
19
|
+
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
2. Range replace (3 lines → 3 new lines):
|
|
23
23
|
```json
|
|
24
|
-
{ "
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
] }
|
|
24
|
+
{ "content_lines": [
|
|
25
|
+
"function greet(name) {",
|
|
26
|
+
" return `Hello, ${name}`;",
|
|
27
|
+
"}"
|
|
28
|
+
], "hash_range_inclusive": ["ZPM", "VRW"], "path": "src/main.ts" }
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
3. Delete a range:
|
|
32
32
|
```json
|
|
33
|
-
{ "
|
|
33
|
+
{ "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"], "path": "src/server.ts" }
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
4. Append after the last line (include the old last line so the new line is added after it):
|
|
37
37
|
```json
|
|
38
|
-
{ "
|
|
38
|
+
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"], "path": "src/main.ts" }
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
5. Seed content into an empty file (replace the single empty-line hash returned by read):
|
|
42
42
|
```json
|
|
43
|
-
{ "
|
|
43
|
+
{ "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"], "path": "src/main.ts" }
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
47
47
|
|
|
48
48
|
Wrong:
|
|
49
49
|
```json
|
|
50
|
-
{ "
|
|
51
|
-
```
|
|
50
|
+
{ "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
52
51
|
|
|
53
52
|
Right:
|
|
54
53
|
```json
|
|
55
|
-
{ "
|
|
56
|
-
```
|
|
54
|
+
{ "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
57
55
|
|
|
58
56
|
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
59
57
|
|
|
@@ -61,13 +59,11 @@ Right:
|
|
|
61
59
|
|
|
62
60
|
Wrong:
|
|
63
61
|
```json
|
|
64
|
-
{ "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"]
|
|
65
|
-
```
|
|
62
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
|
|
66
63
|
|
|
67
64
|
Right:
|
|
68
65
|
```json
|
|
69
|
-
{ "
|
|
70
|
-
```
|
|
66
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
71
67
|
|
|
72
68
|
Rules:
|
|
73
69
|
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
@@ -93,14 +89,12 @@ function greet() {
|
|
|
93
89
|
```
|
|
94
90
|
A model might write:
|
|
95
91
|
```json
|
|
96
|
-
{ "
|
|
97
|
-
```
|
|
92
|
+
{ "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
|
|
98
93
|
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`.
|
|
99
94
|
|
|
100
95
|
Right: Only include the new lines that belong in the range:
|
|
101
96
|
```json
|
|
102
|
-
{ "
|
|
103
|
-
```
|
|
97
|
+
{ "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
|
|
104
98
|
The `}` on line 4 is outside the range and stays in place.
|
|
105
99
|
|
|
106
100
|
Error recovery:
|
|
@@ -108,9 +102,9 @@ Error recovery:
|
|
|
108
102
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
109
103
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
110
104
|
- `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
|
|
111
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{
|
|
105
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
|
|
112
106
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
113
|
-
- `[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. `
|
|
107
|
+
- `[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. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
|
|
114
108
|
- `[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.)
|
|
115
109
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
116
110
|
- `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
|
package/prompts/replace.md
CHANGED
|
@@ -21,14 +21,14 @@ read({ path: "src/main.ts" })
|
|
|
21
21
|
|
|
22
22
|
**Bulk mode:**
|
|
23
23
|
```json
|
|
24
|
-
{ "
|
|
25
|
-
{ "
|
|
26
|
-
] }
|
|
24
|
+
{ "changes": [
|
|
25
|
+
{ "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"] }
|
|
26
|
+
], "path": "src/main.ts" }
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
**Flat mode:**
|
|
30
30
|
```json
|
|
31
|
-
{ "
|
|
31
|
+
{ "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Examples:
|
|
@@ -37,85 +37,83 @@ Examples:
|
|
|
37
37
|
|
|
38
38
|
Bulk:
|
|
39
39
|
```json
|
|
40
|
-
{ "
|
|
41
|
-
{ "
|
|
42
|
-
] }
|
|
40
|
+
{ "changes": [
|
|
41
|
+
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"] }
|
|
42
|
+
], "path": "src/main.ts" }
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
Flat:
|
|
46
46
|
```json
|
|
47
|
-
{ "
|
|
47
|
+
{ "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
2. Range replace (3 lines → 3 new lines):
|
|
51
51
|
|
|
52
52
|
Bulk:
|
|
53
53
|
```json
|
|
54
|
-
{ "
|
|
55
|
-
{ "
|
|
54
|
+
{ "changes": [
|
|
55
|
+
{ "content_lines": [
|
|
56
56
|
"function greet(name) {",
|
|
57
57
|
" return `Hello, ${name}`;",
|
|
58
58
|
"}"
|
|
59
|
-
] }
|
|
60
|
-
] }
|
|
59
|
+
], "hash_range_inclusive": ["ZPM", "VRW"] }
|
|
60
|
+
], "path": "src/main.ts" }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Flat:
|
|
64
64
|
```json
|
|
65
|
-
{ "
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
] }
|
|
65
|
+
{ "content_lines": [
|
|
66
|
+
"function greet(name) {",
|
|
67
|
+
" return `Hello, ${name}`;",
|
|
68
|
+
"}"
|
|
69
|
+
], "hash_range_inclusive": ["ZPM", "VRW"], "path": "src/main.ts" }
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
3. Multiple regions in one call (bulk mode only — flat mode supports one edit per call):
|
|
73
73
|
```json
|
|
74
|
-
{ "
|
|
75
|
-
{ "hash_range_inclusive": ["aB3", "xY7"]
|
|
76
|
-
{ "hash_range_inclusive": ["MQX", "ZPM"]
|
|
77
|
-
] }
|
|
74
|
+
{ "changes": [
|
|
75
|
+
{ "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"] },
|
|
76
|
+
{ "content_lines": [], "hash_range_inclusive": ["MQX", "ZPM"] }
|
|
77
|
+
], "path": "src/server.ts" }
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
4. Append after the last line (include the old last line so the new line is added after it):
|
|
81
81
|
|
|
82
82
|
Bulk:
|
|
83
83
|
```json
|
|
84
|
-
{ "
|
|
85
|
-
{ "
|
|
86
|
-
] }
|
|
84
|
+
{ "changes": [
|
|
85
|
+
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"] }
|
|
86
|
+
], "path": "src/main.ts" }
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Flat:
|
|
90
90
|
```json
|
|
91
|
-
{ "
|
|
91
|
+
{ "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"], "path": "src/main.ts" }
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
5. Seed content into an empty file (replace the single empty-line hash returned by read):
|
|
95
95
|
|
|
96
96
|
Bulk:
|
|
97
97
|
```json
|
|
98
|
-
{ "
|
|
99
|
-
{ "
|
|
100
|
-
] }
|
|
98
|
+
{ "changes": [
|
|
99
|
+
{ "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"] }
|
|
100
|
+
], "path": "src/main.ts" }
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
Flat:
|
|
104
104
|
```json
|
|
105
|
-
{ "
|
|
105
|
+
{ "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"], "path": "src/main.ts" }
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
109
109
|
|
|
110
110
|
Wrong:
|
|
111
111
|
```json
|
|
112
|
-
{ "
|
|
113
|
-
```
|
|
112
|
+
{ "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
114
113
|
|
|
115
114
|
Right:
|
|
116
115
|
```json
|
|
117
|
-
{ "
|
|
118
|
-
```
|
|
116
|
+
{ "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
119
117
|
|
|
120
118
|
`hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
121
119
|
|
|
@@ -123,13 +121,11 @@ Right:
|
|
|
123
121
|
|
|
124
122
|
Wrong:
|
|
125
123
|
```json
|
|
126
|
-
{ "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"]
|
|
127
|
-
```
|
|
124
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
|
|
128
125
|
|
|
129
126
|
Right:
|
|
130
127
|
```json
|
|
131
|
-
{ "
|
|
132
|
-
```
|
|
128
|
+
{ "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
|
|
133
129
|
|
|
134
130
|
Rules:
|
|
135
131
|
- `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
|
|
@@ -156,14 +152,12 @@ function greet() {
|
|
|
156
152
|
```
|
|
157
153
|
A model might write:
|
|
158
154
|
```json
|
|
159
|
-
{ "
|
|
160
|
-
```
|
|
155
|
+
{ "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
|
|
161
156
|
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`.
|
|
162
157
|
|
|
163
158
|
Right: Only include the new lines that belong in the range:
|
|
164
159
|
```json
|
|
165
|
-
{ "
|
|
166
|
-
```
|
|
160
|
+
{ "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
|
|
167
161
|
The `}` on line 4 is outside the range and stays in place.
|
|
168
162
|
|
|
169
163
|
Error recovery:
|
|
@@ -171,10 +165,10 @@ Error recovery:
|
|
|
171
165
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
172
166
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
173
167
|
- `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
|
|
174
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{
|
|
168
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
|
|
175
169
|
- `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
|
|
176
170
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
177
|
-
- `[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. `
|
|
171
|
+
- `[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. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
|
|
178
172
|
- `[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.)
|
|
179
173
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
|
180
174
|
- `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
|
package/src/file-reader.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { toCwd } from "./path-utils";
|
|
|
6
6
|
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
7
7
|
import { abortIf } from "./runtime";
|
|
8
8
|
import { assertText, valAccess } from "./validation";
|
|
9
|
-
|
|
9
|
+
import { visLines } from "./utils";
|
|
10
10
|
export interface NormFile {
|
|
11
11
|
absolutePath: string;
|
|
12
12
|
normalized: string;
|
|
@@ -40,7 +40,7 @@ export async function readNormFile(
|
|
|
40
40
|
const normalized = toLF(rawContent);
|
|
41
41
|
|
|
42
42
|
if (maxLines !== undefined) {
|
|
43
|
-
const lineCount = normalized
|
|
43
|
+
const lineCount = visLines(normalized).length;
|
|
44
44
|
if (lineCount > maxLines) {
|
|
45
45
|
throw new Error(
|
|
46
46
|
`[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -9,10 +9,10 @@ export type RAnchor = {
|
|
|
9
9
|
hashMatched: boolean;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export type HEdit = {
|
|
12
|
+
export type HEdit = { content_lines: string[]; hash_range_inclusive: [Anchor, Anchor] };
|
|
13
13
|
export type RHEdit = {
|
|
14
|
-
hash_range_inclusive: [RAnchor, RAnchor];
|
|
15
14
|
content_lines: string[];
|
|
15
|
+
hash_range_inclusive: [RAnchor, RAnchor];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
interface HMismatch {
|
|
@@ -37,8 +37,8 @@ export interface NEdit {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export type HTEdit = {
|
|
40
|
-
hash_range_inclusive: [string, string];
|
|
41
40
|
content_lines: string[];
|
|
41
|
+
hash_range_inclusive: [string, string];
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
function resAnchor(
|
|
@@ -75,51 +75,52 @@ function assertAligned(
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
|
|
78
79
|
export function fmtMismatch(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
80
|
+
mismatches: HMismatch[],
|
|
81
|
+
fileLines: string[],
|
|
82
|
+
fileHashes: string[],
|
|
83
|
+
filePath?: string,
|
|
84
|
+
): string {
|
|
85
|
+
assertAligned(fileLines, fileHashes, "fmtMismatch");
|
|
86
|
+
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
const notFound = mismatches.filter((m) => m.kind === "not_found");
|
|
89
|
+
const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
|
|
90
|
+
|
|
91
|
+
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
92
|
+
if (notFound.length > 0) {
|
|
93
|
+
out.push(
|
|
94
|
+
`[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.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (ambiguous.length > 0) {
|
|
98
|
+
if (out.length > 0) out.push("");
|
|
99
|
+
out.push(
|
|
100
|
+
`[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.`
|
|
101
|
+
);
|
|
102
|
+
for (const m of ambiguous) {
|
|
103
|
+
const sample = (m.candidates ?? []).slice(0, 5);
|
|
104
|
+
const more =
|
|
105
|
+
(m.candidates?.length ?? 0) > sample.length
|
|
106
|
+
? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
|
|
107
|
+
: "";
|
|
108
|
+
const lines = sample
|
|
109
|
+
.map((line) => {
|
|
110
|
+
const content = fileLines[line - 1] ?? "";
|
|
111
|
+
return ` ${line}: ${fileHashes[line - 1]}│${content}`;
|
|
112
|
+
})
|
|
113
|
+
.join("\n");
|
|
114
|
+
out.push(
|
|
115
|
+
` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
return out.join("\n");
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
const ITEM_KS = new Set(["
|
|
123
|
+
const ITEM_KS = new Set(["content_lines", "hash_range_inclusive"]);
|
|
123
124
|
|
|
124
125
|
function isStrArr(value: unknown): value is string[] {
|
|
125
126
|
return (
|
|
@@ -136,7 +137,7 @@ function isStrPair(value: unknown): value is [string, string] {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
139
|
-
rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only {
|
|
140
|
+
rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { content_lines, hash_range_inclusive }.");
|
|
140
141
|
|
|
141
142
|
if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
|
|
142
143
|
throw new Error(
|
|
@@ -163,8 +164,8 @@ export function resEdits(edits: HTEdit[]): HEdit[] {
|
|
|
163
164
|
|
|
164
165
|
const replaceLines = parseText(edit.content_lines);
|
|
165
166
|
result.push({
|
|
166
|
-
hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
|
|
167
167
|
content_lines: replaceLines,
|
|
168
|
+
hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
171
|
return result;
|
|
@@ -214,7 +215,7 @@ export function assertNoBarePrefix(
|
|
|
214
215
|
: `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
|
|
215
216
|
|
|
216
217
|
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:
|
|
218
|
+
`[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: content_lines uses file content only, hash_range_inclusive uses hash anchors.`
|
|
218
219
|
);
|
|
219
220
|
}
|
|
220
221
|
|
|
@@ -290,8 +291,8 @@ export function valEdits(
|
|
|
290
291
|
const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, fileLines, resolved.length);
|
|
291
292
|
if (leading) boundaryWarnings.push(leading);
|
|
292
293
|
resolved.push({
|
|
293
|
-
hash_range_inclusive: [startResolved, endResolved],
|
|
294
294
|
content_lines: edit.content_lines,
|
|
295
|
+
hash_range_inclusive: [startResolved, endResolved],
|
|
295
296
|
});
|
|
296
297
|
}
|
|
297
298
|
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,89 +1,25 @@
|
|
|
1
1
|
import { isRec, has } from "./utils";
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
if (typeof
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} catch {}
|
|
9
|
-
return undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
|
|
13
|
-
const warnings: string[] = [];
|
|
14
|
-
const result = items
|
|
15
|
-
.map((item: unknown) => {
|
|
16
|
-
if (typeof item === "string") {
|
|
17
|
-
const parsed = tryParseJSON(item, isRec);
|
|
18
|
-
if (parsed) {
|
|
19
|
-
warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
|
|
20
|
-
return parsed;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return item;
|
|
24
|
-
})
|
|
25
|
-
.map((change: unknown) => {
|
|
26
|
-
if (!isRec(change)) return change;
|
|
27
|
-
if (typeof change.content_lines !== "string") return change;
|
|
28
|
-
const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
|
|
29
|
-
Array.isArray(v) && v.every((i) => typeof i === "string"),
|
|
30
|
-
);
|
|
31
|
-
if (parsed) {
|
|
32
|
-
warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
|
|
33
|
-
return { ...change, content_lines: parsed };
|
|
34
|
-
}
|
|
35
|
-
return change;
|
|
36
|
-
});
|
|
37
|
-
return { result, warnings };
|
|
3
|
+
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
4
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
5
|
+
record.path = record.file_path;
|
|
6
|
+
delete record.file_path;
|
|
7
|
+
}
|
|
38
8
|
}
|
|
39
9
|
|
|
40
10
|
function normalizeField(
|
|
41
11
|
record: Record<string, unknown>,
|
|
42
12
|
from: string,
|
|
43
13
|
to: string,
|
|
44
|
-
):
|
|
45
|
-
if (!has(record, from)) return
|
|
46
|
-
const raw =
|
|
47
|
-
const wasString = typeof record[from] === "string" && raw !== record[from];
|
|
14
|
+
): void {
|
|
15
|
+
if (!has(record, from)) return;
|
|
16
|
+
const raw = record[from];
|
|
48
17
|
if (Array.isArray(raw)) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return warnings.join(" ");
|
|
53
|
-
}
|
|
54
|
-
} else {
|
|
55
|
-
const single =
|
|
56
|
-
typeof raw === "string"
|
|
57
|
-
? tryParseJSON(raw, isRec)
|
|
58
|
-
: isRec(raw)
|
|
59
|
-
? raw
|
|
60
|
-
: undefined;
|
|
61
|
-
if (single) {
|
|
62
|
-
const { result, warnings } = coerceEditArray([single]);
|
|
63
|
-
record[to] = result;
|
|
64
|
-
if (warnings.length > 0) {
|
|
65
|
-
return warnings.join(" ");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
18
|
+
record[to] = raw;
|
|
19
|
+
} else if (isRec(raw)) {
|
|
20
|
+
record[to] = [raw];
|
|
68
21
|
}
|
|
69
22
|
if (from !== to) delete record[from];
|
|
70
|
-
if (wasString) {
|
|
71
|
-
return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
|
|
72
|
-
}
|
|
73
|
-
return undefined;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
77
|
-
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
78
|
-
record.path = record.file_path;
|
|
79
|
-
delete record.file_path;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function tryParseField(record: Record<string, unknown>, field: string): void {
|
|
84
|
-
if (typeof record[field] === "string") {
|
|
85
|
-
try { record[field] = JSON.parse(record[field] as string); } catch {}
|
|
86
|
-
}
|
|
87
23
|
}
|
|
88
24
|
|
|
89
25
|
export function normReq(input: unknown): unknown {
|
|
@@ -92,42 +28,21 @@ export function normReq(input: unknown): unknown {
|
|
|
92
28
|
}
|
|
93
29
|
|
|
94
30
|
const record: Record<string, unknown> = { ...input };
|
|
95
|
-
const warnings: string[] = [];
|
|
96
31
|
|
|
97
32
|
normalizeFilePath(record);
|
|
98
33
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const w2 = normalizeField(record, "edits", "changes");
|
|
102
|
-
if (w2) warnings.push(w2);
|
|
34
|
+
normalizeField(record, "changes", "changes");
|
|
35
|
+
normalizeField(record, "edits", "changes");
|
|
103
36
|
|
|
104
37
|
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
|
|
108
|
-
) ?? hriRaw;
|
|
109
|
-
if (typeof hriRaw === "string" && hri !== hriRaw) {
|
|
110
|
-
warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const clRaw = record.content_lines;
|
|
114
|
-
const cl = tryParseJSON(clRaw, (v): v is string[] =>
|
|
115
|
-
Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
116
|
-
) ?? clRaw;
|
|
117
|
-
if (typeof clRaw === "string" && cl !== clRaw) {
|
|
118
|
-
warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
|
|
119
|
-
}
|
|
120
|
-
|
|
38
|
+
const hri = record.hash_range_inclusive;
|
|
39
|
+
const cl = record.content_lines;
|
|
121
40
|
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
122
|
-
record.changes = [{
|
|
41
|
+
record.changes = [{ content_lines: cl, hash_range_inclusive: hri }];
|
|
123
42
|
delete record.hash_range_inclusive;
|
|
124
43
|
delete record.content_lines;
|
|
125
44
|
}
|
|
126
45
|
}
|
|
127
46
|
|
|
128
|
-
if (warnings.length > 0) {
|
|
129
|
-
(record as Record<string, unknown>)._normWarnings = warnings;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
47
|
return record;
|
|
133
48
|
}
|
package/src/replace.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
restoreEndings,
|
|
12
12
|
} from "./replace-diff";
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
|
-
import { normReq, normalizeFilePath
|
|
14
|
+
import { normReq, normalizeFilePath } from "./replace-normalize";
|
|
15
15
|
import { isRec, has, rejectUnknownFields } from "./utils";
|
|
16
16
|
import { MAX_HASH_LINES } from "./constants";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
@@ -62,25 +62,25 @@ const hashRangeInclSchema = Type.Array(
|
|
|
62
62
|
|
|
63
63
|
const changeItemSchema = Type.Object(
|
|
64
64
|
{
|
|
65
|
-
hash_range_inclusive: hashRangeInclSchema,
|
|
66
65
|
content_lines: contentLinesSchema,
|
|
66
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
67
67
|
},
|
|
68
68
|
{ additionalProperties: false },
|
|
69
69
|
);
|
|
70
70
|
|
|
71
71
|
export const editToolSchema = Type.Object(
|
|
72
72
|
{
|
|
73
|
-
path: Type.String({ description: "path" }),
|
|
74
73
|
changes: Type.Array(changeItemSchema, { description: "changes over $path" }),
|
|
74
|
+
path: Type.String({ description: "path" }),
|
|
75
75
|
},
|
|
76
76
|
{ additionalProperties: false },
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
export const flatEditToolSchema = Type.Object(
|
|
80
80
|
{
|
|
81
|
-
path: Type.String({ description: "path" }),
|
|
82
|
-
hash_range_inclusive: hashRangeInclSchema,
|
|
83
81
|
content_lines: contentLinesSchema,
|
|
82
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
83
|
+
path: Type.String({ description: "path" }),
|
|
84
84
|
},
|
|
85
85
|
{ additionalProperties: false },
|
|
86
86
|
);
|
|
@@ -127,7 +127,7 @@ export function assertReq(
|
|
|
127
127
|
for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
|
|
128
128
|
if (has(request, legacyKey)) {
|
|
129
129
|
throw new Error(
|
|
130
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"]
|
|
130
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -139,7 +139,7 @@ export function assertReq(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
if (!Array.isArray(request.changes)) {
|
|
142
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_inclusive: ["<START>", "<END>"]
|
|
142
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -311,8 +311,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
311
311
|
if (!isRec(args)) return args as any;
|
|
312
312
|
const record = { ...args };
|
|
313
313
|
normalizeFilePath(record);
|
|
314
|
-
tryParseField(record, "hash_range_inclusive");
|
|
315
|
-
tryParseField(record, "content_lines");
|
|
316
314
|
return record as any;
|
|
317
315
|
}
|
|
318
316
|
: (args: unknown) =>
|
|
@@ -408,14 +406,17 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
408
406
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
409
407
|
const canonical = opts.flat
|
|
410
408
|
? normReq({
|
|
409
|
+
content_lines: (params as any).content_lines,
|
|
410
|
+
hash_range_inclusive: (params as any).hash_range_inclusive,
|
|
411
411
|
path: (params as any).path,
|
|
412
412
|
changes: [{
|
|
413
|
-
hash_range_inclusive: (params as any).hash_range_inclusive,
|
|
414
413
|
content_lines: (params as any).content_lines,
|
|
414
|
+
hash_range_inclusive: (params as any).hash_range_inclusive,
|
|
415
415
|
}],
|
|
416
416
|
})
|
|
417
417
|
: normReq(params);
|
|
418
|
-
|
|
418
|
+
|
|
419
|
+
|
|
419
420
|
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
420
421
|
const path = normalizedParams.path;
|
|
421
422
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
@@ -448,10 +449,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
|
448
449
|
? normalizedParams.changes.length
|
|
449
450
|
: 0;
|
|
450
451
|
|
|
451
|
-
if (normWarnings) {
|
|
452
|
-
warnings.push(...normWarnings);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
452
|
if (originalNormalized === result) {
|
|
456
453
|
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
457
454
|
return buildNoop({
|
package/src/utils.ts
CHANGED
|
@@ -6,10 +6,23 @@ export function has(record: Record<string, unknown>, key: string): boolean {
|
|
|
6
6
|
return Object.hasOwn(record, key);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Splits text into visible lines, stripping the trailing empty element
|
|
11
|
+
* that `split("\n")` produces when the text ends with "\n".
|
|
12
|
+
*
|
|
13
|
+
* This is the canonical way to get user-visible lines from text.
|
|
14
|
+
* For internal hashing that needs the trailing empty string (e.g.
|
|
15
|
+
* `_lineHashesPure`, `mapStableHashes`, `buildIdx`), use
|
|
16
|
+
* `text.split("\n")` directly with a comment explaining why.
|
|
17
|
+
*/
|
|
18
|
+
export function splitLines(text: string): string[] {
|
|
19
|
+
if (text.length === 0) return [];
|
|
20
|
+
const lines = text.split("\n");
|
|
21
|
+
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export function visLines(text: string): string[] {
|
|
10
|
-
|
|
11
|
-
const lines = text.split("\n");
|
|
12
|
-
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
25
|
+
return splitLines(text);
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
export function cntLines(text: string): number {
|