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 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
- { "start": "ve7", "end": "ve7", "lines": [" console.log('hashline');"] }
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
- | `start` | Range-start anchor (required). |
77
- | `end` | Range-end anchor (required). Single-line replace: set `start` = `end`. |
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, edits, returnMode, returnRanges) 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]`.
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 `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
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 `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 `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.
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 `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
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 `nextOffset` line. Call `read` again with `offset=nextOffset` to continue.
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.
@@ -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 `start`/`end`:
16
+ 2. Copy the 3-character HASH (before `│`) into `old_range`:
17
17
  ```json
18
18
  { "path": "src/main.ts", "edits": [
19
- { "start": "MQX", "end": "MQX", "lines": ["const x = 99;"] }
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
- { "start": "MQX", "end": "MQX", "lines": ["const x = 1;"] }
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
- { "start": "ZPM", "end": "VRW", "lines": [
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
- { "start": "aB3", "end": "xY7", "lines": [] },
47
- { "start": "MQX", "end": "ZPM", "lines": [] }
46
+ { "old_range": ["aB3", "xY7"], "new_lines": [] },
47
+ { "old_range": ["MQX", "ZPM"], "new_lines": [] }
48
48
  ] }
49
49
  ```
50
50
 
51
51
  Rules:
52
- - `start` and `end` are required. A single-line replace is `start=X, end=X`.
53
- - To delete a range, use `lines: []`.
54
- - `start`, `end` are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
55
- - `lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
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 `lines` matches current content, the replace is classified as `noop` (file unchanged).
60
- - The `start`/`end` 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.
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. The error includes fresh `>>> HASH│content` lines; copy the HASH and retry.
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.
@@ -79,31 +79,31 @@ function resolveEditToSpan(
79
79
  ): ResolvedEditSpan | null {
80
80
  const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
81
81
 
82
- const startLine = edit.start.line;
83
- const endLine = edit.end.line;
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.lines.length &&
86
+ originalLines.length === edit.new_lines.length &&
87
87
  originalLines.every(
88
- (line, lineIndex) => line === edit.lines[lineIndex],
88
+ (line, lineIndex) => line === edit.new_lines[lineIndex],
89
89
  )
90
90
  ) {
91
91
  noopEdits.push({
92
92
  editIndex: index,
93
- loc: edit.start.hash,
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.lines.length > 0) {
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.lines.join("\n"),
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 lines: [""] to lines: [] for deletion.
238
+ // Normalize new_lines: [""] to new_lines: [] for deletion.
239
239
  edits = edits.map((edit) =>
240
- edit.lines.length === 1 &&
241
- edit.lines[0] === ""
242
- ? { ...edit, lines: [] }
240
+ edit.new_lines.length === 1 &&
241
+ edit.new_lines[0] === ""
242
+ ? { ...edit, new_lines: [] }
243
243
  : edit,
244
244
  );
245
245
 
@@ -10,11 +10,10 @@ export type ResolvedAnchor = {
10
10
  hashMatched: boolean;
11
11
  };
12
12
 
13
- export type HashlineEdit = { start: Anchor; end: Anchor; lines: string[] };
13
+ export type HashlineEdit = { old_range: [Anchor, Anchor]; new_lines: string[] };
14
14
  export type ResolvedHashlineEdit = {
15
- start: ResolvedAnchor;
16
- end: ResolvedAnchor;
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
- start?: string;
33
- end?: string;
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 4-character HASH from each line into your next replace call.`
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 4-character HASH from each line into your next replace call.`
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(["start", "end", "lines"]);
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 ("start" in edit && typeof edit.start !== "string") {
134
+ if ("old_range" in edit && !isStringPair(edit.old_range)) {
129
135
  throw new Error(
130
- `[E_BAD_SHAPE] Edit ${index} field "start" must be a string when provided.`,
136
+ `[E_BAD_SHAPE] Edit ${index} field "old_range" must be a pair of anchor strings [start, end].`,
131
137
  );
132
138
  }
133
- if ("end" in edit && typeof edit.end !== "string") {
134
- throw new Error(`[E_BAD_SHAPE] Edit ${index} field "end" must be a string when provided.`);
139
+ if (!("new_lines" in edit)) {
140
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "new_lines" field.`);
135
141
  }
136
- if (!("lines" in edit)) {
137
- throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "lines" field.`);
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 (typeof edit.end !== "string") {
145
+ if (!isStringPair(edit.old_range)) {
148
146
  throw new Error(
149
- `[E_BAD_OP] Edit ${index} requires an "end" anchor string.`,
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 lines: [""] to lines: [] for deletion.
161
- const replaceLines = hashlineParseText(edit.lines ?? null);
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
- start: parseHashRef(edit.start!),
168
- end: parseHashRef(edit.end!),
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.lines.some((line) => /\\uDDDD/i.test(line))) {
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.lines.length; lineIndex++) {
202
- const line = edit.lines[lineIndex]!;
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.start.hash}-${edit.end.hash}`;
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.start);
261
- const endResolved = tryResolve(edit.end);
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.start.hash} and ${edit.end.hash}).`,
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.lines.at(-1)?.trim();
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
- start: startResolved,
281
- end: endResolved,
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.lines[0]?.trim();
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
- start: startResolved,
298
- end: endResolved,
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
- start: startResolved,
307
- end: endResolved,
308
- lines: edit.lines,
301
+ old_range: [startResolved, endResolved],
302
+ new_lines: edit.new_lines,
309
303
  });
310
304
  }
311
305
 
@@ -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 && details.diff !== previewDiff) {
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[] {
@@ -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 fullPreview =
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
- const formatted = formatHashlineRegion(regionHashes, region);
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
- : "Anchors omitted; use read for subsequent edits.";
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 ReturnMode,
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 hashlineEditLinesSchema = Type.Array(Type.String(), {
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 returnRangeSchema = Type.Object(
55
+ const hasheditOldRangeSchema = Type.Array(
56
+ Type.String({ description: "anchor (3-char HASH)" }),
68
57
  {
69
- start: Type.Integer({
70
- minimum: 1,
71
- description: "first post-edit line to return",
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
- start: Type.Optional(
86
- Type.String({
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", "returnMode", "returnRanges", "edits"]);
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 {start:"<HASH>", end:"<HASH>", lines:[...]}.`
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
  },