pi-hashline-edit-pro 6.4.8 → 6.5.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
@@ -219,15 +219,13 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
219
 
220
220
  ### replace_match
221
221
 
222
- `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range; if `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line change. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
222
+ `replace_match` changes part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused; in `replacement` it counts as `""` and deletes the matches). Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
223
223
 
224
224
  `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
225
 
226
- `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
226
+ `match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right — since `match` stays within one line, so does each replacement. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
227
227
 
228
- `replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, and `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged. Both are refused in strict-input mode with `[E_BAD_SHAPE]`.
229
-
230
- In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
228
+ In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `match` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
231
229
 
232
230
  ### insert
233
231
 
@@ -459,20 +457,18 @@ Full reference:
459
457
  | Code | Meaning |
460
458
  | --- | --- |
461
459
  | `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
462
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
460
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match` string spans lines: `match`/`replacement` contains a line break after the optional trailing one. |
463
461
  | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
464
462
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
465
- | `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `old_string` in the selected range. The current `anchor│content` rows are returned, and when `old_string` looks like a retyped copy of the range the message names the first mismatching line and column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
463
+ | `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `match` in the selected range. The current `anchor│content` rows are returned, and when `match` looks like a retyped copy of a single-line range the message names the first mismatching column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
466
464
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
467
465
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
468
466
  | `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
469
467
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
470
- | `[W_REPLACE_MATCH_WHOLE_RANGE]` | `replace_match` `old_string` is the entire selected range. The anchors already identify it; use `replace` with the same anchors, or send only the changed fragment. Refused in strict-input mode. |
471
- | `[W_REPLACE_MATCH_RETYPE]` | `replace_match` `old_string` retypes a long run of text that `new_string` leaves unchanged. Send only the fragment that changes; refused in strict-input mode. |
472
468
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
473
- | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
474
- | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
475
- | `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names both code points, the column, and the reference anchor, for example `[H_UNICODE_SWAPPED] U+200D at col 21 where Kq3f│ has U+200B; resend with U+200B if unintended.` The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
469
+ | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31).` names the field, the escape, and up to three affected anchors with their columns; when more than three rows carry it, it gives `on 10 rows; undo_last_change if unintended.` instead of the anchor list. |
470
+ | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST] Zbbb│ missing "\u2060"; Kq3f│ has it at col 31.` The column is attributed to the row that had the character: `; Kq3f│ has it at col N` while that row's anchor is still valid, otherwise `; the replaced row had it at col N`, and a dead anchor is never named. When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
471
+ | `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED] Zbbb│ has "\u200d" at col 21; expected "\u200b".` A surviving reference row appears as `; Kq3f│ has "\u200b"`; a replaced row's dead anchor is never named. The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
476
472
  | `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
477
473
  | `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
478
474
  | `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.8",
3
+ "version": "6.5.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -92,7 +92,7 @@ function batchGuideline(flags: EditToolFlags): string {
92
92
  const tools = operationNames(SHARED_EDIT_OPS, flags);
93
93
  const outcome = flags.autoRead ? "diff" : "result";
94
94
  const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
95
- return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`old_string\`) fails alone; a failure while the batch runs aborts it.${script}`;
95
+ return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
96
96
  }
97
97
 
98
98
  function diffGuideline(flags: EditToolFlags): string {
@@ -157,10 +157,8 @@ export function withInsertPrompts(base: { description: string; snippet: string;
157
157
  return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
158
158
  }
159
159
 
160
- export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
161
- const guidelines = base.guidelines.map((guideline) => guideline.text);
162
- if (flags.strictInput) guidelines.push("`replace_match`: strict-input mode rejects an `old_string` that covers the whole range or retypes unchanged text.");
163
- return { description: base.description, snippet: base.snippet, guidelines };
160
+ export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, _flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
161
+ return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
164
162
  }
165
163
 
166
164
  function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
@@ -299,7 +297,7 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
299
297
  return anchorTarget;
300
298
  }
301
299
 
302
- const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]", "[W_REPLACE_MATCH_WHOLE_RANGE]", "[W_REPLACE_MATCH_RETYPE]"];
300
+ const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
303
301
  export async function throwIfStrictInput(warnings: string[]): Promise<void> {
304
302
  const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
305
303
  if (fixes.length === 0) return;
@@ -76,8 +76,12 @@ export function isFidelitySensitiveChar(char: string): boolean {
76
76
  return char === REPLACEMENT_CHAR || INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char) || LOOKALIKE_PUNCT_RE.test(char);
77
77
  }
78
78
 
