pi-hashline-edit-pro 0.11.5 → 0.12.0

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