pi-hashline-edit-pro 0.11.4 → 0.12.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/package.json +1 -1
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +30 -31
- package/src/file-kind.ts +134 -134
- package/src/hashline/apply.ts +71 -71
- package/src/hashline/resolve.ts +148 -149
- package/src/replace-normalize.ts +52 -30
- package/src/replace-render.ts +2 -2
- package/src/replace.ts +94 -94
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines in a text file via HASH anchors from read, batching all
|
|
1
|
+
Replace lines in a text file via HASH anchors from read, batching all changes to a file in one call
|
package/prompts/replace.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Replace lines in a text file using HASH anchors from `read`.
|
|
2
2
|
|
|
3
|
-
Put all operations on one file in a single `replace` call. Stack every region into the `
|
|
3
|
+
Put all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.
|
|
4
4
|
|
|
5
5
|
How to use:
|
|
6
6
|
|
|
@@ -11,8 +11,8 @@ read({ path: "src/main.ts" })
|
|
|
11
11
|
|
|
12
12
|
2. Copy the 3-character HASH (before `│`) into `hash_range_incl`:
|
|
13
13
|
```json
|
|
14
|
-
{ "path": "src/main.ts", "
|
|
15
|
-
{ "hash_range_incl": ["MQX", "MQX"], "
|
|
14
|
+
{ "path": "src/main.ts", "changes": [
|
|
15
|
+
{ "hash_range_incl": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
|
|
16
16
|
] }
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -20,15 +20,15 @@ Examples:
|
|
|
20
20
|
|
|
21
21
|
1. Single line replace:
|
|
22
22
|
```json
|
|
23
|
-
{ "path": "src/main.ts", "
|
|
24
|
-
{ "hash_range_incl": ["MQX", "MQX"], "
|
|
23
|
+
{ "path": "src/main.ts", "changes": [
|
|
24
|
+
{ "hash_range_incl": ["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
|
-
{ "path": "src/main.ts", "
|
|
31
|
-
{ "hash_range_incl": ["ZPM", "VRW"], "
|
|
30
|
+
{ "path": "src/main.ts", "changes": [
|
|
31
|
+
{ "hash_range_incl": ["ZPM", "VRW"], "content_lines": [
|
|
32
32
|
"function greet(name) {",
|
|
33
33
|
" return `Hello, ${name}`;",
|
|
34
34
|
"}"
|
|
@@ -38,75 +38,74 @@ Examples:
|
|
|
38
38
|
|
|
39
39
|
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
40
40
|
```json
|
|
41
|
-
{ "path": "src/server.ts", "
|
|
42
|
-
{ "hash_range_incl": ["aB3", "xY7"], "
|
|
43
|
-
{ "hash_range_incl": ["MQX", "ZPM"], "
|
|
41
|
+
{ "path": "src/server.ts", "changes": [
|
|
42
|
+
{ "hash_range_incl": ["aB3", "xY7"], "content_lines": [] },
|
|
43
|
+
{ "hash_range_incl": ["MQX", "ZPM"], "content_lines": [] }
|
|
44
44
|
] }
|
|
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
|
|
|
49
49
|
```json
|
|
50
|
-
{ "path": "src/main.ts", "
|
|
51
|
-
{ "hash_range_incl": ["ZPM", "ZPM"], "
|
|
50
|
+
{ "path": "src/main.ts", "changes": [
|
|
51
|
+
{ "hash_range_incl": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
|
|
52
52
|
] }
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
5. Seed content into an empty file (replace the single empty-line hash returned by read):
|
|
56
56
|
|
|
57
57
|
```json
|
|
58
|
-
{ "path": "src/main.ts", "
|
|
59
|
-
{ "hash_range_incl": ["aB3", "aB3"], "
|
|
58
|
+
{ "path": "src/main.ts", "changes": [
|
|
59
|
+
{ "hash_range_incl": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
|
|
60
60
|
] }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
⚠️ Common mistake: do not copy the `HASH│` prefix into `
|
|
63
|
+
⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
|
|
64
64
|
|
|
65
65
|
Wrong:
|
|
66
66
|
```json
|
|
67
|
-
{ "hash_range_incl": ["F4T", "F4T"], "
|
|
67
|
+
{ "hash_range_incl": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Right:
|
|
71
71
|
```json
|
|
72
|
-
{ "hash_range_incl": ["F4T", "F4T"], "
|
|
72
|
+
{ "hash_range_incl": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
`hash_range_incl` uses the hash anchor. `
|
|
75
|
+
`hash_range_incl` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
|
|
76
76
|
|
|
77
77
|
⚠️ Common mistake: `hash_range_incl` is only the 3-character HASH, not the full `HASH│content` line.
|
|
78
78
|
|
|
79
79
|
Wrong:
|
|
80
80
|
```json
|
|
81
|
-
{ "hash_range_incl": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "
|
|
81
|
+
{ "hash_range_incl": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
Right:
|
|
85
85
|
```json
|
|
86
|
-
{ "hash_range_incl": ["F4T", "F4T"], "
|
|
86
|
+
{ "hash_range_incl": ["F4T", "F4T"], "content_lines": [...] }
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Rules:
|
|
90
|
-
- `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"]
|
|
91
|
-
- To delete a range, use `
|
|
90
|
+
- `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"].
|
|
91
|
+
- To delete a range, use `content_lines: []`.
|
|
92
92
|
- `hash_range_incl` 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, no `+`/`-` diff markers.
|
|
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
|
-
- All
|
|
97
|
-
- If `
|
|
96
|
+
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
97
|
+
- If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
98
98
|
- The `hash_range_incl` is inclusive — both anchors and every line between them are replaced. 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
|
-
|
|
100
99
|
On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
|
|
101
100
|
|
|
102
101
|
Error recovery:
|
|
103
102
|
- `[E_STALE_ANCHOR]` — file changed since last read. Call `read` to get fresh anchors, then copy the HASH and retry.
|
|
104
103
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
105
104
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
106
|
-
- `[E_BAD_SHAPE]` — malformed request or
|
|
107
|
-
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_incl,
|
|
108
|
-
- `[E_EDIT_CONFLICT]` — two
|
|
105
|
+
- `[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 `{hash_range_incl, content_lines}` instead.
|
|
107
|
+
- `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
|
|
109
108
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
110
|
-
- `[E_BARE_HASH_PREFIX]` — a `
|
|
111
|
-
- `[E_INVALID_PATCH]` — diff prefixes (`+`/`-`) in `
|
|
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. `hash_range_incl` uses hashes, `content_lines` does not.
|
|
110
|
+
- `[E_INVALID_PATCH]` — diff prefixes (`+`/`-`) in `content_lines`. Use literal content only.
|
|
112
111
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
package/src/file-kind.ts
CHANGED
|
@@ -3,158 +3,158 @@ import { fileTypeFromBuffer } from "file-type";
|
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
|
|
5
5
|
const IMG_TYPES = new Set<string>([
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
"image/jpeg",
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/gif",
|
|
9
|
+
"image/webp",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const TEXT_TYPES = new Set<string>([
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
"application/rtf",
|
|
14
|
+
"application/xml",
|
|
15
|
+
"application/x-ms-regedit",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
18
|
function isTextType(mimeType: string): boolean {
|
|
19
|
-
|
|
19
|
+
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export type FKind =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
| { kind: "directory" }
|
|
24
|
+
| { kind: "image"; mimeType: string }
|
|
25
|
+
| { kind: "text" }
|
|
26
|
+
| { kind: "binary"; description: string };
|
|
27
27
|
|
|
28
28
|
export type LFile =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
| { kind: "directory" }
|
|
30
|
+
| { kind: "image"; mimeType: string }
|
|
31
|
+
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
32
|
+
| { kind: "binary"; description: string };
|
|
33
33
|
|
|
34
34
|
function hasNull(buffer: Uint8Array): boolean {
|
|
35
|
-
|
|
35
|
+
return buffer.includes(0);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export async function loadFileKindAndText(
|
|
39
|
-
|
|
39
|
+
filePath: string,
|
|
40
40
|
): Promise<LFile> {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
41
|
+
const pathStat = await fsStat(filePath);
|
|
42
|
+
if (pathStat.isDirectory()) {
|
|
43
|
+
return { kind: "directory" };
|
|
44
|
+
}
|
|
45
|
+
if (!pathStat.isFile()) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "binary",
|
|
48
|
+
description: "unsupported file type",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (pathStat.size > MAX_BYTES) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "binary",
|
|
54
|
+
description: `file exceeds ${MAX_BYTES} byte limit`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fileHandle = await fsOpen(filePath, "r");
|
|
59
|
+
try {
|
|
60
|
+
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
61
|
+
const { bytesRead } = await fileHandle.read(
|
|
62
|
+
buffer,
|
|
63
|
+
0,
|
|
64
|
+
SNIFF_BYTES,
|
|
65
|
+
0,
|
|
66
|
+
);
|
|
67
|
+
if (bytesRead === 0) {
|
|
68
|
+
return { kind: "text", text: "" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sample = buffer.subarray(0, bytesRead);
|
|
72
|
+
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
73
|
+
if (
|
|
74
|
+
detectedMimeType !== undefined &&
|
|
75
|
+
!isTextType(detectedMimeType)
|
|
76
|
+
) {
|
|
77
|
+
if (IMG_TYPES.has(detectedMimeType)) {
|
|
78
|
+
return { kind: "image", mimeType: detectedMimeType };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: "binary",
|
|
82
|
+
description: detectedMimeType,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (hasNull(sample)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: "binary",
|
|
88
|
+
description: "null bytes detected",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const decoder = new TextDecoder("utf-8");
|
|
93
|
+
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
94
|
+
let hadUtf8DecodeErrors = false;
|
|
95
|
+
const noteUtf8Err = (chunk?: Uint8Array): void => {
|
|
96
|
+
if (hadUtf8DecodeErrors) return;
|
|
97
|
+
try {
|
|
98
|
+
fatalDecoder.decode(chunk, { stream: chunk !== undefined });
|
|
99
|
+
} catch (error: unknown) {
|
|
100
|
+
if (error instanceof TypeError) {
|
|
101
|
+
hadUtf8DecodeErrors = true;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
noteUtf8Err(sample);
|
|
109
|
+
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
110
|
+
|
|
111
|
+
let position = bytesRead;
|
|
112
|
+
while (true) {
|
|
113
|
+
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
114
|
+
buffer,
|
|
115
|
+
0,
|
|
116
|
+
SNIFF_BYTES,
|
|
117
|
+
position,
|
|
118
|
+
);
|
|
119
|
+
if (chunkBytesRead === 0) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
124
|
+
if (hasNull(chunk)) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "binary",
|
|
127
|
+
description: "null bytes detected",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
noteUtf8Err(chunk);
|
|
131
|
+
parts.push(decoder.decode(chunk, { stream: true }));
|
|
132
|
+
position += chunkBytesRead;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
noteUtf8Err();
|
|
136
|
+
parts.push(decoder.decode());
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
kind: "text",
|
|
140
|
+
text: parts.join(""),
|
|
141
|
+
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
142
|
+
};
|
|
143
|
+
} finally {
|
|
144
|
+
await fileHandle.close();
|
|
145
|
+
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export async function classifyFileKind(filePath: string): Promise<FKind> {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
149
|
+
const loaded = await loadFileKindAndText(filePath);
|
|
150
|
+
switch (loaded.kind) {
|
|
151
|
+
case "directory":
|
|
152
|
+
return loaded;
|
|
153
|
+
case "image":
|
|
154
|
+
return loaded;
|
|
155
|
+
case "binary":
|
|
156
|
+
return loaded;
|
|
157
|
+
case "text":
|
|
158
|
+
return { kind: "text" };
|
|
159
|
+
}
|
|
160
160
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -68,72 +68,72 @@ function throwConflict(
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function resToSpan(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
edit: RHEdit,
|
|
72
|
+
index: number,
|
|
73
|
+
content: string,
|
|
74
|
+
lineIndex: LIdx,
|
|
75
|
+
noopEdits: NEdit[],
|
|
76
76
|
): RESpan | null {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
77
|
+
const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
|
|
78
|
+
|
|
79
|
+
const startLine = edit.hash_range_incl[0].line;
|
|
80
|
+
const endLine = edit.hash_range_incl[1].line;
|
|
81
|
+
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
82
|
+
if (
|
|
83
|
+
originalLines.length === edit.content_lines.length &&
|
|
84
|
+
originalLines.every(
|
|
85
|
+
(line, lineIndex) => line === edit.content_lines[lineIndex],
|
|
86
|
+
)
|
|
87
|
+
) {
|
|
88
|
+
noopEdits.push({
|
|
89
|
+
editIndex: index,
|
|
90
|
+
loc: edit.hash_range_incl[0].hash,
|
|
91
|
+
currentContent: originalLines.join("\n"),
|
|
92
|
+
});
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (edit.content_lines.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
kind: "replace",
|
|
99
|
+
index,
|
|
100
|
+
label: descEdit(edit),
|
|
101
|
+
start: lineStarts[startLine - 1]!,
|
|
102
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
103
|
+
replacement: edit.content_lines.join("\n"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (startLine === 1 && endLine === fileLines.length) {
|
|
108
|
+
return {
|
|
109
|
+
kind: "replace",
|
|
110
|
+
index,
|
|
111
|
+
label: descEdit(edit),
|
|
112
|
+
start: 0,
|
|
113
|
+
end: content.length,
|
|
114
|
+
replacement: "",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (endLine < fileLines.length) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "replace",
|
|
121
|
+
index,
|
|
122
|
+
label: descEdit(edit),
|
|
123
|
+
start: lineStarts[startLine - 1]!,
|
|
124
|
+
end: lineStarts[endLine]!,
|
|
125
|
+
replacement: "",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
kind: "replace",
|
|
131
|
+
index,
|
|
132
|
+
label: descEdit(edit),
|
|
133
|
+
start: Math.max(0, lineStarts[startLine - 1]! - 1),
|
|
134
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
135
|
+
replacement: "",
|
|
136
|
+
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
function assertNoConflict(spans: RESpan[]): void {
|
|
@@ -232,12 +232,12 @@ export function applyEdits(
|
|
|
232
232
|
lastChangedLine: undefined,
|
|
233
233
|
};
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
edits = edits.map((edit) =>
|
|
236
|
+
edit.content_lines.length === 1 &&
|
|
237
|
+
edit.content_lines[0] === ""
|
|
238
|
+
? { ...edit, content_lines: [] }
|
|
239
|
+
: edit,
|
|
240
|
+
);
|
|
241
241
|
|
|
242
242
|
const lineIndex = buildIdx(content);
|
|
243
243
|
const fileHashes = precomputedHashes ?? lineHashes(content);
|
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 = { hash_range_incl: [Anchor, Anchor];
|
|
12
|
+
export type HEdit = { hash_range_incl: [Anchor, Anchor]; content_lines: string[] };
|
|
13
13
|
export type RHEdit = {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
hash_range_incl: [RAnchor, RAnchor];
|
|
15
|
+
content_lines: string[];
|
|
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
|
-
|
|
41
|
-
|
|
40
|
+
hash_range_incl: [string, string];
|
|
41
|
+
content_lines: string[];
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
function resAnchor(
|
|
@@ -119,7 +119,7 @@ export function fmtMismatch(
|
|
|
119
119
|
return out.join("\n");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
const ITEM_KS = new Set(["hash_range_incl", "
|
|
122
|
+
const ITEM_KS = new Set(["hash_range_incl", "content_lines"]);
|
|
123
123
|
|
|
124
124
|
function isStrArr(value: unknown): value is string[] {
|
|
125
125
|
return (
|
|
@@ -136,87 +136,86 @@ function isStrPair(value: unknown): value is [string, string] {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function assertItem(edit: Record<string, unknown>, index: number): void {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { hash_range_incl, content_lines }.");
|
|
140
|
+
|
|
141
|
+
if ("hash_range_incl" in edit && !isStrPair(edit.hash_range_incl)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[E_BAD_SHAPE] Edit ${index} field "hash_range_incl" must be a pair of anchor strings [start, end].`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!("content_lines" in edit)) {
|
|
147
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "content_lines" field. Provide the replacement lines (use [] to delete).`);
|
|
148
|
+
}
|
|
149
|
+
if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
|
|
150
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
|
|
151
|
+
}
|
|
152
|
+
if (!isStrPair(edit.hash_range_incl)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[E_BAD_SHAPE] Edit ${index} requires an "hash_range_incl" pair of anchor strings [start, end].`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
export function resEdits(edits: HTEdit[]): HEdit[] {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
160
|
+
const result: HEdit[] = [];
|
|
161
|
+
for (const [index, edit] of edits.entries()) {
|
|
162
|
+
assertItem(edit as Record<string, unknown>, index);
|
|
163
|
+
|
|
164
|
+
const replaceLines = parseText(edit.content_lines);
|
|
165
|
+
result.push({
|
|
166
|
+
hash_range_incl: [parseHashRef(edit.hash_range_incl[0]), parseHashRef(edit.hash_range_incl[1])],
|
|
167
|
+
content_lines: replaceLines,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
function warnUnicodeEsc(
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
edits: HEdit[],
|
|
175
|
+
warnings: string[],
|
|
177
176
|
): void {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
177
|
+
for (const edit of edits) {
|
|
178
|
+
if (edit.content_lines.some((line) => /\\uDDDD/i.test(line))) {
|
|
179
|
+
warnings.push(
|
|
180
|
+
"Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
185
184
|
}
|
|
186
185
|
|
|
187
186
|
export function assertNoBarePrefix(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
edits: HEdit[],
|
|
188
|
+
fileLines: string[],
|
|
189
|
+
fileHashes: string[],
|
|
191
190
|
): void {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
191
|
+
const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
|
|
192
|
+
for (let editIndex = 0; editIndex < edits.length; editIndex++) {
|
|
193
|
+
const edit = edits[editIndex]!;
|
|
194
|
+
for (let lineIndex = 0; lineIndex < edit.content_lines.length; lineIndex++) {
|
|
195
|
+
const line = edit.content_lines[lineIndex]!;
|
|
196
|
+
const match = line.match(HL_BARE_PREFIX_RE);
|
|
197
|
+
if (match) suspects.push({ line, hash: match[1]!, editIndex, lineIndex });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (suspects.length === 0) return;
|
|
201
|
+
const locations = suspects
|
|
202
|
+
.map((s) => `edit ${s.editIndex}, content_lines[${s.lineIndex}]`)
|
|
203
|
+
.join("; ");
|
|
204
|
+
|
|
205
|
+
const fileHashSet = new Set(fileHashes);
|
|
206
|
+
const matched = suspects.filter((s) => fileHashSet.has(s.hash));
|
|
207
|
+
const matchedCount = matched.length;
|
|
208
|
+
|
|
209
|
+
const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
|
|
210
|
+
|
|
211
|
+
const linesHint =
|
|
212
|
+
matchedCount === 0
|
|
213
|
+
? `None match file line hashes.`
|
|
214
|
+
: `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
|
|
215
|
+
|
|
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: hash_range_incl uses hash anchors, content_lines uses file content only.`
|
|
218
|
+
);
|
|
220
219
|
}
|
|
221
220
|
|
|
222
221
|
export function descEdit(edit: RHEdit): string {
|
|
@@ -224,80 +223,80 @@ export function descEdit(edit: RHEdit): string {
|
|
|
224
223
|
}
|
|
225
224
|
|
|
226
225
|
export function valEdits(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
edits: HEdit[],
|
|
227
|
+
fileLines: string[],
|
|
228
|
+
fileHashes: string[],
|
|
229
|
+
warnings: string[],
|
|
230
|
+
signal: AbortSignal | undefined,
|
|
232
231
|
): { resolved: RHEdit[]; mismatches: HMismatch[]; boundaryWarnings: BDupWarn[] } {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
232
|
+
assertAligned(fileLines, fileHashes, "valEdits");
|
|
233
|
+
const resolved: RHEdit[] = [];
|
|
234
|
+
const mismatches: HMismatch[] = [];
|
|
235
|
+
const boundaryWarnings: BDupWarn[] = [];
|
|
236
|
+
|
|
237
|
+
const tryResolve = (ref: Anchor): RAnchor | undefined => {
|
|
238
|
+
const result = resAnchor(ref, fileLines, fileHashes);
|
|
239
|
+
if ("kind" in result) {
|
|
240
|
+
mismatches.push(result);
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
for (const edit of edits) {
|
|
247
|
+
abortIf(signal);
|
|
248
|
+
const startResolved = tryResolve(edit.hash_range_incl[0]);
|
|
249
|
+
const endResolved = tryResolve(edit.hash_range_incl[1]);
|
|
250
|
+
if (!startResolved || !endResolved) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (startResolved.line > endResolved.line) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_incl[0].hash} and ${edit.hash_range_incl[1].hash}).`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const endLine = endResolved.line;
|
|
259
|
+
const nextLine = fileLines[endLine];
|
|
260
|
+
const replacementLastLine = edit.content_lines.at(-1);
|
|
261
|
+
if (
|
|
262
|
+
nextLine !== undefined &&
|
|
263
|
+
replacementLastLine !== undefined &&
|
|
264
|
+
replacementLastLine.length > 0 &&
|
|
265
|
+
replacementLastLine === nextLine
|
|
266
|
+
) {
|
|
267
|
+
boundaryWarnings.push({
|
|
268
|
+
kind: "trailing",
|
|
269
|
+
survivingLineContent: nextLine,
|
|
270
|
+
survivingLineIndex: endLine,
|
|
271
|
+
occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
|
|
272
|
+
replacementLineContent: replacementLastLine,
|
|
273
|
+
editIndex: resolved.length,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const prevLine = fileLines[startResolved.line - 2];
|
|
277
|
+
const replacementFirstLine = edit.content_lines[0];
|
|
278
|
+
if (
|
|
279
|
+
prevLine !== undefined &&
|
|
280
|
+
replacementFirstLine !== undefined &&
|
|
281
|
+
replacementFirstLine.length > 0 &&
|
|
282
|
+
replacementFirstLine === prevLine
|
|
283
|
+
) {
|
|
284
|
+
boundaryWarnings.push({
|
|
285
|
+
kind: "leading",
|
|
286
|
+
survivingLineContent: prevLine,
|
|
287
|
+
survivingLineIndex: startResolved.line - 2,
|
|
288
|
+
occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
|
|
289
|
+
replacementLineContent: replacementFirstLine,
|
|
290
|
+
editIndex: resolved.length,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
resolved.push({
|
|
294
|
+
hash_range_incl: [startResolved, endResolved],
|
|
295
|
+
content_lines: edit.content_lines,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { resolved, mismatches, boundaryWarnings };
|
|
301
300
|
}
|
|
302
301
|
|
|
303
302
|
export { warnUnicodeEsc };
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,36 +1,58 @@
|
|
|
1
1
|
import { isRec, has } from "./utils";
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
function coerceChangesArray(changes: unknown): unknown {
|
|
4
|
+
if (typeof changes !== "string") {
|
|
5
|
+
return changes;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
const parsed: unknown = JSON.parse(changes);
|
|
9
|
+
return Array.isArray(parsed) ? parsed : changes;
|
|
10
|
+
} catch {
|
|
11
|
+
return changes;
|
|
12
|
+
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function coerceContentLines(changes: unknown): unknown {
|
|
16
|
+
if (!Array.isArray(changes)) return changes;
|
|
17
|
+
return changes.map((change: unknown) => {
|
|
18
|
+
if (!isRec(change)) return change;
|
|
19
|
+
if (typeof change.content_lines !== "string") return change;
|
|
20
|
+
try {
|
|
21
|
+
const parsed: unknown = JSON.parse(change.content_lines);
|
|
22
|
+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
23
|
+
return { ...change, content_lines: parsed };
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// not valid JSON, leave as-is for downstream validation
|
|
27
|
+
}
|
|
28
|
+
return change;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
export function normReq(input: unknown): unknown {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
if (!isRec(input)) {
|
|
34
|
+
return input;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const record: Record<string, unknown> = { ...input };
|
|
38
|
+
|
|
39
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
40
|
+
record.path = record.file_path;
|
|
41
|
+
delete record.file_path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hasChangesField = has(record, "changes");
|
|
45
|
+
const hasEditsField = has(record, "edits");
|
|
46
|
+
|
|
47
|
+
if (hasChangesField) {
|
|
48
|
+
record.changes = coerceChangesArray(record.changes);
|
|
49
|
+
record.changes = coerceContentLines(record.changes);
|
|
50
|
+
} else if (hasEditsField) {
|
|
51
|
+
// Accept "edits" as an alias for "changes" for backward compatibility
|
|
52
|
+
record.changes = coerceChangesArray(record.edits);
|
|
53
|
+
record.changes = coerceContentLines(record.changes);
|
|
54
|
+
delete record.edits;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return record;
|
|
58
|
+
}
|
package/src/replace-render.ts
CHANGED
|
@@ -32,13 +32,13 @@ export function getPreviewInput(
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
if (!Array.isArray(normalized.
|
|
35
|
+
if (!Array.isArray(normalized.changes)) {
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const request: ReqParams = {
|
|
40
40
|
path: normalized.path,
|
|
41
|
-
|
|
41
|
+
changes: normalized.changes as HTEdit[],
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
return request;
|
package/src/replace.ts
CHANGED
|
@@ -42,9 +42,9 @@ import {
|
|
|
42
42
|
} from "./replace-render";
|
|
43
43
|
import { loadP, loadGuide } from "./prompts";
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const contentLinesSchema = Type.Array(Type.String(), {
|
|
46
|
+
description:
|
|
47
|
+
"literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
const hashRangeInclSchema = Type.Array(
|
|
@@ -56,24 +56,24 @@ const hashRangeInclSchema = Type.Array(
|
|
|
56
56
|
},
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
const changeItemSchema = Type.Object(
|
|
60
|
+
{
|
|
61
|
+
hash_range_incl: hashRangeInclSchema,
|
|
62
|
+
content_lines: contentLinesSchema,
|
|
63
|
+
},
|
|
64
|
+
{ additionalProperties: false },
|
|
65
65
|
);
|
|
66
66
|
export const editToolSchema = Type.Object(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
{
|
|
68
|
+
path: Type.String({ description: "path" }),
|
|
69
|
+
changes: Type.Array(changeItemSchema, { description: "changes over $path" }),
|
|
70
|
+
},
|
|
71
|
+
{ additionalProperties: false },
|
|
72
72
|
);
|
|
73
73
|
|
|
74
74
|
export type ReqParams = {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
path: string;
|
|
76
|
+
changes: HTEdit[];
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
export type ReplaceDetails = {
|
|
@@ -88,90 +88,90 @@ export type ReplaceDetails = {
|
|
|
88
88
|
const E_DESC = loadP("../prompts/replace.md");
|
|
89
89
|
const E_SNIPPET = loadP("../prompts/replace-snippet.md");
|
|
90
90
|
const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
|
|
91
|
-
const ROOT_KS = new Set(["path", "
|
|
91
|
+
const ROOT_KS = new Set(["path", "changes"]);
|
|
92
92
|
|
|
93
93
|
export function assertReq(
|
|
94
|
-
|
|
94
|
+
request: unknown,
|
|
95
95
|
): asserts request is ReqParams {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
96
|
+
if (!isRec(request)) {
|
|
97
|
+
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
|
|
101
|
+
if (has(request, legacyKey)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_incl: ["<START>", "<END>"], content_lines: [...]}.`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
109
|
+
|
|
110
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
111
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!Array.isArray(request.changes)) {
|
|
115
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_incl: ["<START>", "<END>"], content_lines: [...] }.');
|
|
116
|
+
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
async function execPipeline(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
params: ReqParams,
|
|
121
|
+
cwd: string,
|
|
122
|
+
accessMode: number,
|
|
123
|
+
signal?: AbortSignal,
|
|
124
124
|
): Promise<{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
path: string;
|
|
126
|
+
toolEdits: HTEdit[];
|
|
127
|
+
originalNormalized: string;
|
|
128
|
+
result: string;
|
|
129
|
+
bom: string;
|
|
130
|
+
originalEnding: "\r\n" | "\n";
|
|
131
|
+
hadUtf8DecodeErrors: boolean;
|
|
132
|
+
warnings: string[];
|
|
133
|
+
noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
|
|
134
|
+
firstChangedLine?: number;
|
|
135
|
+
lastChangedLine?: number;
|
|
136
|
+
originalHashes?: string[];
|
|
137
137
|
}> {
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
139
|
+
const path = params.path;
|
|
140
|
+
const toolEdits = Array.isArray(params.changes)
|
|
141
|
+
? (params.changes as HTEdit[])
|
|
142
|
+
: [];
|
|
143
|
+
|
|
144
|
+
if (toolEdits.length === 0) {
|
|
145
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "changes" array.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
149
|
+
path, cwd, signal, accessMode,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const resolved = resEdits(toolEdits);
|
|
153
|
+
const anchorResult = applyEdits(
|
|
154
|
+
originalNormalized,
|
|
155
|
+
resolved,
|
|
156
|
+
signal,
|
|
157
|
+
originalHashes,
|
|
158
|
+
path,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
path,
|
|
163
|
+
toolEdits,
|
|
164
|
+
originalNormalized,
|
|
165
|
+
result: anchorResult.content,
|
|
166
|
+
bom,
|
|
167
|
+
originalEnding,
|
|
168
|
+
hadUtf8DecodeErrors,
|
|
169
|
+
warnings: [...(anchorResult.warnings ?? [])],
|
|
170
|
+
noopEdits: anchorResult.noopEdits,
|
|
171
|
+
firstChangedLine: anchorResult.firstChangedLine,
|
|
172
|
+
lastChangedLine: anchorResult.lastChangedLine,
|
|
173
|
+
originalHashes,
|
|
174
|
+
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
export async function compPreview(
|
|
@@ -358,9 +358,9 @@ const toolDef: ToolDef = {
|
|
|
358
358
|
signal,
|
|
359
359
|
);
|
|
360
360
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
const editsAttempted = Array.isArray(normalizedParams.changes)
|
|
362
|
+
? normalizedParams.changes.length
|
|
363
|
+
: 0;
|
|
364
364
|
|
|
365
365
|
if (originalNormalized === result) {
|
|
366
366
|
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|