pi-hashline-edit-pro 0.8.0 → 0.9.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 +7 -8
- package/package.json +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/replace.md +21 -13
- package/src/hashline/apply.ts +11 -11
- package/src/hashline/resolve.ts +44 -50
- package/src/replace-render.ts +1 -6
- package/src/replace-response.ts +12 -285
- package/src/replace.ts +12 -155
package/README.md
CHANGED
|
@@ -66,19 +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
|
"edits": [
|
|
69
|
-
{ "
|
|
69
|
+
{ "old_range": ["ve7", "ve7"], "new_lines": [" console.log('hashline');"] }
|
|
70
70
|
]
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
| Field | Purpose |
|
|
75
75
|
|---|---|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `lines` | Replacement content (one string per line). Use `[]` to delete. |
|
|
76
|
+
| `old_range` | Inclusive line range `[start_hash, end_hash]` (required). |
|
|
77
|
+
| `new_lines` | Replacement content (one string per line). Use `[]` to delete. |
|
|
79
78
|
|
|
80
|
-
- **Request structure validation.** The request envelope (path
|
|
81
|
-
- **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 `{
|
|
79
|
+
- **Request structure validation.** The request envelope (`path`, `edits`) 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_OP]`.
|
|
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 `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
|
|
82
81
|
|
|
83
82
|
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
|
|
84
83
|
|
|
@@ -105,8 +104,8 @@ The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is ex
|
|
|
105
104
|
|
|
106
105
|
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH from each line into the next replace call.
|
|
107
106
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
108
|
-
- **Strict patch content.** If `
|
|
109
|
-
- **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 `{
|
|
107
|
+
- **Strict patch content.** If `new_lines` contains `+HASH│` display prefixes (or `-N ` diff rows), the edit is rejected with `[E_INVALID_PATCH]`. Bare `HASH│` content (the first 4 chars of a `new_lines` entry looking like 3 base64 chars + `│`) is also 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.
|
|
108
|
+
- **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 `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
|
|
110
109
|
- **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.
|
|
111
110
|
- **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.
|
|
112
111
|
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -6,7 +6,7 @@ HASH format:
|
|
|
6
6
|
- The line number is not part of the output. Use the HASH to reference lines.
|
|
7
7
|
|
|
8
8
|
Pagination:
|
|
9
|
-
- Large files return a truncated preview with a `
|
|
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
10
|
|
|
11
11
|
File kinds:
|
|
12
12
|
- Text files are returned as `HASH│content` lines.
|
|
@@ -1,3 +1,3 @@
|
|
|
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 returned `--- Anchors ---` block has fresh hashes for nearby follow-up replaces.
|
|
3
|
-
- On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
|
package/prompts/replace.md
CHANGED
|
@@ -13,10 +13,10 @@ read({ path: "src/main.ts" })
|
|
|
13
13
|
// VRW│const z = 3;
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
2. Copy the 3-character HASH (before `│`) into `
|
|
16
|
+
2. Copy the 3-character HASH (before `│`) into `old_range`:
|
|
17
17
|
```json
|
|
18
18
|
{ "path": "src/main.ts", "edits": [
|
|
19
|
-
{ "
|
|
19
|
+
{ "old_range": ["MQX", "MQX"], "new_lines": ["const x = 99;"] }
|
|
20
20
|
] }
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -25,14 +25,14 @@ Examples:
|
|
|
25
25
|
1. Single line replace:
|
|
26
26
|
```json
|
|
27
27
|
{ "path": "src/main.ts", "edits": [
|
|
28
|
-
{ "
|
|
28
|
+
{ "old_range": ["MQX", "MQX"], "new_lines": ["const x = 1;"] }
|
|
29
29
|
] }
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
2. Range replace (3 lines → 3 new lines):
|
|
33
33
|
```json
|
|
34
34
|
{ "path": "src/main.ts", "edits": [
|
|
35
|
-
{ "
|
|
35
|
+
{ "old_range": ["ZPM", "VRW"], "new_lines": [
|
|
36
36
|
"function greet(name) {",
|
|
37
37
|
" return `Hello, ${name}`;",
|
|
38
38
|
"}"
|
|
@@ -43,24 +43,32 @@ Examples:
|
|
|
43
43
|
3. Multiple regions in one call (delete two non-adjacent ranges):
|
|
44
44
|
```json
|
|
45
45
|
{ "path": "src/server.ts", "edits": [
|
|
46
|
-
{ "
|
|
47
|
-
{ "
|
|
46
|
+
{ "old_range": ["aB3", "xY7"], "new_lines": [] },
|
|
47
|
+
{ "old_range": ["MQX", "ZPM"], "new_lines": [] }
|
|
48
48
|
] }
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Rules:
|
|
52
|
-
- `
|
|
53
|
-
- To delete a range, use `
|
|
54
|
-
- `
|
|
55
|
-
- `
|
|
52
|
+
- `old_range` is a pair `[start, end]`. A single-line replace is `old_range: ["X", "X"]`.
|
|
53
|
+
- To delete a range, use `new_lines: []`.
|
|
54
|
+
- `old_range` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
55
|
+
- `new_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
|
|
56
56
|
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
57
57
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
58
58
|
- All edits in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
59
|
-
- If `
|
|
60
|
-
- The `
|
|
59
|
+
- If `new_lines` matches current content, the replace is classified as `noop` (file unchanged).
|
|
60
|
+
- The `old_range` 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.
|
|
61
61
|
|
|
62
62
|
On success, the response contains an `--- Anchors ---` block with fresh HASH anchors for the changed region. Use those for nearby follow-up replaces instead of re-reading.
|
|
63
63
|
|
|
64
64
|
Error recovery:
|
|
65
|
-
- `[E_STALE_ANCHOR]` — file changed since last read.
|
|
65
|
+
- `[E_STALE_ANCHOR]` — file changed since last read. Call `read` to get fresh anchors, then copy the HASH and retry.
|
|
66
66
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
67
|
+
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
68
|
+
- `[E_BAD_SHAPE]` — malformed request or edit item (missing fields, wrong types, unknown fields).
|
|
69
|
+
- `[E_LEGACY_SHAPE]` — old `oldText`/`newText` format detected. Use `{old_range, new_lines}` instead.
|
|
70
|
+
- `[E_EDIT_CONFLICT]` — two edits overlap on the same line range. Make edits non-overlapping.
|
|
71
|
+
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
72
|
+
- `[E_BARE_HASH_PREFIX]` — edit line starts with `HASH│`. Use literal file content in `new_lines`, not read output.
|
|
73
|
+
- `[E_INVALID_PATCH]` — diff prefixes (`+`/`-`) in `new_lines`. Use literal content only.
|
|
74
|
+
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
package/src/hashline/apply.ts
CHANGED
|
@@ -79,31 +79,31 @@ function resolveEditToSpan(
|
|
|
79
79
|
): ResolvedEditSpan | null {
|
|
80
80
|
const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
|
|
81
81
|
|
|
82
|
-
const startLine = edit.
|
|
83
|
-
const endLine = edit.
|
|
82
|
+
const startLine = edit.old_range[0].line;
|
|
83
|
+
const endLine = edit.old_range[1].line;
|
|
84
84
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
85
85
|
if (
|
|
86
|
-
originalLines.length === edit.
|
|
86
|
+
originalLines.length === edit.new_lines.length &&
|
|
87
87
|
originalLines.every(
|
|
88
|
-
(line, lineIndex) => line === edit.
|
|
88
|
+
(line, lineIndex) => line === edit.new_lines[lineIndex],
|
|
89
89
|
)
|
|
90
90
|
) {
|
|
91
91
|
noopEdits.push({
|
|
92
92
|
editIndex: index,
|
|
93
|
-
loc: edit.
|
|
93
|
+
loc: edit.old_range[0].hash,
|
|
94
94
|
currentContent: originalLines.join("\n"),
|
|
95
95
|
});
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
if (edit.
|
|
99
|
+
if (edit.new_lines.length > 0) {
|
|
100
100
|
return {
|
|
101
101
|
kind: "replace",
|
|
102
102
|
index,
|
|
103
103
|
label: describeEdit(edit),
|
|
104
104
|
start: lineStarts[startLine - 1]!,
|
|
105
105
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
106
|
-
replacement: edit.
|
|
106
|
+
replacement: edit.new_lines.join("\n"),
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -235,11 +235,11 @@ export function applyHashlineEdits(
|
|
|
235
235
|
lastChangedLine: undefined,
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
-
// Normalize
|
|
238
|
+
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
239
239
|
edits = edits.map((edit) =>
|
|
240
|
-
edit.
|
|
241
|
-
edit.
|
|
242
|
-
? { ...edit,
|
|
240
|
+
edit.new_lines.length === 1 &&
|
|
241
|
+
edit.new_lines[0] === ""
|
|
242
|
+
? { ...edit, new_lines: [] }
|
|
243
243
|
: edit,
|
|
244
244
|
);
|
|
245
245
|
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -10,11 +10,10 @@ export type ResolvedAnchor = {
|
|
|
10
10
|
hashMatched: boolean;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export type HashlineEdit = {
|
|
13
|
+
export type HashlineEdit = { old_range: [Anchor, Anchor]; new_lines: string[] };
|
|
14
14
|
export type ResolvedHashlineEdit = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
lines: string[];
|
|
15
|
+
old_range: [ResolvedAnchor, ResolvedAnchor];
|
|
16
|
+
new_lines: string[];
|
|
18
17
|
};
|
|
19
18
|
interface HashMismatch {
|
|
20
19
|
ref: Anchor;
|
|
@@ -29,9 +28,8 @@ export interface NoopEdit {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
export type HashlineToolEdit = {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
lines?: string[];
|
|
31
|
+
old_range?: [string, string];
|
|
32
|
+
new_lines?: string[];
|
|
35
33
|
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
36
34
|
oldText?: string;
|
|
37
35
|
/** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
|
|
@@ -76,16 +74,16 @@ export function formatMismatchError(
|
|
|
76
74
|
const notFound = mismatches.filter((m) => m.kind === "not_found");
|
|
77
75
|
const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
|
|
78
76
|
|
|
77
|
+
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
79
78
|
if (notFound.length > 0) {
|
|
80
|
-
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
81
79
|
out.push(
|
|
82
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Call read() to get fresh anchors, then copy the
|
|
80
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-character HASH from each line into your next replace call.`
|
|
83
81
|
);
|
|
84
82
|
}
|
|
85
83
|
if (ambiguous.length > 0) {
|
|
86
84
|
if (out.length > 0) out.push("");
|
|
87
85
|
out.push(
|
|
88
|
-
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Call read() to get fresh anchors, then copy the
|
|
86
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Call read() to get fresh anchors, then copy the 3-character HASH from each line into your next replace call.`
|
|
89
87
|
);
|
|
90
88
|
for (const m of ambiguous) {
|
|
91
89
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -110,13 +108,21 @@ export function formatMismatchError(
|
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
|
|
113
|
-
const ITEM_KEYS = new Set(["
|
|
111
|
+
const ITEM_KEYS = new Set(["old_range", "new_lines"]);
|
|
114
112
|
function isStringArray(value: unknown): value is string[] {
|
|
115
113
|
return (
|
|
116
114
|
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
117
115
|
);
|
|
118
116
|
}
|
|
119
117
|
|
|
118
|
+
function isStringPair(value: unknown): value is [string, string] {
|
|
119
|
+
return (
|
|
120
|
+
Array.isArray(value) &&
|
|
121
|
+
value.length === 2 &&
|
|
122
|
+
value.every((item) => typeof item === "string")
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
121
127
|
const unknownKeys = Object.keys(edit).filter((key) => !ITEM_KEYS.has(key));
|
|
122
128
|
if (unknownKeys.length > 0) {
|
|
@@ -125,28 +131,20 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
|
125
131
|
);
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
if ("
|
|
134
|
+
if ("old_range" in edit && !isStringPair(edit.old_range)) {
|
|
129
135
|
throw new Error(
|
|
130
|
-
`[E_BAD_SHAPE] Edit ${index} field "
|
|
136
|
+
`[E_BAD_SHAPE] Edit ${index} field "old_range" must be a pair of anchor strings [start, end].`,
|
|
131
137
|
);
|
|
132
138
|
}
|
|
133
|
-
if ("
|
|
134
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index}
|
|
139
|
+
if (!("new_lines" in edit)) {
|
|
140
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field.`);
|
|
135
141
|
}
|
|
136
|
-
if (
|
|
137
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index}
|
|
138
|
-
}
|
|
139
|
-
if ("lines" in edit && !isStringArray(edit.lines)) {
|
|
140
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "lines" must be a string array.`);
|
|
141
|
-
}
|
|
142
|
-
if (typeof edit.start !== "string") {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`[E_BAD_OP] Edit ${index} requires a "start" anchor string.`,
|
|
145
|
-
);
|
|
142
|
+
if ("new_lines" in edit && !isStringArray(edit.new_lines)) {
|
|
143
|
+
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "new_lines" must be a string array.`);
|
|
146
144
|
}
|
|
147
|
-
if (
|
|
145
|
+
if (!isStringPair(edit.old_range)) {
|
|
148
146
|
throw new Error(
|
|
149
|
-
`[E_BAD_OP] Edit ${index} requires an "
|
|
147
|
+
`[E_BAD_OP] Edit ${index} requires an "old_range" pair of anchor strings [start, end].`,
|
|
150
148
|
);
|
|
151
149
|
}
|
|
152
150
|
|
|
@@ -157,16 +155,15 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
157
155
|
for (const [index, edit] of edits.entries()) {
|
|
158
156
|
assertEditItem(edit as Record<string, unknown>, index);
|
|
159
157
|
|
|
160
|
-
// Normalize
|
|
161
|
-
const replaceLines = hashlineParseText(edit.
|
|
158
|
+
// Normalize new_lines: [""] to new_lines: [] for deletion.
|
|
159
|
+
const replaceLines = hashlineParseText(edit.new_lines ?? null);
|
|
162
160
|
const normalizedLines =
|
|
163
161
|
replaceLines.length === 1 && replaceLines[0] === ""
|
|
164
162
|
? []
|
|
165
163
|
: replaceLines;
|
|
166
164
|
result.push({
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
lines: normalizedLines,
|
|
165
|
+
old_range: [parseHashRef(edit.old_range![0]), parseHashRef(edit.old_range![1])],
|
|
166
|
+
new_lines: normalizedLines,
|
|
170
167
|
});
|
|
171
168
|
}
|
|
172
169
|
return result;
|
|
@@ -177,7 +174,7 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(
|
|
|
177
174
|
warnings: string[],
|
|
178
175
|
): void {
|
|
179
176
|
for (const edit of edits) {
|
|
180
|
-
if (edit.
|
|
177
|
+
if (edit.new_lines.some((line) => /\\uDDDD/i.test(line))) {
|
|
181
178
|
warnings.push(
|
|
182
179
|
"Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
|
|
183
180
|
);
|
|
@@ -198,8 +195,8 @@ export function assertNoBareHashPrefixLines(
|
|
|
198
195
|
const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
|
|
199
196
|
for (let editIndex = 0; editIndex < edits.length; editIndex++) {
|
|
200
197
|
const edit = edits[editIndex]!;
|
|
201
|
-
for (let lineIndex = 0; lineIndex < edit.
|
|
202
|
-
const line = edit.
|
|
198
|
+
for (let lineIndex = 0; lineIndex < edit.new_lines.length; lineIndex++) {
|
|
199
|
+
const line = edit.new_lines[lineIndex]!;
|
|
203
200
|
const match = line.match(HASHLINE_BARE_PREFIX_RE);
|
|
204
201
|
if (match) suspects.push({ line, hash: match[1]!, editIndex, lineIndex });
|
|
205
202
|
}
|
|
@@ -227,7 +224,7 @@ export function assertNoBareHashPrefixLines(
|
|
|
227
224
|
* Human-readable label for a resolved edit (used in warnings and conflict errors).
|
|
228
225
|
*/
|
|
229
226
|
export function describeEdit(edit: ResolvedHashlineEdit): string {
|
|
230
|
-
return `replace ${edit.
|
|
227
|
+
return `replace ${edit.old_range[0].hash}-${edit.old_range[1].hash}`;
|
|
231
228
|
}
|
|
232
229
|
|
|
233
230
|
export function validateAnchorEdits(
|
|
@@ -257,19 +254,19 @@ export function validateAnchorEdits(
|
|
|
257
254
|
|
|
258
255
|
for (const edit of edits) {
|
|
259
256
|
throwIfAborted(signal);
|
|
260
|
-
const startResolved = tryResolve(edit.
|
|
261
|
-
const endResolved = tryResolve(edit.
|
|
257
|
+
const startResolved = tryResolve(edit.old_range[0]);
|
|
258
|
+
const endResolved = tryResolve(edit.old_range[1]);
|
|
262
259
|
if (!startResolved || !endResolved) {
|
|
263
260
|
continue;
|
|
264
261
|
}
|
|
265
262
|
if (startResolved.line > endResolved.line) {
|
|
266
263
|
throw new Error(
|
|
267
|
-
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.
|
|
264
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.old_range[0].hash} and ${edit.old_range[1].hash}).`,
|
|
268
265
|
);
|
|
269
266
|
}
|
|
270
267
|
const endLine = endResolved.line;
|
|
271
268
|
const nextLine = fileLines[endLine];
|
|
272
|
-
const replacementLastLine = edit.
|
|
269
|
+
const replacementLastLine = edit.new_lines.at(-1)?.trim();
|
|
273
270
|
if (
|
|
274
271
|
nextLine !== undefined &&
|
|
275
272
|
replacementLastLine &&
|
|
@@ -277,16 +274,15 @@ export function validateAnchorEdits(
|
|
|
277
274
|
replacementLastLine === nextLine.trim()
|
|
278
275
|
) {
|
|
279
276
|
const resolvedEdit: ResolvedHashlineEdit = {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
lines: edit.lines,
|
|
277
|
+
old_range: [startResolved, endResolved],
|
|
278
|
+
new_lines: edit.new_lines,
|
|
283
279
|
};
|
|
284
280
|
warnings.push(
|
|
285
281
|
`Potential boundary duplication after ${describeEdit(resolvedEdit)}: the replacement ends with a line that matches the next surviving line after trim.`,
|
|
286
282
|
);
|
|
287
283
|
}
|
|
288
284
|
const prevLine = fileLines[startResolved.line - 2];
|
|
289
|
-
const replacementFirstLine = edit.
|
|
285
|
+
const replacementFirstLine = edit.new_lines[0]?.trim();
|
|
290
286
|
if (
|
|
291
287
|
prevLine !== undefined &&
|
|
292
288
|
replacementFirstLine &&
|
|
@@ -294,18 +290,16 @@ export function validateAnchorEdits(
|
|
|
294
290
|
replacementFirstLine === prevLine.trim()
|
|
295
291
|
) {
|
|
296
292
|
const resolvedEdit: ResolvedHashlineEdit = {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
lines: edit.lines,
|
|
293
|
+
old_range: [startResolved, endResolved],
|
|
294
|
+
new_lines: edit.new_lines,
|
|
300
295
|
};
|
|
301
296
|
warnings.push(
|
|
302
297
|
`Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
|
|
303
298
|
);
|
|
304
299
|
}
|
|
305
300
|
resolved.push({
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
lines: edit.lines,
|
|
301
|
+
old_range: [startResolved, endResolved],
|
|
302
|
+
new_lines: edit.new_lines,
|
|
309
303
|
});
|
|
310
304
|
}
|
|
311
305
|
|
package/src/replace-render.ts
CHANGED
|
@@ -144,7 +144,6 @@ export function isAppliedChangedResult(
|
|
|
144
144
|
const metrics = details?.metrics;
|
|
145
145
|
return (
|
|
146
146
|
metrics?.classification === "applied" &&
|
|
147
|
-
metrics.return_mode === "changed" &&
|
|
148
147
|
metrics.added_lines !== undefined &&
|
|
149
148
|
metrics.removed_lines !== undefined
|
|
150
149
|
);
|
|
@@ -153,14 +152,11 @@ export function isAppliedChangedResult(
|
|
|
153
152
|
export function buildAppliedChangedResultText(
|
|
154
153
|
text: string | undefined,
|
|
155
154
|
details: HashlineReplaceToolDetails | undefined,
|
|
156
|
-
preview: ReplacePreview | undefined,
|
|
157
155
|
theme: FgTheme,
|
|
158
156
|
): string | undefined {
|
|
159
|
-
const previewDiff =
|
|
160
|
-
preview && !("error" in preview) ? preview.diff : undefined;
|
|
161
157
|
const sections: string[] = [];
|
|
162
158
|
|
|
163
|
-
if (details?.diff
|
|
159
|
+
if (details?.diff) {
|
|
164
160
|
sections.push(formatResultDiff(details.diff, theme));
|
|
165
161
|
}
|
|
166
162
|
|
|
@@ -169,7 +165,6 @@ export function buildAppliedChangedResultText(
|
|
|
169
165
|
|
|
170
166
|
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
171
167
|
}
|
|
172
|
-
|
|
173
168
|
// ─── Markdown rendering ─────────────────────────────────────────────────
|
|
174
169
|
|
|
175
170
|
function trimEdgeEmptyLines(lines: string[]): string[] {
|
package/src/replace-response.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
computeLineHashes,
|
|
6
6
|
formatHashlineRegion,
|
|
7
7
|
} from "./hashline";
|
|
8
|
-
import { formatHashlineReadPreview } from "./read";
|
|
9
8
|
import { getVisibleLines } from "./utils";
|
|
10
9
|
|
|
11
10
|
type ToolResult = {
|
|
@@ -17,31 +16,10 @@ type ToolResult = {
|
|
|
17
16
|
const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
|
|
18
17
|
|
|
19
18
|
|
|
20
|
-
export type ReturnMode = "changed" | "full" | "ranges";
|
|
21
|
-
|
|
22
|
-
export type ReturnRange = {
|
|
23
|
-
start: number;
|
|
24
|
-
end?: number;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ReturnedRangePreview = {
|
|
28
|
-
start: number;
|
|
29
|
-
end: number;
|
|
30
|
-
text: string;
|
|
31
|
-
nextOffset?: number;
|
|
32
|
-
empty?: true;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export type FullContentPreview = {
|
|
36
|
-
text: string;
|
|
37
|
-
nextOffset?: number;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
19
|
export type ReplaceMetrics = {
|
|
41
20
|
edits_attempted: number;
|
|
42
21
|
edits_noop: number;
|
|
43
22
|
warnings: number;
|
|
44
|
-
return_mode: ReturnMode;
|
|
45
23
|
classification: "applied" | "noop";
|
|
46
24
|
changed_lines?: { first: number; last: number };
|
|
47
25
|
added_lines?: number;
|
|
@@ -66,14 +44,12 @@ type NoopEditEntry = {
|
|
|
66
44
|
currentContent: string;
|
|
67
45
|
};
|
|
68
46
|
|
|
47
|
+
|
|
69
48
|
// ─── Builder inputs ─────────────────────────────────────────────────────
|
|
70
49
|
|
|
71
50
|
export interface NoopResponseInput {
|
|
72
51
|
path: string;
|
|
73
|
-
returnMode: ReturnMode;
|
|
74
|
-
requestedReturnRanges: ReturnRange[] | undefined;
|
|
75
52
|
noopEdits: NoopEditEntry[] | undefined;
|
|
76
|
-
originalNormalized: string;
|
|
77
53
|
snapshotId: string;
|
|
78
54
|
editMeta: ReplaceMeta;
|
|
79
55
|
warnings: string[] | undefined;
|
|
@@ -81,14 +57,8 @@ export interface NoopResponseInput {
|
|
|
81
57
|
|
|
82
58
|
export interface SuccessResponseInput {
|
|
83
59
|
path: string;
|
|
84
|
-
returnMode: ReturnMode;
|
|
85
|
-
requestedReturnRanges: ReturnRange[] | undefined;
|
|
86
60
|
originalNormalized: string;
|
|
87
61
|
result: string;
|
|
88
|
-
/** Precomputed hashes for `result`. When omitted the response builder
|
|
89
|
-
* computes them on demand; the caller should pass them in when the same
|
|
90
|
-
* result is rendered through more than one path (full / ranges / changed)
|
|
91
|
-
* to avoid redundant work. */
|
|
92
62
|
resultHashes?: string[];
|
|
93
63
|
warnings: string[] | undefined;
|
|
94
64
|
snapshotId: string;
|
|
@@ -114,7 +84,6 @@ function countDiffLines(diff: string, marker: "+" | "-"): number {
|
|
|
114
84
|
|
|
115
85
|
function buildMetrics(args: {
|
|
116
86
|
classification: "applied" | "noop";
|
|
117
|
-
returnMode: ReturnMode;
|
|
118
87
|
editsAttempted: number;
|
|
119
88
|
noopEditsCount: number;
|
|
120
89
|
warningsCount: number;
|
|
@@ -127,7 +96,6 @@ function buildMetrics(args: {
|
|
|
127
96
|
edits_attempted: args.editsAttempted,
|
|
128
97
|
edits_noop: args.noopEditsCount,
|
|
129
98
|
warnings: args.warningsCount,
|
|
130
|
-
return_mode: args.returnMode,
|
|
131
99
|
classification: args.classification,
|
|
132
100
|
};
|
|
133
101
|
if (
|
|
@@ -150,117 +118,12 @@ function warningsBlockOf(warnings: string[] | undefined): string {
|
|
|
150
118
|
return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
151
119
|
}
|
|
152
120
|
|
|
153
|
-
function outlineBlockOf(outlineText: string): string {
|
|
154
|
-
return outlineText ? `\n\n${outlineText}` : "";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ─── Structure outline ──────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
const STRUCTURE_MARKER_RE =
|
|
160
|
-
/^(#{1,6}\s+.+|(export\s+)?(async\s+)?function\s+\w+|(export\s+)?class\s+\w+|(export\s+)?interface\s+\w+|(export\s+)?type\s+\w+|(export\s+)?enum\s+\w+|(const|let|var)\s+\w+\s*=\s*(async\s*)?\()/;
|
|
161
|
-
|
|
162
|
-
function truncateOutlineEntry(text: string, max = 88): string {
|
|
163
|
-
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function collectOutlineEntries(previewText: string): string[] {
|
|
167
|
-
const structural: string[] = [];
|
|
168
|
-
for (const line of previewText.split("\n")) {
|
|
169
|
-
const match = line.match(/^\s*([A-Za-z0-9_\-]{3})│(.*)$/);
|
|
170
|
-
if (!match) continue;
|
|
171
|
-
const content = match[2]!.trim();
|
|
172
|
-
if (content.length === 0) continue;
|
|
173
|
-
if (!STRUCTURE_MARKER_RE.test(content)) continue;
|
|
174
|
-
structural.push(truncateOutlineEntry(content.replace(/\s+/g, " ")));
|
|
175
|
-
}
|
|
176
|
-
return structural.slice(0, 8);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function buildStructureOutline(
|
|
180
|
-
sections: Array<{ label?: string; previewText: string }>,
|
|
181
|
-
): { text: string; outline: string[] } {
|
|
182
|
-
const outlineLines: string[] = [];
|
|
183
|
-
const detailOutline: string[] = [];
|
|
184
|
-
const useSectionLabels = sections.length > 1;
|
|
185
|
-
|
|
186
|
-
for (const section of sections) {
|
|
187
|
-
const entries = collectOutlineEntries(section.previewText);
|
|
188
|
-
if (entries.length === 0) continue;
|
|
189
|
-
if (useSectionLabels && section.label) {
|
|
190
|
-
outlineLines.push(`- ${section.label}`);
|
|
191
|
-
}
|
|
192
|
-
for (const entry of entries) {
|
|
193
|
-
outlineLines.push(useSectionLabels ? ` - ${entry}` : `- ${entry}`);
|
|
194
|
-
detailOutline.push(section.label ? `${section.label}: ${entry}` : entry);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (outlineLines.length === 0) {
|
|
199
|
-
return { text: "", outline: [] };
|
|
200
|
-
}
|
|
201
|
-
return {
|
|
202
|
-
text: ["Structure outline:", ...outlineLines].join("\n"),
|
|
203
|
-
outline: detailOutline,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ─── Range previews ─────────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
function formatRequestedRangePreviews(
|
|
210
|
-
text: string,
|
|
211
|
-
ranges: ReturnRange[],
|
|
212
|
-
precomputedHashes?: string[],
|
|
213
|
-
): { text: string; returnedRanges: ReturnedRangePreview[] } {
|
|
214
|
-
const totalLines = getVisibleLines(text).length;
|
|
215
|
-
const returnedRanges = ranges.map((range) => {
|
|
216
|
-
const requestedEnd = range.end ?? range.start;
|
|
217
|
-
const preview = formatHashlineReadPreview(
|
|
218
|
-
text,
|
|
219
|
-
{
|
|
220
|
-
offset: range.start,
|
|
221
|
-
limit: requestedEnd - range.start + 1,
|
|
222
|
-
},
|
|
223
|
-
precomputedHashes,
|
|
224
|
-
);
|
|
225
|
-
const hasReturnedLines = /^\s*[A-Za-z0-9_\-]{3}│/m.test(preview.text);
|
|
226
|
-
const actualEnd = hasReturnedLines
|
|
227
|
-
? preview.nextOffset !== undefined
|
|
228
|
-
? preview.nextOffset - 1
|
|
229
|
-
: Math.min(requestedEnd, totalLines)
|
|
230
|
-
: requestedEnd;
|
|
231
|
-
return {
|
|
232
|
-
start: range.start,
|
|
233
|
-
end: hasReturnedLines ? Math.max(range.start, actualEnd) : actualEnd,
|
|
234
|
-
text: preview.text,
|
|
235
|
-
...(preview.nextOffset !== undefined
|
|
236
|
-
? { nextOffset: preview.nextOffset }
|
|
237
|
-
: {}),
|
|
238
|
-
...(!hasReturnedLines ? { empty: true as const } : {}),
|
|
239
|
-
};
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const formatted = returnedRanges
|
|
243
|
-
.map(
|
|
244
|
-
(range, index) =>
|
|
245
|
-
`--- Range ${index + 1} ---\n${range.text}`,
|
|
246
|
-
)
|
|
247
|
-
.join("\n\n");
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
text: formatted,
|
|
251
|
-
returnedRanges,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
121
|
// ─── Builders ───────────────────────────────────────────────────────────
|
|
256
122
|
|
|
257
123
|
export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
258
124
|
const {
|
|
259
125
|
path,
|
|
260
|
-
returnMode,
|
|
261
|
-
requestedReturnRanges,
|
|
262
126
|
noopEdits,
|
|
263
|
-
originalNormalized,
|
|
264
127
|
snapshotId,
|
|
265
128
|
editMeta,
|
|
266
129
|
warnings,
|
|
@@ -275,39 +138,10 @@ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
|
275
138
|
.join("\n")
|
|
276
139
|
: "The edits produced identical content.";
|
|
277
140
|
|
|
278
|
-
const
|
|
279
|
-
returnMode === "full"
|
|
280
|
-
? formatHashlineReadPreview(originalNormalized, { offset: 1 })
|
|
281
|
-
: undefined;
|
|
282
|
-
const rangePreviews =
|
|
283
|
-
returnMode === "ranges"
|
|
284
|
-
? formatRequestedRangePreviews(
|
|
285
|
-
originalNormalized,
|
|
286
|
-
requestedReturnRanges!,
|
|
287
|
-
)
|
|
288
|
-
: undefined;
|
|
289
|
-
const outline =
|
|
290
|
-
returnMode === "full"
|
|
291
|
-
? buildStructureOutline([{ previewText: fullPreview!.text }])
|
|
292
|
-
: returnMode === "ranges"
|
|
293
|
-
? buildStructureOutline(
|
|
294
|
-
rangePreviews!.returnedRanges.map((range, index) => ({
|
|
295
|
-
label: `Range ${index + 1} (lines ${range.start}-${range.end})`,
|
|
296
|
-
previewText: range.text,
|
|
297
|
-
})),
|
|
298
|
-
)
|
|
299
|
-
: undefined;
|
|
300
|
-
|
|
301
|
-
const text =
|
|
302
|
-
returnMode === "full"
|
|
303
|
-
? `No changes made to ${path}\nClassification: noop${outlineBlockOf(outline!.text)}\n\nFull content is available in details.fullContent.`
|
|
304
|
-
: returnMode === "ranges"
|
|
305
|
-
? `No changes made to ${path}\nClassification: noop${outlineBlockOf(outline!.text)}\n\nRequested range payloads are available in details.returnedRanges.`
|
|
306
|
-
: `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
|
|
141
|
+
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
|
|
307
142
|
|
|
308
143
|
const metrics = buildMetrics({
|
|
309
144
|
classification: "noop",
|
|
310
|
-
returnMode,
|
|
311
145
|
editsAttempted: editMeta.editsAttempted,
|
|
312
146
|
noopEditsCount: editMeta.noopEditsCount,
|
|
313
147
|
warningsCount: warnings?.length ?? 0,
|
|
@@ -320,119 +154,6 @@ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
|
320
154
|
firstChangedLine: undefined,
|
|
321
155
|
snapshotId,
|
|
322
156
|
classification: "noop" as const,
|
|
323
|
-
...(fullPreview?.nextOffset !== undefined
|
|
324
|
-
? { nextOffset: fullPreview.nextOffset }
|
|
325
|
-
: {}),
|
|
326
|
-
...(fullPreview
|
|
327
|
-
? {
|
|
328
|
-
fullContent: {
|
|
329
|
-
text: fullPreview.text,
|
|
330
|
-
...(fullPreview.nextOffset !== undefined
|
|
331
|
-
? { nextOffset: fullPreview.nextOffset }
|
|
332
|
-
: {}),
|
|
333
|
-
},
|
|
334
|
-
}
|
|
335
|
-
: {}),
|
|
336
|
-
...(rangePreviews
|
|
337
|
-
? { returnedRanges: rangePreviews.returnedRanges }
|
|
338
|
-
: {}),
|
|
339
|
-
...(outline ? { structureOutline: outline.outline } : {}),
|
|
340
|
-
metrics,
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function buildFullResponse(input: SuccessResponseInput): ToolResult {
|
|
346
|
-
const { path, result, warnings, snapshotId, originalNormalized, editMeta } =
|
|
347
|
-
input;
|
|
348
|
-
|
|
349
|
-
const diffResult = generateDiffString(originalNormalized, result);
|
|
350
|
-
const resultHashes = input.resultHashes ?? computeLineHashes(result);
|
|
351
|
-
const fullPreview = formatHashlineReadPreview(
|
|
352
|
-
result,
|
|
353
|
-
{ offset: 1 },
|
|
354
|
-
resultHashes,
|
|
355
|
-
);
|
|
356
|
-
const outline = buildStructureOutline([{ previewText: fullPreview.text }]);
|
|
357
|
-
const text = `Updated ${path}${warningsBlockOf(warnings)}${outlineBlockOf(outline.text)}\n\nFull content is available in details.fullContent.`;
|
|
358
|
-
|
|
359
|
-
const metrics = buildMetrics({
|
|
360
|
-
classification: "applied",
|
|
361
|
-
returnMode: "full",
|
|
362
|
-
editsAttempted: editMeta.editsAttempted,
|
|
363
|
-
noopEditsCount: editMeta.noopEditsCount,
|
|
364
|
-
warningsCount: warnings?.length ?? 0,
|
|
365
|
-
firstChangedLine: editMeta.firstChangedLine,
|
|
366
|
-
lastChangedLine: editMeta.lastChangedLine,
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
content: [{ type: "text", text }],
|
|
371
|
-
details: {
|
|
372
|
-
diff: diffResult.diff,
|
|
373
|
-
firstChangedLine:
|
|
374
|
-
editMeta.firstChangedLine ?? diffResult.firstChangedLine,
|
|
375
|
-
snapshotId,
|
|
376
|
-
...(fullPreview.nextOffset !== undefined
|
|
377
|
-
? { nextOffset: fullPreview.nextOffset }
|
|
378
|
-
: {}),
|
|
379
|
-
fullContent: {
|
|
380
|
-
text: fullPreview.text,
|
|
381
|
-
...(fullPreview.nextOffset !== undefined
|
|
382
|
-
? { nextOffset: fullPreview.nextOffset }
|
|
383
|
-
: {}),
|
|
384
|
-
},
|
|
385
|
-
structureOutline: outline.outline,
|
|
386
|
-
metrics,
|
|
387
|
-
},
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
export function buildRangesResponse(input: SuccessResponseInput): ToolResult {
|
|
392
|
-
const {
|
|
393
|
-
path,
|
|
394
|
-
result,
|
|
395
|
-
warnings,
|
|
396
|
-
snapshotId,
|
|
397
|
-
originalNormalized,
|
|
398
|
-
requestedReturnRanges,
|
|
399
|
-
editMeta,
|
|
400
|
-
} = input;
|
|
401
|
-
|
|
402
|
-
const diffResult = generateDiffString(originalNormalized, result);
|
|
403
|
-
const resultHashes = input.resultHashes ?? computeLineHashes(result);
|
|
404
|
-
const rangePreviews = formatRequestedRangePreviews(
|
|
405
|
-
result,
|
|
406
|
-
requestedReturnRanges!,
|
|
407
|
-
resultHashes,
|
|
408
|
-
);
|
|
409
|
-
const outline = buildStructureOutline(
|
|
410
|
-
rangePreviews.returnedRanges.map((range, index) => ({
|
|
411
|
-
label: `Range ${index + 1} (lines ${range.start}-${range.end})`,
|
|
412
|
-
previewText: range.text,
|
|
413
|
-
})),
|
|
414
|
-
);
|
|
415
|
-
const text = `Updated ${path}${warningsBlockOf(warnings)}${outlineBlockOf(outline.text)}\n\nRequested range payloads are available in details.returnedRanges.`;
|
|
416
|
-
|
|
417
|
-
const metrics = buildMetrics({
|
|
418
|
-
classification: "applied",
|
|
419
|
-
returnMode: "ranges",
|
|
420
|
-
editsAttempted: editMeta.editsAttempted,
|
|
421
|
-
noopEditsCount: editMeta.noopEditsCount,
|
|
422
|
-
warningsCount: warnings?.length ?? 0,
|
|
423
|
-
firstChangedLine: editMeta.firstChangedLine,
|
|
424
|
-
lastChangedLine: editMeta.lastChangedLine,
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
content: [{ type: "text", text }],
|
|
429
|
-
details: {
|
|
430
|
-
diff: diffResult.diff,
|
|
431
|
-
firstChangedLine:
|
|
432
|
-
editMeta.firstChangedLine ?? diffResult.firstChangedLine,
|
|
433
|
-
snapshotId,
|
|
434
|
-
returnedRanges: rangePreviews.returnedRanges,
|
|
435
|
-
structureOutline: outline.outline,
|
|
436
157
|
metrics,
|
|
437
158
|
},
|
|
438
159
|
};
|
|
@@ -463,7 +184,7 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
463
184
|
anchorRange.start - 1,
|
|
464
185
|
anchorRange.end,
|
|
465
186
|
);
|
|
466
|
-
|
|
187
|
+
const formatted = formatHashlineRegion(regionHashes, region);
|
|
467
188
|
const block = `--- Anchors ---\n${formatted}`;
|
|
468
189
|
return Buffer.byteLength(block, "utf8") <=
|
|
469
190
|
CHANGED_ANCHOR_TEXT_BUDGET_BYTES
|
|
@@ -472,15 +193,21 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
472
193
|
})()
|
|
473
194
|
: resultLines.length === 0
|
|
474
195
|
? "File is empty. Use edit to insert content."
|
|
475
|
-
:
|
|
476
|
-
|
|
196
|
+
: (() => {
|
|
197
|
+
const diffLines = diffResult.diff.split("\n");
|
|
198
|
+
const MAX_DIFF_LINES = 30;
|
|
199
|
+
if (diffLines.length <= MAX_DIFF_LINES) {
|
|
200
|
+
return `Diff preview:\n${diffResult.diff}`;
|
|
201
|
+
}
|
|
202
|
+
const truncated = diffLines.slice(0, MAX_DIFF_LINES).join("\n");
|
|
203
|
+
return `Diff preview (${diffLines.length} lines, showing first ${MAX_DIFF_LINES}):\n${truncated}\n...`;
|
|
204
|
+
})();
|
|
477
205
|
const text = [anchorsBlock, warningsBlock.trimStart()]
|
|
478
206
|
.filter((section) => section.length > 0)
|
|
479
207
|
.join("\n\n");
|
|
480
208
|
|
|
481
209
|
const metrics = buildMetrics({
|
|
482
210
|
classification: "applied",
|
|
483
|
-
returnMode: "changed",
|
|
484
211
|
editsAttempted: editMeta.editsAttempted,
|
|
485
212
|
noopEditsCount: editMeta.noopEditsCount,
|
|
486
213
|
warningsCount: warnings?.length ?? 0,
|
package/src/replace.ts
CHANGED
|
@@ -30,11 +30,9 @@ import { throwIfAborted } from "./runtime";
|
|
|
30
30
|
import { getFileSnapshot } from "./snapshot";
|
|
31
31
|
import {
|
|
32
32
|
buildChangedResponse,
|
|
33
|
-
buildFullResponse,
|
|
34
33
|
buildNoopResponse,
|
|
35
|
-
buildRangesResponse,
|
|
36
34
|
type ReplaceMeta,
|
|
37
|
-
type
|
|
35
|
+
type ReplaceMetrics,
|
|
38
36
|
} from "./replace-response";
|
|
39
37
|
import {
|
|
40
38
|
buildAppliedChangedResultText,
|
|
@@ -48,69 +46,31 @@ import {
|
|
|
48
46
|
type ReplaceRenderState,
|
|
49
47
|
} from "./replace-render";
|
|
50
48
|
|
|
51
|
-
function stringEnumSchema<const Values extends readonly string[]>(
|
|
52
|
-
values: Values,
|
|
53
|
-
options: { description: string },
|
|
54
|
-
) {
|
|
55
|
-
return Type.Unsafe<Values[number]>({
|
|
56
|
-
type: "string",
|
|
57
|
-
enum: [...values],
|
|
58
|
-
description: options.description,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
49
|
|
|
62
|
-
const
|
|
50
|
+
const hashlineEditNewLinesSchema = Type.Array(Type.String(), {
|
|
63
51
|
description:
|
|
64
52
|
"replacement content, one array entry per line, no HASH| prefix",
|
|
65
53
|
});
|
|
66
54
|
|
|
67
|
-
const
|
|
55
|
+
const hasheditOldRangeSchema = Type.Array(
|
|
56
|
+
Type.String({ description: "anchor (3-char HASH)" }),
|
|
68
57
|
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}),
|
|
73
|
-
end: Type.Optional(
|
|
74
|
-
Type.Integer({
|
|
75
|
-
minimum: 1,
|
|
76
|
-
description: "last post-edit line to return",
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
58
|
+
description: "inclusive line range to replace [start, end]",
|
|
59
|
+
minItems: 2,
|
|
60
|
+
maxItems: 2,
|
|
79
61
|
},
|
|
80
|
-
{ additionalProperties: false },
|
|
81
62
|
);
|
|
82
63
|
|
|
83
64
|
const hashlineEditItemSchema = Type.Object(
|
|
84
65
|
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
description:
|
|
88
|
-
"required range-start anchor (hash anchor like \"aB3x\" copied from read output); no content may follow the anchor",
|
|
89
|
-
}),
|
|
90
|
-
),
|
|
91
|
-
end: Type.Optional(
|
|
92
|
-
Type.String({
|
|
93
|
-
description:
|
|
94
|
-
"required range-end anchor (hash anchor like \"aB3x\"). To replace a single line, set start = end = the line's anchor",
|
|
95
|
-
}),
|
|
96
|
-
),
|
|
97
|
-
lines: Type.Optional(hashlineEditLinesSchema),
|
|
66
|
+
old_range: Type.Optional(hasheditOldRangeSchema),
|
|
67
|
+
new_lines: Type.Optional(hashlineEditNewLinesSchema),
|
|
98
68
|
},
|
|
99
69
|
{ additionalProperties: false },
|
|
100
70
|
);
|
|
101
71
|
export const hashlineEditToolSchema = Type.Object(
|
|
102
72
|
{
|
|
103
73
|
path: Type.String({ description: "path" }),
|
|
104
|
-
returnMode: Type.Optional(
|
|
105
|
-
stringEnumSchema(["changed", "full", "ranges"] as const, {
|
|
106
|
-
description: 'response mode: "changed", "full", or "ranges"',
|
|
107
|
-
}),
|
|
108
|
-
),
|
|
109
|
-
returnRanges: Type.Optional(
|
|
110
|
-
Type.Array(returnRangeSchema, {
|
|
111
|
-
description: "post-edit line ranges when returnMode is ranges",
|
|
112
|
-
}),
|
|
113
|
-
),
|
|
114
74
|
edits: Type.Optional(
|
|
115
75
|
Type.Array(hashlineEditItemSchema, { description: "edits over $path" }),
|
|
116
76
|
),
|
|
@@ -118,41 +78,11 @@ export const hashlineEditToolSchema = Type.Object(
|
|
|
118
78
|
{ additionalProperties: false },
|
|
119
79
|
);
|
|
120
80
|
|
|
121
|
-
type ReturnRange = {
|
|
122
|
-
start: number;
|
|
123
|
-
end?: number;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
type ReturnedRangePreview = {
|
|
127
|
-
start: number;
|
|
128
|
-
end: number;
|
|
129
|
-
text: string;
|
|
130
|
-
nextOffset?: number;
|
|
131
|
-
empty?: true;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
type FullContentPreview = {
|
|
135
|
-
text: string;
|
|
136
|
-
nextOffset?: number;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
81
|
export type ReplaceRequestParams = {
|
|
140
82
|
path: string;
|
|
141
|
-
returnMode?: "changed" | "full" | "ranges";
|
|
142
|
-
returnRanges?: ReturnRange[];
|
|
143
83
|
edits?: HashlineToolEdit[];
|
|
144
84
|
};
|
|
145
85
|
|
|
146
|
-
type ReplaceMetrics = {
|
|
147
|
-
edits_attempted: number;
|
|
148
|
-
edits_noop: number;
|
|
149
|
-
warnings: number;
|
|
150
|
-
return_mode: "changed" | "full" | "ranges";
|
|
151
|
-
classification: "applied" | "noop";
|
|
152
|
-
changed_lines?: { first: number; last: number };
|
|
153
|
-
added_lines?: number;
|
|
154
|
-
removed_lines?: number;
|
|
155
|
-
};
|
|
156
86
|
|
|
157
87
|
export type HashlineReplaceToolDetails = {
|
|
158
88
|
diff: string;
|
|
@@ -164,9 +94,6 @@ export type HashlineReplaceToolDetails = {
|
|
|
164
94
|
*/
|
|
165
95
|
snapshotId?: string;
|
|
166
96
|
classification?: "noop";
|
|
167
|
-
nextOffset?: number;
|
|
168
|
-
fullContent?: FullContentPreview;
|
|
169
|
-
returnedRanges?: ReturnedRangePreview[];
|
|
170
97
|
structureOutline?: string[];
|
|
171
98
|
/**
|
|
172
99
|
* Phase 2 C — opt-in observability surface for hosts. Never echoed in text.
|
|
@@ -194,7 +121,7 @@ const EDIT_PROMPT_GUIDELINES = readFileSync(
|
|
|
194
121
|
.map((line) => line.trim())
|
|
195
122
|
.filter((line) => line.startsWith("- "))
|
|
196
123
|
.map((line) => line.slice(2));
|
|
197
|
-
const ROOT_KEYS = new Set(["path", "
|
|
124
|
+
const ROOT_KEYS = new Set(["path", "edits"]);
|
|
198
125
|
|
|
199
126
|
export function assertReplaceRequest(
|
|
200
127
|
request: unknown,
|
|
@@ -203,10 +130,10 @@ export function assertReplaceRequest(
|
|
|
203
130
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
204
131
|
}
|
|
205
132
|
|
|
206
|
-
for (const legacyKey of ["oldText", "newText", "old_text", "new_text"]) {
|
|
133
|
+
for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "start", "end", "lines"]) {
|
|
207
134
|
if (hasOwn(request, legacyKey)) {
|
|
208
135
|
throw new Error(
|
|
209
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {
|
|
136
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {old_range: ["<START>", "<END>"], new_lines: [...]}.`
|
|
210
137
|
);
|
|
211
138
|
}
|
|
212
139
|
}
|
|
@@ -228,66 +155,7 @@ export function assertReplaceRequest(
|
|
|
228
155
|
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided.');
|
|
229
156
|
}
|
|
230
157
|
|
|
231
|
-
if (hasOwn(request, "returnMode")) {
|
|
232
|
-
if (
|
|
233
|
-
request.returnMode !== "changed" &&
|
|
234
|
-
request.returnMode !== "full" &&
|
|
235
|
-
request.returnMode !== "ranges"
|
|
236
|
-
) {
|
|
237
|
-
throw new Error(
|
|
238
|
-
'[E_BAD_SHAPE] Edit request field "returnMode" must be "changed", "full", or "ranges" when provided.',
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (hasOwn(request, "returnRanges")) {
|
|
244
|
-
if (
|
|
245
|
-
!Array.isArray(request.returnRanges) ||
|
|
246
|
-
request.returnRanges.length === 0
|
|
247
|
-
) {
|
|
248
|
-
throw new Error(
|
|
249
|
-
'[E_BAD_SHAPE] Edit request field "returnRanges" must be a non-empty array when provided.',
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
for (const [index, range] of request.returnRanges.entries()) {
|
|
253
|
-
if (!isRecord(range)) {
|
|
254
|
-
throw new Error(`[E_BAD_SHAPE] returnRanges[${index}] must be an object.`);
|
|
255
|
-
}
|
|
256
|
-
if (!Number.isInteger(range.start) || (range.start as number) < 1) {
|
|
257
|
-
throw new Error(
|
|
258
|
-
`[E_BAD_SHAPE] returnRanges[${index}].start must be a positive integer.`,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
if (hasOwn(range, "end")) {
|
|
262
|
-
if (!Number.isInteger(range.end) || (range.end as number) < 1) {
|
|
263
|
-
throw new Error(
|
|
264
|
-
`[E_BAD_SHAPE] returnRanges[${index}].end must be a positive integer when provided.`,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
if ((range.end as number) < (range.start as number)) {
|
|
268
|
-
throw new Error(`[E_BAD_SHAPE] returnRanges[${index}].end must be >= start.`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (request.returnMode === "ranges") {
|
|
275
|
-
if (
|
|
276
|
-
!Array.isArray(request.returnRanges) ||
|
|
277
|
-
request.returnRanges.length === 0
|
|
278
|
-
) {
|
|
279
|
-
throw new Error(
|
|
280
|
-
'[E_BAD_SHAPE] Edit request with returnMode "ranges" requires a non-empty "returnRanges" array.',
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
} else if (hasOwn(request, "returnRanges")) {
|
|
284
|
-
throw new Error(
|
|
285
|
-
'[E_BAD_SHAPE] Edit request field "returnRanges" is only supported when returnMode is "ranges".',
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
158
|
}
|
|
290
|
-
|
|
291
159
|
async function executeEditPipeline(
|
|
292
160
|
request: unknown,
|
|
293
161
|
cwd: string,
|
|
@@ -495,7 +363,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
495
363
|
const renderedText = getRenderedEditTextContent(typedResult);
|
|
496
364
|
|
|
497
365
|
const renderState = context.state as ReplaceRenderState | undefined;
|
|
498
|
-
const previewBeforeResult = renderState?.preview;
|
|
499
366
|
if (renderState) {
|
|
500
367
|
renderState.preview = undefined;
|
|
501
368
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
@@ -517,7 +384,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
517
384
|
const appliedChangedText = buildAppliedChangedResultText(
|
|
518
385
|
renderedText,
|
|
519
386
|
typedResult.details,
|
|
520
|
-
previewBeforeResult,
|
|
521
387
|
theme,
|
|
522
388
|
);
|
|
523
389
|
if (!appliedChangedText) {
|
|
@@ -549,9 +415,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
549
415
|
const normalizedParams = normalized;
|
|
550
416
|
const path = normalizedParams.path;
|
|
551
417
|
const absolutePath = resolveToCwd(path, ctx.cwd);
|
|
552
|
-
const returnMode = normalizedParams.returnMode ?? "changed";
|
|
553
|
-
const requestedReturnRanges = normalizedParams.returnRanges;
|
|
554
|
-
|
|
555
418
|
const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
|
|
556
419
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
557
420
|
throwIfAborted(signal);
|
|
@@ -581,8 +444,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
581
444
|
const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
582
445
|
return buildNoopResponse({
|
|
583
446
|
path,
|
|
584
|
-
returnMode: returnMode as ReturnMode,
|
|
585
|
-
requestedReturnRanges,
|
|
586
447
|
noopEdits,
|
|
587
448
|
originalNormalized,
|
|
588
449
|
snapshotId: noopSnapshotId,
|
|
@@ -617,8 +478,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
617
478
|
|
|
618
479
|
const successInput = {
|
|
619
480
|
path,
|
|
620
|
-
returnMode: returnMode as ReturnMode,
|
|
621
|
-
requestedReturnRanges,
|
|
622
481
|
originalNormalized,
|
|
623
482
|
result,
|
|
624
483
|
resultHashes: computeLineHashes(result),
|
|
@@ -627,8 +486,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
627
486
|
editMeta,
|
|
628
487
|
};
|
|
629
488
|
|
|
630
|
-
if (returnMode === "full") return buildFullResponse(successInput);
|
|
631
|
-
if (returnMode === "ranges") return buildRangesResponse(successInput);
|
|
632
489
|
return buildChangedResponse(successInput);
|
|
633
490
|
});
|
|
634
491
|
},
|