79
- function formatCodePoint(char: string): string {
80
- return `U+${char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`;
79
+ export function formatCharEscape(char: string): string {
80
+ const codePoint = char.codePointAt(0);
81
+ if (codePoint === undefined) return "";
82
+ if (codePoint <= 0xffff) return `\\u${codePoint.toString(16).padStart(4, "0")}`;
83
+ const pair = codePoint - 0x10000;
84
+ return `\\u${(0xd800 + (pair >> 10)).toString(16).padStart(4, "0")}\\u${(0xdc00 + (pair & 0x3ff)).toString(16).padStart(4, "0")}`;
81
85
  }
82
86
 
83
87
  function plural(count: number, noun: string): string {
@@ -89,11 +93,16 @@ interface ReferenceRow {
89
93
  index: number;
90
94
  }
91
95
 
92
- function hiddenCharHint(char: string, reference: ReferenceRow, anchor: string | undefined): string {
96
+ function hiddenCharHint(char: string, reference: ReferenceRow, liveAnchor: string | undefined, referenceAnchor: string | undefined): string {
93
97
  const column = [...reference.line].indexOf(char) + 1;
94
- const codePoint = formatCodePoint(char);
95
- const referenceNote = anchor === undefined ? "" : `; ${anchor}${HASH_SEP} has it`;
96
- return `[H_UNICODE_LOST] ${codePoint} missing at col ${column}${referenceNote}; resend with ${codePoint} if unintended.`;
98
+ const escape = formatCharEscape(char);
99
+ const located = referenceAnchor === undefined
100
+ ? `the replaced row had it at col ${column}`
101
+ : `${referenceAnchor}${HASH_SEP} has it at col ${column}`;
102
+ if (liveAnchor === undefined) {
103
+ return `[H_UNICODE_LOST] "${escape}" missing; ${located}.`;
104
+ }
105
+ return `[H_UNICODE_LOST] ${liveAnchor}${HASH_SEP} missing "${escape}"; ${located}.`;
97
106
  }
98
107
 
99
108
  function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
@@ -122,6 +131,7 @@ interface SwappedChar {
122
131
  interface SwapCandidate {
123
132
  reference: ReferenceRow;
124
133
  swapped: SwappedChar;
134
+ liveAnchor?: string;
125
135
  }
126
136
 
127
137
  function containsSensitiveChar(line: string): boolean {
@@ -134,6 +144,14 @@ function normalizeSensitiveChars(line: string): string {
134
144
  return normalized;
135
145
  }
136
146
 
147
+ function sensitiveStripped(line: string): string {
148
+ let visible = "";
149
+ for (const char of line) {
150
+ if (!isFidelitySensitiveChar(char)) visible += char;
151
+ }
152
+ return visible;
153
+ }
154
+
137
155
  function isLookalikeSwap(oldChar: string, newChar: string): boolean {
138
156
  if (isFidelitySensitiveChar(oldChar) && isFidelitySensitiveChar(newChar)) return true;
139
157
  return LOOKALIKE_SUBSTITUTES[newChar] === oldChar;
@@ -150,21 +168,46 @@ function sharesFidelityClass(left: string, right: string): boolean {
150
168
  );
151
169
  }
152
170
 
153
- function findSubstitute(char: string, reference: ReferenceRow, payload: readonly string[]): string | undefined {
171
+ interface SubstituteMatch {
172
+ value: string;
173
+ payloadIndex: number;
174
+ column: number;
175
+ }
176
+
177
+ interface SubstituteRank {
178
+ echo: boolean;
179
+ aligned: boolean;
180
+ distance: number;
181
+ }
182
+
183
+ function preferSubstitute(candidate: SubstituteRank, best: SubstituteRank | undefined): boolean {
184
+ if (best === undefined) return true;
185
+ if (candidate.echo !== best.echo) return candidate.echo;
186
+ if (candidate.aligned !== best.aligned) return candidate.aligned;
187
+ return candidate.distance < best.distance;
188
+ }
189
+
190
+ function findSubstitute(char: string, reference: ReferenceRow, payload: readonly string[]): SubstituteMatch | undefined {
154
191
  const referenceChars = [...reference.line];
155
192
  const column = referenceChars.indexOf(char);
156
- for (const line of payload) {
193
+ const visibleReference = sensitiveStripped(reference.line);
194
+ let best: SubstituteMatch | undefined;
195
+ let bestRank: SubstituteRank | undefined;
196
+ for (let index = 0; index < payload.length; index += 1) {
197
+ const line = payload[index]!;
157
198
  const candidateChars = [...line];
158
- if (candidateChars.length !== referenceChars.length) continue;
159
- const candidate = candidateChars[column];
160
- if (candidate !== undefined && candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
161
- }
162
- for (const line of payload) {
163
- for (const candidate of line) {
164
- if (candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
199
+ const echo = sensitiveStripped(line) === visibleReference;
200
+ const sameLength = candidateChars.length === referenceChars.length;
201
+ for (let at = 0; at < candidateChars.length; at += 1) {
202
+ const candidate = candidateChars[at]!;
203
+ if (candidate === char || !sharesFidelityClass(char, candidate)) continue;
204
+ const rank = { echo, aligned: sameLength && at === column, distance: Math.abs(at - column) };
205
+ if (!preferSubstitute(rank, bestRank)) continue;
206
+ bestRank = rank;
207
+ best = { value: candidate, payloadIndex: index, column: at + 1 };
165
208
  }
166
209
  }
167
- return undefined;
210
+ return best;
168
211
  }
169
212
 
170
213
  function swappedCharPair(oldLine: string, newLine: string): SwappedChar | undefined {
@@ -193,10 +236,15 @@ function swappedCandidate(line: string, matches: IndexedLine[], spanStart: numbe
193
236
  return candidates.find((candidate) => candidate.reference.index >= spanStart && candidate.reference.index <= spanEnd) ?? candidates[0];
194
237
  }
195
238
 
196
- function swappedCharHint(swapped: SwappedChar, anchor: string | undefined): string {
197
- const label = anchor === undefined ? "the replaced line" : `${anchor}${HASH_SEP}`;
198
- const expected = formatCodePoint(swapped.oldChar);
199
- return `[H_UNICODE_SWAPPED] ${formatCodePoint(swapped.newChar)} at col ${swapped.column} where ${label} has ${expected}; resend with ${expected} if unintended.`;
239
+ function swappedCharHint(swapped: SwappedChar, liveAnchor: string | undefined, referenceAnchor: string | undefined): string {
240
+ const newEscape = formatCharEscape(swapped.newChar);
241
+ const oldEscape = formatCharEscape(swapped.oldChar);
242
+ if (liveAnchor === undefined) {
243
+ const label = referenceAnchor === undefined ? "the replaced line" : `${referenceAnchor}${HASH_SEP}`;
244
+ return `[H_UNICODE_SWAPPED] "${newEscape}" at col ${swapped.column} where ${label} has "${oldEscape}".`;
245
+ }
246
+ const referenceNote = referenceAnchor === undefined ? `; expected "${oldEscape}"` : `; ${referenceAnchor}${HASH_SEP} has "${oldEscape}"`;
247
+ return `[H_UNICODE_SWAPPED] ${liveAnchor}${HASH_SEP} has "${newEscape}" at col ${swapped.column}${referenceNote}.`;
200
248
  }
201
249
 
202
250
  function trailingWhitespaceHint(oldLine: string, newLine: string, anchor: string | undefined): string {
@@ -327,18 +375,6 @@ function literalEscapeHintRows(
327
375
  return rows;
328
376
  }
329
377
 
330
- function actualEscapeName(escape: string): string {
331
- const hex = /^\\u([0-9a-fA-F]{4})$/.exec(escape)?.[1];
332
- if (hex !== undefined) return `U+${hex.toUpperCase()}`;
333
- if (escape.length === 2) {
334
- if (escape[1] === "n") return "a line break";
335
- if (escape[1] === "r") return "a carriage return";
336
- if (escape[1] === "t") return "a tab";
337
- if (escape[1] === '"') return "a double quote";
338
- }
339
- return "the character";
340
- }
341
-
342
378
  export function annotateLiteralEscapeHints(
343
379
  hints: string[],
344
380
  resultContent: string,
@@ -353,10 +389,10 @@ export function annotateLiteralEscapeHints(
353
389
  const rows = literalEscapeHintRows(resultContent, spans, resultHashes, escape);
354
390
  if (rows.length === 0) return hint;
355
391
  if (rows.length > MAX_LITERAL_ESCAPE_ROWS) {
356
- return `${hint} on ${plural(rows.length, "row")}; undo_last_change + resend with ${actualEscapeName(escape)} if unintended.`;
392
+ return `${hint} on ${plural(rows.length, "row")}; undo_last_change if unintended.`;
357
393
  }
358
394
  const locations = rows.map((row) => `${resultHashes[row.index]!}${HASH_SEP} col ${literalEscapeColumn(row.line, escape)}`).join(", ");
359
- return `${hint} (${locations}); resend with ${actualEscapeName(escape)} if unintended.`;
395
+ return `${hint} (${locations}).`;
360
396
  });
361
397
  }
362
398
 
@@ -404,6 +440,8 @@ export function fidelityHints(
404
440
  return sensitiveIndex;
405
441
  };
406
442
  let offset = 0;
443
+ const removedByAnySpan = (index: number): boolean =>
444
+ spans.some((candidate) => index >= candidate.start && index <= candidate.end);
407
445
  for (const span of [...spans].sort((a, b) => a.start - b.start)) {
408
446
  const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
409
447
  const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
@@ -416,10 +454,31 @@ export function fidelityHints(
416
454
  ? removed.map((line, index) => ({ line, index: span.start + index }))
417
455
  : referenceRows(oldLines, span.start, span.end);
418
456
  const insertGate = carried === undefined ? null : (gramSet(payload, SIMILARITY_RUN, true) ?? new Set<string>());
457
+ const resultIndexForPayload = (payloadIndex: number): number =>
458
+ span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
459
+ const referenceSurvives = (reference: ReferenceRow): boolean =>
460
+ (carried !== undefined && reference.index === span.start && inserted[carried] === oldLines[reference.index]) ||
461
+ !removedByAnySpan(reference.index);
462
+ const liveAnchorForPayload = (payloadIndex: number): string | undefined =>
463
+ resultHashes?.[resultIndexForPayload(payloadIndex)];
464
+ const payloadIndexSharingRun = (line: string): number => {
465
+ const grams = gramSet([sensitiveStripped(line)], SIMILARITY_RUN);
466
+ return grams === undefined ? -1 : payload.findIndex((candidate) => sharesRun(sensitiveStripped(candidate), grams, SIMILARITY_RUN));
467
+ };
468
+ const liveAnchorForReference = (reference: ReferenceRow): string | undefined => {
469
+ const shared = payloadIndexSharingRun(reference.line);
470
+ if (shared >= 0) return liveAnchorForPayload(shared);
471
+ if (carried !== undefined || removed.length !== payload.length) return undefined;
472
+ const payloadIndex = reference.index - span.start;
473
+ return payloadIndex >= 0 && payloadIndex < payload.length ? liveAnchorForPayload(payloadIndex) : undefined;
474
+ };
475
+ const referenceAnchorFor = (reference: ReferenceRow): string | undefined =>
476
+ referenceSurvives(reference) ? originalHashes?.[reference.index] : undefined;
419
477
  const swapKeys = new Set<string>();
420
478
  const swaps: SwapCandidate[] = [];
421
479
  let candidates: Map<string, IndexedLine[]> | undefined;
422
- for (const line of payload) {
480
+ for (let payloadIndex = 0; payloadIndex < payload.length; payloadIndex += 1) {
481
+ const line = payload[payloadIndex]!;
423
482
  if (!containsSensitiveChar(line)) continue;
424
483
  candidates ??= sensitiveLines();
425
484
  const matches = [...(candidates.get(normalizeSensitiveChars(line)) ?? []), ...references];
@@ -428,7 +487,7 @@ export function fidelityHints(
428
487
  const key = `${candidate.reference.index}:${candidate.swapped.oldChar}`;
429
488
  if (swapKeys.has(key)) continue;
430
489
  swapKeys.add(key);
431
- swaps.push(candidate);
490
+ swaps.push({ ...candidate, liveAnchor: liveAnchorForPayload(payloadIndex) });
432
491
  }
433
492
  const missing = new Map<string, ReferenceRow>();
434
493
  for (const reference of references) {
@@ -449,17 +508,17 @@ export function fidelityHints(
449
508
  const substitute = findSubstitute(char, reference, payload);
450
509
  if (substitute !== undefined) {
451
510
  seen.add(`swap:${reference.index}:${char}`);
452
- hints.push(swappedCharHint({ oldChar: char, newChar: substitute, column: [...reference.line].indexOf(char) + 1 }, originalHashes?.[reference.index]));
511
+ hints.push(swappedCharHint({ oldChar: char, newChar: substitute.value, column: substitute.column }, liveAnchorForPayload(substitute.payloadIndex), referenceAnchorFor(reference)));
453
512
  continue;
454
513
  }
455
- hints.push(hiddenCharHint(char, reference, originalHashes?.[reference.index]));
514
+ hints.push(hiddenCharHint(char, reference, liveAnchorForReference(reference), referenceAnchorFor(reference)));
456
515
  }
457
516
  for (const candidate of swaps) {
458
517
  if (hints.length >= MAX_FIDELITY_HINTS) break;
459
518
  const key = `swap:${candidate.reference.index}:${candidate.swapped.oldChar}`;
460
519
  if (seen.has(key)) continue;
461
520
  seen.add(key);
462
- hints.push(swappedCharHint(candidate.swapped, originalHashes?.[candidate.reference.index]));
521
+ hints.push(swappedCharHint(candidate.swapped, candidate.liveAnchor, referenceAnchorFor(candidate.reference)));
463
522
  }
464
523
  const indentReferenceFor = (payloadIndex: number): ReferenceRow | undefined => {
465
524
  if (options?.indentHints === false) return undefined;
@@ -482,8 +541,6 @@ export function fidelityHints(
482
541
  }
483
542
  return undefined;
484
543
  };
485
- const resultIndexForPayload = (payloadIndex: number): number =>
486
- span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
487
544
  for (let index = 0; index < payload.length; index += 1) {
488
545
  if (hints.length >= MAX_FIDELITY_HINTS) break;
489
546
  const payloadLine = payload[index]!;
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
3
3
  import { TEXT_ENDING_NOTE, TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
4
+ import { toLF } from "./normalize";
4
5
 
5
6
  const textSchema = Type.String({
6
7
  description:
@@ -166,25 +167,44 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
166
167
  }
167
168
  }
168
169
 
169
- const MATCH_KS = new Set(["path", "replace_from", "replace_to", "old_string", "new_string"]);
170
+ const MATCH_KS = new Set(["path", "replace_from", "replace_to", "match", "replacement"]);
170
171
 
171
172
  export interface ReplaceMatchReq {
172
173
  path?: string;
173
174
  replace_from: string;
174
175
  replace_to: string;
175
- old_string: string;
176
- new_string: string;
176
+ match: string;
177
+ replacement: string;
178
+ }
179
+
180
+ export function singleLineFragment(field: "match" | "replacement", raw: string): string {
181
+ const text = toLF(raw);
182
+ const fragment = text.endsWith("\n") ? text.slice(0, -1) : text;
183
+ const lineBreak = fragment.indexOf("\n");
184
+ if (lineBreak >= 0) {
185
+ const column = [...fragment.slice(0, lineBreak)].length + 1;
186
+ throw new Error(
187
+ `[E_BAD_SHAPE] ${field} must stay within one line, but the value contains a line break at column ${column}. A trailing line break is accepted as the line's own terminator; text on another line belongs to replace.`,
188
+ );
189
+ }
190
+ if (field === "match" && fragment.length === 0) {
191
+ throw new Error('[E_BAD_SHAPE] Replace-match request field "match" must be a non-empty string holding the exact text to find.');
192
+ }
193
+ return fragment;
177
194
  }
178
195
 
179
196
  export function assertReplaceMatchReq(request: unknown): asserts request is ReplaceMatchReq {
180
197
  if (!isRec(request)) {
181
198
  throw new Error("[E_BAD_SHAPE] Replace-match request must be an object.");
182
199
  }
200
+ if ("old_string" in request || "new_string" in request) {
201
+ throw new Error('[E_BAD_SHAPE] Replace-match request no longer accepts "old_string"/"new_string"; use "match" for the text to find and "replacement" for the new text.');
202
+ }
183
203
  rejectUnknownFields(request, MATCH_KS, "Replace-match request");
184
204
  if (request.path !== undefined && typeof request.path !== "string") {
185
205
  throw new Error('[E_BAD_SHAPE] Replace-match request field "path" must be a string when provided.');
186
206
  }
187
- for (const key of ["replace_from", "replace_to", "old_string", "new_string"] as const) {
207
+ for (const key of ["replace_from", "replace_to", "match", "replacement"] as const) {
188
208
  if (typeof (request as Record<string, unknown>)[key] !== "string") {
189
209
  throw new Error(`[E_BAD_SHAPE] Replace-match request requires a "${key}" string.`);
190
210
  }
@@ -193,13 +213,12 @@ export function assertReplaceMatchReq(request: unknown): asserts request is Repl
193
213
  if (within.replace_from.length === 0 || within.replace_to.length === 0) {
194
214
  throw new Error('[E_BAD_SHAPE] Replace-match request requires non-empty "replace_from" and "replace_to" anchors.');
195
215
  }
196
- if (within.old_string.length === 0) {
197
- throw new Error('[E_BAD_SHAPE] Replace-match request field "old_string" must be a non-empty string holding the exact text to find.');
198
- }
199
- assertNoNul([within.new_string]);
216
+ singleLineFragment("match", within.match);
217
+ singleLineFragment("replacement", within.replacement);
218
+ assertNoNul([within.replacement]);
200
219
  }
201
220
 
202
- export function getReplaceMatchInput(args: unknown): { path?: string; replace_from: string; replace_to: string; old_string: string; new_string: string } | null {
221
+ export function getReplaceMatchInput(args: unknown): { path?: string; replace_from: string; replace_to: string; match: string; replacement: string } | null {
203
222
  let normalized: unknown;
204
223
  try {
205
224
  normalized = normalizeRequest(args, "replace");
@@ -210,8 +229,8 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
210
229
  if (
211
230
  typeof normalized.replace_from !== "string" ||
212
231
  typeof normalized.replace_to !== "string" ||
213
- typeof normalized.old_string !== "string" ||
214
- typeof normalized.new_string !== "string"
232
+ typeof normalized.match !== "string" ||
233
+ typeof normalized.replacement !== "string"
215
234
  ) {
216
235
  return null;
217
236
  }
@@ -219,8 +238,8 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
219
238
  ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
220
239
  replace_from: normalized.replace_from,
221
240
  replace_to: normalized.replace_to,
222
- old_string: normalized.old_string,
223
- new_string: normalized.new_string,
241
+ match: normalized.match,
242
+ replacement: normalized.replacement,
224
243
  };
225
244
  }
226
245
 
@@ -230,15 +249,15 @@ const replaceMatchFromSchema = Type.String({
230
249
  });
231
250
  const replaceMatchToSchema = Type.String({
232
251
  description:
233
- "4-char anchor of the LAST line of the range searched; same as replace_from for a single line.",
252
+ "4-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
234
253
  });
235
- const replaceMatchOldStringSchema = Type.String({
254
+ const replaceMatchMatchSchema = Type.String({
236
255
  description:
237
- "The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line match: use `replace` instead. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
256
+ "The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line match: use `replace` instead.",
238
257
  });
239
- const replaceMatchNewStringSchema = Type.String({
258
+ const replaceMatchReplacementSchema = Type.String({
240
259
  description:
241
- `The exact replacement for every matched occurrence. "" deletes the matches. ${TEXT_ENDING_NOTE} The rest of the range is kept byte-for-byte.`,
260
+ `The exact replacement for every matched occurrence within the range. "" deletes the matches. It must stay within one line: a single trailing line break is accepted and dropped (a value that is only a line break counts as ""), and a line break anywhere else is refused. The rest of each line is kept byte-for-byte.`,
242
261
  });
243
262
  const replaceMatchPathRequiredSchema = Type.String({
244
263
  description:
@@ -249,8 +268,8 @@ export const replaceMatchToolSchema = Type.Object(
249
268
  {
250
269
  replace_from: replaceMatchFromSchema,
251
270
  replace_to: replaceMatchToSchema,
252
- old_string: replaceMatchOldStringSchema,
253
- new_string: replaceMatchNewStringSchema,
271
+ match: replaceMatchMatchSchema,
272
+ replacement: replaceMatchReplacementSchema,
254
273
  },
255
274
  { additionalProperties: true },
256
275
  );
@@ -262,8 +281,8 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
262
281
  path: replaceMatchPathRequiredSchema,
263
282
  replace_from: replaceMatchFromSchema,
264
283
  replace_to: replaceMatchToSchema,
265
- old_string: replaceMatchOldStringSchema,
266
- new_string: replaceMatchNewStringSchema,
284
+ match: replaceMatchMatchSchema,
285
+ replacement: replaceMatchReplacementSchema,
267
286
  },
268
287
  { additionalProperties: true },
269
288
  ) as typeof replaceMatchToolSchema;
@@ -1,10 +1,9 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { constants } from "node:fs";
3
- import * as Diff from "diff";
4
3
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
5
4
  import { commitEdit } from "./commit";
6
5
  import { readNormFile, type NormFile } from "./file-reader";
7
- import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
6
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
8
7
  import { servedForPath, withAnchorSession } from "./anchor-registry";
9
8
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
10
9
  import { loadP, loadGuidelines } from "./prompts";
@@ -13,13 +12,13 @@ import {
13
12
  buildReplaceMatchToolSchema,
14
13
  getReplaceMatchInput,
15
14
  normReq,
15
+ singleLineFragment,
16
16
  type ReplaceMatchReq,
17
17
  } from "./payload-contract";
18
18
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
19
- import { toLF, type LineEnding } from "./normalize";
20
- import { isFidelitySensitiveChar } from "./edit-fidelity";
21
- import { countNewlines, splitWithEndings } from "./line-endings";
19
+ import { type LineEnding } from "./normalize";
22
20
  import { MAX_RANGE_STALE_LINES } from "./constants";
21
+ import { formatCharEscape, isFidelitySensitiveChar } from "./edit-fidelity";
23
22
  import {
24
23
  assertBoundaryLinesServed,
25
24
  DEFAULT_EDIT_FLAGS,
@@ -43,73 +42,43 @@ interface MatchRefs {
43
42
 
44
43
  export interface MatchPlan {
45
44
  editParams: { remove_from: string; remove_to: string; text: string };
46
- warnings: string[];
47
45
  contentSeparators: (LineEnding | undefined)[];
48
46
  servedOverride?: ReadonlyMap<string, string>;
49
47
  }
50
48
 
51
- function formatMatchRange(start: number, end: number): string {
52
- return start === end ? `line ${start}` : `lines ${start}-${end}`;
53
- }
54
-
55
49
  function replaceAllOccurrences(
56
- text: string,
57
- oldText: string,
58
- newText: string,
59
- ): { text: string; separators: (LineEnding | undefined)[]; count: number } {
50
+ line: string,
51
+ matchText: string,
52
+ replacementText: string,
53
+ ): { text: string; count: number } {
60
54
  const parts: string[] = [];
61
- const inserted: Array<{ start: number; end: number }> = [];
62
- let length = 0;
63
- const append = (chunk: string, fromNewText: boolean): void => {
64
- if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
65
- parts.push(chunk);
66
- length += chunk.length;
67
- };
68
55
  let cursor = 0;
69
56
  let count = 0;
70
57
  for (;;) {
71
- const found = text.indexOf(oldText, cursor);
58
+ const found = line.indexOf(matchText, cursor);
72
59
  if (found < 0) break;
73
- append(text.slice(cursor, found), false);
74
- append(newText, true);
75
- cursor = found + oldText.length;
60
+ parts.push(line.slice(cursor, found), replacementText);
61
+ cursor = found + matchText.length;
76
62
  count += 1;
77
63
  }
78
- if (count === 0) return { text, separators: [], count: 0 };
79
- append(text.slice(cursor), false);
80
- const replacement = parts.join("");
81
- const parsed = splitWithEndings(replacement);
82
- const separators: (LineEnding | undefined)[] = [];
83
- let offset = 0;
84
- let insertedIndex = 0;
85
- for (let index = 0; index < parsed.lines.length; index++) {
86
- offset += parsed.lines[index]!.length;
87
- const ending = parsed.endings[index];
88
- if (ending === undefined) continue;
89
- while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
90
- const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
91
- separators.push(fromNewText ? ending : undefined);
92
- offset += ending.length;
93
- }
94
- return { text: replacement, separators, count };
64
+ if (count === 0) return { text: line, count: 0 };
65
+ parts.push(line.slice(cursor));
66
+ return { text: parts.join(""), count };
95
67
  }
96
68
 
97
- const RETYPE_DIFF_MAX_CHARS = 20_000;
98
- const RETYPE_MAX_EDIT_LENGTH = 2000;
99
- const RETYPE_MIN_UNCHANGED_CHARS = 40;
100
- const RETYPE_MIN_UNCHANGED_MULTILINE = 20;
101
- const RETYPE_EXAMPLE_MAX_CHARS = 40;
102
- const WHOLE_RANGE_MIN_CHARS = 40;
103
- const WHOLE_RANGE_MIN_LINES = 2;
104
- const NEAR_MISS_MIN_OLD_CHARS = 20;
105
- const NEAR_MISS_MATCH_RATIO = 0.8;
69
+ function linesToPayloadText(lines: string[]): string {
70
+ if (lines.length === 0) return "";
71
+ const joined = lines.join("\n");
72
+ return lines[lines.length - 1] === "" ? `${joined}\n` : joined;
73
+ }
106
74
 
107
- interface RetypeAnalysis {
108
- changed: number;
109
- unchanged: number;
110
- middle: { oldFragment: string; newFragment: string };
75
+ function fileInheritedEndings(lineCount: number): (LineEnding | undefined)[] {
76
+ return new Array<LineEnding | undefined>(lineCount).fill(undefined);
111
77
  }
112
78
 
79
+ const NEAR_MISS_MIN_MATCH_CHARS = 20;
80
+ const NEAR_MISS_MATCH_RATIO = 0.8;
81
+
113
82
  function commonPrefixLength(left: string, right: string): number {
114
83
  const limit = Math.min(left.length, right.length);
115
84
  let index = 0;
@@ -124,91 +93,50 @@ function commonSuffixLength(left: string, right: string, prefix: number): number
124
93
  return suffix;
125
94
  }
126
95
 
127
- function analyzeRetype(oldText: string, newText: string): RetypeAnalysis {
128
- const prefix = commonPrefixLength(oldText, newText);
129
- const suffix = commonSuffixLength(oldText, newText, prefix);
130
- const middle = {
131
- oldFragment: oldText.slice(prefix, oldText.length - suffix),
132
- newFragment: newText.slice(prefix, newText.length - suffix),
133
- };
134
- if (oldText.length + newText.length <= RETYPE_DIFF_MAX_CHARS) {
135
- const parts = Diff.diffChars(oldText, newText, { maxEditLength: RETYPE_MAX_EDIT_LENGTH }) as Array<{ added?: boolean; removed?: boolean; value: string }> | undefined;
136
- if (parts !== undefined) {
137
- let changed = 0;
138
- let removed = 0;
139
- for (const part of parts) {
140
- if (part.added === true) changed += part.value.length;
141
- else if (part.removed === true) {
142
- changed += part.value.length;
143
- removed += part.value.length;
144
- }
145
- }
146
- return { changed, unchanged: oldText.length - removed, middle };
147
- }
148
- }
149
- return { changed: middle.oldFragment.length + middle.newFragment.length, unchanged: prefix + suffix, middle };
150
- }
151
-
152
- function retypeSuggestion(analysis: RetypeAnalysis): string {
153
- const { oldFragment, newFragment } = analysis.middle;
154
- if (oldFragment.length === 0 && newFragment.length === 0) return "Send only the fragment that changes instead.";
155
- if (oldFragment.length === 0) return "Send the added text with insert instead.";
156
- if (
157
- oldFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
158
- newFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
159
- oldFragment.includes("\n") ||
160
- newFragment.includes("\n")
161
- ) {
162
- return "Send only the fragment that changes instead.";
163
- }
164
- return `Send old_string ${JSON.stringify(oldFragment)} and new_string ${JSON.stringify(newFragment)} instead.`;
96
+ function charAt(text: string, index: number): string | undefined {
97
+ if (index >= text.length) return undefined;
98
+ return String.fromCodePoint(text.codePointAt(index)!);
165
99
  }
166
100
 
167
- function retypeWarning(oldText: string, newText: string): string | undefined {
168
- const analysis = analyzeRetype(oldText, newText);
169
- if (analysis.changed === 0) return undefined;
170
- const minUnchanged = oldText.includes("\n") ? RETYPE_MIN_UNCHANGED_MULTILINE : RETYPE_MIN_UNCHANGED_CHARS;
171
- if (analysis.unchanged < minUnchanged) return undefined;
172
- const lineCount = oldText.split("\n").length;
173
- return `[W_REPLACE_MATCH_RETYPE] "old_string" retypes ${analysis.unchanged} unchanged character(s) across ${lineCount} line(s). ${retypeSuggestion(analysis)}`;
174
- }
175
-
176
- function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number, start: number, end: number, startHash: string, endHash: string): string | undefined {
177
- if (oldText !== rangeText) return undefined;
178
- if (lineCount < WHOLE_RANGE_MIN_LINES && oldText.length < WHOLE_RANGE_MIN_CHARS) return undefined;
179
- return `[W_REPLACE_MATCH_WHOLE_RANGE] "old_string" is the entire selected range (${formatMatchRange(start, end)}); the anchors already identify it. Use replace with remove_from "${startHash}", remove_to "${endHash}" and the new text, or send only the fragment that changes.`;
101
+ function codePointStart(text: string, index: number): number {
102
+ if (index <= 0 || index >= text.length) return index;
103
+ const before = text.charCodeAt(index - 1);
104
+ const at = text.charCodeAt(index);
105
+ return before >= 0xd800 && before <= 0xdbff && at >= 0xdc00 && at <= 0xdfff ? index - 1 : index;
180
106
  }
181
107
 
182
108
  function describeChar(char: string | undefined): string {
183
109
  if (char === undefined) return "the end of the text";
184
110
  const codePoint = char.codePointAt(0)!;
185
- const shown = JSON.stringify(char);
186
- return isFidelitySensitiveChar(char) || codePoint < 0x20 || codePoint > 0x7e
187
- ? `${shown} (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`
188
- : shown;
111
+ return isFidelitySensitiveChar(char) || codePoint > 0x7e ? `"${formatCharEscape(char)}"` : JSON.stringify(char);
189
112
  }
190
113
 
191
- function nearMissDetail(oldText: string, rangeText: string, start: number): string | undefined {
192
- if (oldText.length < NEAR_MISS_MIN_OLD_CHARS && !oldText.includes("\n")) return undefined;
193
- const prefix = commonPrefixLength(oldText, rangeText);
194
- const suffix = commonSuffixLength(oldText, rangeText, prefix);
195
- const comparable = Math.min(oldText.length, rangeText.length);
114
+ function nearMissDetail(matchText: string, lineText: string, line: number): string | undefined {
115
+ if (matchText.length < NEAR_MISS_MIN_MATCH_CHARS) return undefined;
116
+ const prefix = commonPrefixLength(matchText, lineText);
117
+ const suffix = commonSuffixLength(matchText, lineText, prefix);
118
+ const comparable = Math.min(matchText.length, lineText.length);
196
119
  if (prefix + suffix < Math.floor(comparable * NEAR_MISS_MATCH_RATIO)) return undefined;
197
- const lineOffset = countNewlines(rangeText.slice(0, prefix));
198
- const column = prefix - rangeText.lastIndexOf("\n", prefix - 1);
199
- return `the first mismatch is at line ${start + lineOffset}, col ${column}: the file has ${describeChar(rangeText[prefix])} where old_string has ${describeChar(oldText[prefix])}.`;
120
+ const fileStart = codePointStart(lineText, prefix);
121
+ const matchStart = codePointStart(matchText, prefix);
122
+ const column = [...lineText.slice(0, fileStart)].length + 1;
123
+ return `the first mismatch is at line ${line}, col ${column}: the file has ${describeChar(charAt(lineText, fileStart))} where match has ${describeChar(charAt(matchText, matchStart))}.`;
200
124
  }
201
125
 
202
- function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, oldText: string): string {
126
+ function formatMatchRange(start: number, end: number): string {
127
+ return start === end ? `line ${start}` : `lines ${start}-${end}`;
128
+ }
129
+
130
+ function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, matchText: string): string {
203
131
  const rangeLength = end - start + 1;
204
132
  const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
205
133
  const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
206
- const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
207
- const nearMiss = nearMissDetail(oldText, rangeText, start);
134
+ const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}. Call read with offset=${start + shownCount} to see the rest.]` : "";
135
+ const nearMiss = start === end ? nearMissDetail(matchText, rangeText, start) : undefined;
208
136
  const advice = nearMiss !== undefined
209
- ? `${nearMiss} That looks like a retyped copy of the range; send only the fragment that changes (copy it from the served row), or use replace with the same anchors.`
210
- : "Copy only the fragment that changes from the served row (comparison uses LF breaks and excludes the last line's terminator) and retry; for a whole-line or whole-range rewrite use replace with the same anchors.";
211
- return `[E_SUBSTRING_NOT_FOUND] "old_string" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\n${advice}`;
137
+ ? `${nearMiss} That looks like a retyped copy of the line; send only the fragment that changes (copy it from the served row), or use replace with the same anchor.`
138
+ : "Copy only the fragment that changes from the served row (a trailing line break is accepted and dropped) and retry; for a whole-line rewrite use replace with the same anchors.";
139
+ return `[E_SUBSTRING_NOT_FOUND] "match" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\n${advice}`;
212
140
  }
213
141
 
214
142
  export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warnings: string[] } {
@@ -225,6 +153,8 @@ export function buildReplaceMatchEdit(
225
153
  displayPath: string,
226
154
  served?: ReadonlyMap<string, string>,
227
155
  ): MatchPlan {
156
+ const matchText = singleLineFragment("match", req.match);
157
+ const replacementText = singleLineFragment("replacement", req.replacement);
228
158
  const fileLines = splitLines(preload.normalized);
229
159
  const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
230
160
  const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
@@ -232,33 +162,26 @@ export function buildReplaceMatchEdit(
232
162
  const end = Math.max(fromLine, toLine);
233
163
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
234
164
  const rangeLines = fileLines.slice(start - 1, end);
235
- const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
236
- const oldText = toLF(req.old_string);
237
- const newText = toLF(req.new_string);
238
- const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
165
+ let count = 0;
166
+ const replacementLines = rangeLines.map((line) => {
167
+ const result = replaceAllOccurrences(line, matchText, replacementText);
168
+ count += result.count;
169
+ return result.text;
170
+ });
239
171
  if (count === 0) {
240
- throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
172
+ throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
241
173
  }
242
- const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
174
+ const replacement = linesToPayloadText(replacementLines);
243
175
  const startRef = fromLine <= toLine ? refs.from : refs.to;
244
176
  const endRef = fromLine <= toLine ? refs.to : refs.from;
245
177
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
246
- const warnings: string[] = [];
247
- const retype = retypeWarning(oldText, newText);
248
- if (retype !== undefined) {
249
- warnings.push(retype);
250
- } else {
251
- const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
252
- if (wholeRange !== undefined) warnings.push(wholeRange);
253
- }
254
178
  return {
255
179
  editParams: {
256
180
  remove_from: startRef.hash,
257
181
  remove_to: endRef.hash,
258
182
  text: replacement,
259
183
  },
260
- warnings,
261
- contentSeparators,
184
+ contentSeparators: fileInheritedEndings(replacementLines.length),
262
185
  ...(servedOverride !== undefined ? { servedOverride } : {}),
263
186
  };
264
187
  }
@@ -267,7 +190,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
267
190
  const req = normReq(input.member.args, "replace");
268
191
  assertReplaceMatchReq(req);
269
192
  const { refs, warnings } = parseMatchAnchors(req);
270
- const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
193
+ const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
271
194
  const preload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
272
195
  const matchPlan = buildReplaceMatchEdit(req, refs, preload, input.displayPath, input.served);
273
196
  const resWarnings: string[] = [];
@@ -281,11 +204,10 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
281
204
  });
282
205
  return {
283
206
  hedit,
284
- warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
207
+ warnings: [...warnings, ...hints, ...resWarnings],
285
208
  contentSeparators: matchPlan.contentSeparators,
286
209
  planned,
287
210
  foldedLines: 0,
288
- ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
289
211
  };
290
212
  }
291
213
 
@@ -312,7 +234,6 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
312
234
  signal,
313
235
  });
314
236
  const plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
315
- await throwIfStrictInput(plan.warnings);
316
237
  const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
317
238
  accessMode: constants.R_OK,
318
239
  noPersist: true,
@@ -367,7 +288,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
367
288
  if (member) noteBatchFailure(member, error);
368
289
  throw error;
369
290
  }
370
- const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
291
+ const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
371
292
  const targetPath = await resolveEditTargetWithRequirement({
372
293
  removeFrom: req.replace_from,
373
294
  removeTo: req.replace_to,
@@ -394,7 +315,6 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
394
315
  await noteAnchorError(mutationTargetPath, error);
395
316
  throw error;
396
317
  }
397
- await throwIfStrictInput(plan.warnings);
398
318
  const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
399
319
  accessMode: constants.R_OK | constants.W_OK,
400
320
  signal,
@@ -407,7 +327,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
407
327
  absolutePath,
408
328
  mutationTargetPath,
409
329
  editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
410
- prefixWarnings: [...warnings, ...hints, ...plan.warnings],
330
+ prefixWarnings: [...warnings, ...hints],
411
331
  signal,
412
332
  verb: "replaced",
413
333
  noopNoun: "Replacement",
@@ -1,3 +1,3 @@
1
1
  - `read`: call again after an edit when you need anchors you lack — post-edit diff `+`/` ` rows and served `anchor│content` rows already carry fresh anchors for the changed range.
2
2
  - `read`: an auto-read-all outline row shows a symbol label, or a single-line symbol's content; its `[limit N]` is the span to request.
3
- - `read`: `replace_match` needs both boundary lines served; copy `old_string` from a served row or read the lines first.
3
+ - `read`: `replace_match` needs both boundary lines served; copy `match` from a served row or read the lines first.
@@ -1,3 +1,3 @@
1
- - `replace_match`: `old_string` is only the fragment that changes — never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range when only some lines should change.
2
- - `replace_match`: if `old_string` is everything on the line except indentation and trailing punctuation, that match is the whole line — use `replace`. A shorter fragment repeated on several lines (a value, a name) is the intended multi-line use.
3
- - `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the exact text.
1
+ - `replace_match`: `match` is only the fragment that changes — never text that `replacement` leaves unchanged; every occurrence inside the range is replaced, so narrow the anchors when only some lines should change.
2
+ - `replace_match`: the anchors bound the range searched, and `match`/`replacement` must each stay within one line — a trailing line break is the line's terminator, an interior one belongs to `replace`, and a lone break cannot be a `match`.
3
+ - `replace_match`: when the change covers the whole line, use `replace` with the same anchor and the exact text.
@@ -1 +1 @@
1
- Replace part of a line or range by anchor: every `old_string` occurrence inside `replace_from`/`replace_to` becomes `new_string`; the rest of the line stays untouched
1
+ Replace part of a line or range by anchor: every `match` occurrence inside the range becomes `replacement`; the rest of each line stays untouched
@@ -1,3 +1,3 @@
1
- Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the fragment that changes — never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. The same short fragment on several lines (a value, a name) may be replaced in one call. `remove_from`/`remove_to` are accepted as aliases.
1
+ Replace part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, while a line break anywhere else is refused; a lone line break leaves nothing to find in `match` and counts as `""` in `replacement`. Send only the fragment that changes — never text that `replacement` leaves unchanged. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide; when only one occurrence on a range should change, narrow `replace_from`/`replace_to` instead of lengthening the fragment. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. `remove_from`/`remove_to` are accepted as aliases.
2
2
 
3
- The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
3
+ The two boundary anchors must still match what was last shown; lines strictly inside the range are searched as they are on disk.