pi-hashline-edit-pro 6.4.8 → 6.5.1
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 +8 -16
- package/package.json +1 -1
- package/src/edit-common.ts +6 -11
- package/src/edit-fidelity.ts +117 -41
- package/src/grep.ts +1 -1
- package/src/insert.ts +2 -7
- package/src/payload-contract.ts +43 -28
- package/src/read.ts +1 -1
- package/src/replace-match.ts +67 -147
- package/src/replace-response.ts +5 -4
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +1 -1
- package/tool-prompts/read-guidelines.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +3 -3
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +2 -2
- package/tool-prompts/replace.md +1 -1
- package/tool-prompts/transfer-guidelines.md +1 -1
package/README.md
CHANGED
|
@@ -190,8 +190,6 @@ Edge cases:
|
|
|
190
190
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
191
191
|
| `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
192
192
|
|
|
193
|
-
`replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
|
|
194
|
-
|
|
195
193
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
196
194
|
|
|
197
195
|
```json
|
|
@@ -219,15 +217,11 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
219
217
|
|
|
220
218
|
### replace_match
|
|
221
219
|
|
|
222
|
-
`replace_match` changes part of a line
|
|
223
|
-
|
|
224
|
-
`remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
|
|
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.
|
|
220
|
+
`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.
|
|
227
221
|
|
|
228
|
-
`
|
|
222
|
+
`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.
|
|
229
223
|
|
|
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 `
|
|
224
|
+
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
225
|
|
|
232
226
|
### insert
|
|
233
227
|
|
|
@@ -459,20 +453,18 @@ Full reference:
|
|
|
459
453
|
| Code | Meaning |
|
|
460
454
|
| --- | --- |
|
|
461
455
|
| `[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,
|
|
456
|
+
| `[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
457
|
| `[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
458
|
| `[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 `
|
|
459
|
+
| `[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
460
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
467
461
|
| `[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
462
|
| `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
469
463
|
| `[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
464
|
| `[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)
|
|
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
|
|
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
|
|
465
|
+
| `[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. |
|
|
466
|
+
| `[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. |
|
|
467
|
+
| `[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
468
|
| `[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
469
|
| `[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
470
|
| `[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.
|
|
3
|
+
"version": "6.5.1",
|
|
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",
|
package/src/edit-common.ts
CHANGED
|
@@ -80,7 +80,6 @@ const UNDO_WRITE_CLEARS_ID = "undo-write-clears";
|
|
|
80
80
|
const UNDO_CROSS_FILE_MOVE_ID = "undo-cross-file-move";
|
|
81
81
|
const NEUTRAL_READ_REREAD = "`read`: call again after an edit when you need anchors you lack.";
|
|
82
82
|
const NEUTRAL_UNDO_WRITE_CLEARS = "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write.";
|
|
83
|
-
const GREP_OPS_PHRASE = "replace, insert, copy, or move";
|
|
84
83
|
const UNDO_OPS_PHRASE = "replace, replace_match, insert, copy, or move";
|
|
85
84
|
const UNDO_GUIDELINE_OPS_PHRASE = "`replace`/`replace_match`/`insert`/`copy`/`move`";
|
|
86
85
|
|
|
@@ -92,12 +91,12 @@ function batchGuideline(flags: EditToolFlags): string {
|
|
|
92
91
|
const tools = operationNames(SHARED_EDIT_OPS, flags);
|
|
93
92
|
const outcome = flags.autoRead ? "diff" : "result";
|
|
94
93
|
const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
|
|
95
|
-
return `${tools}: same-file calls in one message commit as one batch;
|
|
94
|
+
return `${tools}: same-file calls in one message commit as one batch. If you already know all the edits and their anchors, send them together instead of one turn at a time; an edit that needs anchors from a previous result waits for the next turn. 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
95
|
}
|
|
97
96
|
|
|
98
97
|
function diffGuideline(flags: EditToolFlags): string {
|
|
99
98
|
const tools = operationNames(SHARED_DIFF_OPS, flags);
|
|
100
|
-
return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit.`;
|
|
99
|
+
return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit. Rows the diff does not show keep their anchors, so a follow-up edit on them needs no re-read.`;
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
function pathGuideline(flags: EditToolFlags): string {
|
|
@@ -157,10 +156,8 @@ export function withInsertPrompts(base: { description: string; snippet: string;
|
|
|
157
156
|
return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
|
|
158
157
|
}
|
|
159
158
|
|
|
160
|
-
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] },
|
|
161
|
-
|
|
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 };
|
|
159
|
+
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, _flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
160
|
+
return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
|
|
164
161
|
}
|
|
165
162
|
|
|
166
163
|
function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
|
|
@@ -183,9 +180,7 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
|
|
|
183
180
|
export function withGrepPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
184
181
|
const guidelines = base.guidelines.map((guideline) => guideline.text);
|
|
185
182
|
if (flags.codemode) guidelines.push(GREP_SCRIPT_GUIDELINE);
|
|
186
|
-
|
|
187
|
-
const description = base.description.replaceAll(GREP_OPS_PHRASE, joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags)));
|
|
188
|
-
return { description, snippet: base.snippet, guidelines };
|
|
183
|
+
return { description: base.description, snippet: base.snippet, guidelines };
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
export function withUndoPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
@@ -299,7 +294,7 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
|
|
|
299
294
|
return anchorTarget;
|
|
300
295
|
}
|
|
301
296
|
|
|
302
|
-
const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"
|
|
297
|
+
const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
|
|
303
298
|
export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
304
299
|
const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
|
|
305
300
|
if (fixes.length === 0) return;
|
package/src/edit-fidelity.ts
CHANGED
|
@@ -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
|
|
80
|
-
|
|
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,
|
|
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
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
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
|
|
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,
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
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,29 @@ 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
|
|
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})
|
|
395
|
+
return `${hint} (${locations}).`;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const LITERAL_ESCAPE_DETAIL_RE = /^\[H_LITERAL_ESCAPE\] [^:]*: "(.*?)" written as literal text(?: \((.*)\))?\.?$/;
|
|
400
|
+
const LITERAL_ESCAPE_ANCHOR_RE = /([A-Za-z]{4})│/g;
|
|
401
|
+
const UNICODE_LOST_DETAIL_RE = /^\[H_UNICODE_LOST\] ([A-Za-z]{4})│ missing "([^"]*)"/;
|
|
402
|
+
|
|
403
|
+
export function dedupeLiteralEscapeHints(hints: readonly string[]): string[] {
|
|
404
|
+
const suppressed = new Set<string>();
|
|
405
|
+
for (const hint of hints) {
|
|
406
|
+
const literal = LITERAL_ESCAPE_DETAIL_RE.exec(hint);
|
|
407
|
+
if (literal === null) continue;
|
|
408
|
+
const escape = literal[1] ?? "";
|
|
409
|
+
for (const anchor of (literal[2] ?? "").matchAll(LITERAL_ESCAPE_ANCHOR_RE)) suppressed.add(`${anchor[1]}\0${escape}`);
|
|
410
|
+
}
|
|
411
|
+
if (suppressed.size === 0) return [...hints];
|
|
412
|
+
return hints.filter((hint) => {
|
|
413
|
+
const lost = UNICODE_LOST_DETAIL_RE.exec(hint);
|
|
414
|
+
return lost === null || !suppressed.has(`${lost[1]}\0${lost[2]}`);
|
|
360
415
|
});
|
|
361
416
|
}
|
|
362
417
|
|
|
@@ -404,6 +459,8 @@ export function fidelityHints(
|
|
|
404
459
|
return sensitiveIndex;
|
|
405
460
|
};
|
|
406
461
|
let offset = 0;
|
|
462
|
+
const removedByAnySpan = (index: number): boolean =>
|
|
463
|
+
spans.some((candidate) => index >= candidate.start && index <= candidate.end);
|
|
407
464
|
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
408
465
|
const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
409
466
|
const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
|
|
@@ -416,10 +473,31 @@ export function fidelityHints(
|
|
|
416
473
|
? removed.map((line, index) => ({ line, index: span.start + index }))
|
|
417
474
|
: referenceRows(oldLines, span.start, span.end);
|
|
418
475
|
const insertGate = carried === undefined ? null : (gramSet(payload, SIMILARITY_RUN, true) ?? new Set<string>());
|
|
476
|
+
const resultIndexForPayload = (payloadIndex: number): number =>
|
|
477
|
+
span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
|
|
478
|
+
const referenceSurvives = (reference: ReferenceRow): boolean =>
|
|
479
|
+
(carried !== undefined && reference.index === span.start && inserted[carried] === oldLines[reference.index]) ||
|
|
480
|
+
!removedByAnySpan(reference.index);
|
|
481
|
+
const liveAnchorForPayload = (payloadIndex: number): string | undefined =>
|
|
482
|
+
resultHashes?.[resultIndexForPayload(payloadIndex)];
|
|
483
|
+
const payloadIndexSharingRun = (line: string): number => {
|
|
484
|
+
const grams = gramSet([sensitiveStripped(line)], SIMILARITY_RUN);
|
|
485
|
+
return grams === undefined ? -1 : payload.findIndex((candidate) => sharesRun(sensitiveStripped(candidate), grams, SIMILARITY_RUN));
|
|
486
|
+
};
|
|
487
|
+
const liveAnchorForReference = (reference: ReferenceRow): string | undefined => {
|
|
488
|
+
const shared = payloadIndexSharingRun(reference.line);
|
|
489
|
+
if (shared >= 0) return liveAnchorForPayload(shared);
|
|
490
|
+
if (carried !== undefined || removed.length !== payload.length) return undefined;
|
|
491
|
+
const payloadIndex = reference.index - span.start;
|
|
492
|
+
return payloadIndex >= 0 && payloadIndex < payload.length ? liveAnchorForPayload(payloadIndex) : undefined;
|
|
493
|
+
};
|
|
494
|
+
const referenceAnchorFor = (reference: ReferenceRow): string | undefined =>
|
|
495
|
+
referenceSurvives(reference) ? originalHashes?.[reference.index] : undefined;
|
|
419
496
|
const swapKeys = new Set<string>();
|
|
420
497
|
const swaps: SwapCandidate[] = [];
|
|
421
498
|
let candidates: Map<string, IndexedLine[]> | undefined;
|
|
422
|
-
for (
|
|
499
|
+
for (let payloadIndex = 0; payloadIndex < payload.length; payloadIndex += 1) {
|
|
500
|
+
const line = payload[payloadIndex]!;
|
|
423
501
|
if (!containsSensitiveChar(line)) continue;
|
|
424
502
|
candidates ??= sensitiveLines();
|
|
425
503
|
const matches = [...(candidates.get(normalizeSensitiveChars(line)) ?? []), ...references];
|
|
@@ -428,7 +506,7 @@ export function fidelityHints(
|
|
|
428
506
|
const key = `${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
429
507
|
if (swapKeys.has(key)) continue;
|
|
430
508
|
swapKeys.add(key);
|
|
431
|
-
swaps.push(candidate);
|
|
509
|
+
swaps.push({ ...candidate, liveAnchor: liveAnchorForPayload(payloadIndex) });
|
|
432
510
|
}
|
|
433
511
|
const missing = new Map<string, ReferenceRow>();
|
|
434
512
|
for (const reference of references) {
|
|
@@ -449,17 +527,17 @@ export function fidelityHints(
|
|
|
449
527
|
const substitute = findSubstitute(char, reference, payload);
|
|
450
528
|
if (substitute !== undefined) {
|
|
451
529
|
seen.add(`swap:${reference.index}:${char}`);
|
|
452
|
-
hints.push(swappedCharHint({ oldChar: char, newChar: substitute, column:
|
|
530
|
+
hints.push(swappedCharHint({ oldChar: char, newChar: substitute.value, column: substitute.column }, liveAnchorForPayload(substitute.payloadIndex), referenceAnchorFor(reference)));
|
|
453
531
|
continue;
|
|
454
532
|
}
|
|
455
|
-
hints.push(hiddenCharHint(char, reference,
|
|
533
|
+
hints.push(hiddenCharHint(char, reference, liveAnchorForReference(reference), referenceAnchorFor(reference)));
|
|
456
534
|
}
|
|
457
535
|
for (const candidate of swaps) {
|
|
458
536
|
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
459
537
|
const key = `swap:${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
460
538
|
if (seen.has(key)) continue;
|
|
461
539
|
seen.add(key);
|
|
462
|
-
hints.push(swappedCharHint(candidate.swapped,
|
|
540
|
+
hints.push(swappedCharHint(candidate.swapped, candidate.liveAnchor, referenceAnchorFor(candidate.reference)));
|
|
463
541
|
}
|
|
464
542
|
const indentReferenceFor = (payloadIndex: number): ReferenceRow | undefined => {
|
|
465
543
|
if (options?.indentHints === false) return undefined;
|
|
@@ -482,8 +560,6 @@ export function fidelityHints(
|
|
|
482
560
|
}
|
|
483
561
|
return undefined;
|
|
484
562
|
};
|
|
485
|
-
const resultIndexForPayload = (payloadIndex: number): number =>
|
|
486
|
-
span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
|
|
487
563
|
for (let index = 0; index < payload.length; index += 1) {
|
|
488
564
|
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
489
565
|
const payloadLine = payload[index]!;
|
package/src/grep.ts
CHANGED
|
@@ -393,7 +393,7 @@ const grepToolSchema = Type.Object(
|
|
|
393
393
|
limit: Type.Optional(
|
|
394
394
|
Type.Integer({
|
|
395
395
|
minimum: 1,
|
|
396
|
-
description: "Maximum number of matched lines to return",
|
|
396
|
+
description: "Maximum number of matched lines to return (default 100)",
|
|
397
397
|
}),
|
|
398
398
|
),
|
|
399
399
|
},
|
package/src/insert.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { LineEnding } from "./normalize";
|
|
|
11
11
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
12
12
|
import { withAnchorSession } from "./anchor-registry";
|
|
13
13
|
import { loadP, loadGuidelines } from "./prompts";
|
|
14
|
-
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
+
import { assertInsertReq, normReq, pathRequiredSchema, type InsertReq } from "./payload-contract";
|
|
15
15
|
import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
|
|
16
16
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
17
17
|
import type { RPreview, RRState } from "./replace-render";
|
|
@@ -32,11 +32,6 @@ const insertTextSchema = Type.String({
|
|
|
32
32
|
`The exact text to insert; an empty string inserts one blank line. ${TEXT_ENDING_NOTE} Never include the anchor line.`,
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
const insertPathRequiredSchema = Type.String({
|
|
36
|
-
description:
|
|
37
|
-
"Path to the file the anchor was served for; required and must match anchor ownership.",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
35
|
const insertToolSchema = Type.Object(
|
|
41
36
|
{
|
|
42
37
|
anchor: insertAnchorSchema,
|
|
@@ -50,7 +45,7 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
|
|
|
50
45
|
if (!requirePath) return insertToolSchema;
|
|
51
46
|
return Type.Object(
|
|
52
47
|
{
|
|
53
|
-
path:
|
|
48
|
+
path: pathRequiredSchema,
|
|
54
49
|
anchor: insertAnchorSchema,
|
|
55
50
|
direction: insertDirectionSchema,
|
|
56
51
|
text: insertTextSchema,
|
package/src/payload-contract.ts
CHANGED
|
@@ -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:
|
|
@@ -16,7 +17,7 @@ const removeToSchema = Type.String({
|
|
|
16
17
|
description:
|
|
17
18
|
"4-char anchor of the LAST line to remove.",
|
|
18
19
|
});
|
|
19
|
-
const pathRequiredSchema = Type.String({
|
|
20
|
+
export const pathRequiredSchema = Type.String({
|
|
20
21
|
description:
|
|
21
22
|
"Path to the file the anchors were served for; required and must match anchor ownership.",
|
|
22
23
|
});
|
|
@@ -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", "
|
|
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
|
-
|
|
176
|
-
|
|
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", "
|
|
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
|
-
|
|
197
|
-
|
|
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;
|
|
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.
|
|
214
|
-
typeof normalized.
|
|
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
|
-
|
|
223
|
-
|
|
241
|
+
match: normalized.match,
|
|
242
|
+
replacement: normalized.replacement,
|
|
224
243
|
};
|
|
225
244
|
}
|
|
226
245
|
|
|
@@ -230,27 +249,23 @@ 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.",
|
|
234
|
-
});
|
|
235
|
-
const replaceMatchOldStringSchema = Type.String({
|
|
236
|
-
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.",
|
|
252
|
+
"4-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
|
|
238
253
|
});
|
|
239
|
-
const
|
|
254
|
+
const replaceMatchMatchSchema = Type.String({
|
|
240
255
|
description:
|
|
241
|
-
|
|
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.",
|
|
242
257
|
});
|
|
243
|
-
const
|
|
258
|
+
const replaceMatchReplacementSchema = Type.String({
|
|
244
259
|
description:
|
|
245
|
-
|
|
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.`,
|
|
246
261
|
});
|
|
247
262
|
|
|
248
263
|
export const replaceMatchToolSchema = Type.Object(
|
|
249
264
|
{
|
|
250
265
|
replace_from: replaceMatchFromSchema,
|
|
251
266
|
replace_to: replaceMatchToSchema,
|
|
252
|
-
|
|
253
|
-
|
|
267
|
+
match: replaceMatchMatchSchema,
|
|
268
|
+
replacement: replaceMatchReplacementSchema,
|
|
254
269
|
},
|
|
255
270
|
{ additionalProperties: true },
|
|
256
271
|
);
|
|
@@ -259,11 +274,11 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
|
|
|
259
274
|
if (!requirePath) return replaceMatchToolSchema;
|
|
260
275
|
return Type.Object(
|
|
261
276
|
{
|
|
262
|
-
path:
|
|
277
|
+
path: pathRequiredSchema,
|
|
263
278
|
replace_from: replaceMatchFromSchema,
|
|
264
279
|
replace_to: replaceMatchToSchema,
|
|
265
|
-
|
|
266
|
-
|
|
280
|
+
match: replaceMatchMatchSchema,
|
|
281
|
+
replacement: replaceMatchReplacementSchema,
|
|
267
282
|
},
|
|
268
283
|
{ additionalProperties: true },
|
|
269
284
|
) as typeof replaceMatchToolSchema;
|
package/src/read.ts
CHANGED
|
@@ -320,7 +320,7 @@ function buildReadToolSchema(codemode: boolean) {
|
|
|
320
320
|
limit: Type.Optional(
|
|
321
321
|
Type.Integer({
|
|
322
322
|
minimum: 1,
|
|
323
|
-
description: "Maximum number of lines to read from offset",
|
|
323
|
+
description: "Maximum number of lines to read from offset; counted from the anchor row when `offset` is an anchor",
|
|
324
324
|
}),
|
|
325
325
|
),
|
|
326
326
|
...(codemode
|
package/src/replace-match.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
): { text: string;
|
|
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 =
|
|
58
|
+
const found = line.indexOf(matchText, cursor);
|
|
72
59
|
if (found < 0) break;
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
192
|
-
if (
|
|
193
|
-
const prefix = commonPrefixLength(
|
|
194
|
-
const suffix = commonSuffixLength(
|
|
195
|
-
const comparable = Math.min(
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
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
|
|
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(
|
|
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
|
|
210
|
-
: "Copy only the fragment that changes from the served row (
|
|
211
|
-
return `[E_SUBSTRING_NOT_FOUND] "
|
|
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
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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,
|
|
172
|
+
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
|
|
241
173
|
}
|
|
242
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
330
|
+
prefixWarnings: [...warnings, ...hints],
|
|
411
331
|
signal,
|
|
412
332
|
verb: "replaced",
|
|
413
333
|
noopNoun: "Replacement",
|
package/src/replace-response.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { HASH_SEP, type NEdit } from "./hashline";
|
|
|
2
2
|
import type { ReplaceDetails } from "./replace";
|
|
3
3
|
import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
|
|
4
4
|
import { visLines, clipLine } from "./utils";
|
|
5
|
-
import { annotateLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
|
|
5
|
+
import { annotateLiteralEscapeHints, dedupeLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
|
|
6
6
|
import { anchoredLine, anchoredLinesFromDiff, diffAnchorsOmitted, toEditVerb, type EditStructured, type EditVerb, type Json } from "./structured";
|
|
7
7
|
|
|
8
8
|
export type TResult = {
|
|
@@ -164,7 +164,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
164
164
|
});
|
|
165
165
|
const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
|
|
166
166
|
const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
|
|
167
|
-
const
|
|
167
|
+
const dedupedHints = dedupeLiteralEscapeHints(annotatedHints);
|
|
168
|
+
const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(dedupedHints)}`;
|
|
168
169
|
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
169
170
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
170
171
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
@@ -203,7 +204,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
203
204
|
text,
|
|
204
205
|
diff: diffResult.diff,
|
|
205
206
|
warnings: [...noticeWarnings],
|
|
206
|
-
hints: [...
|
|
207
|
+
hints: [...dedupedHints],
|
|
207
208
|
firstChangedLine: editMeta.firstChangedLine ?? diffResult.firstChangedLine ?? null,
|
|
208
209
|
anchors,
|
|
209
210
|
anchorsOmitted,
|
|
@@ -220,7 +221,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
220
221
|
metrics,
|
|
221
222
|
diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
|
|
222
223
|
...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
|
|
223
|
-
...(
|
|
224
|
+
...(dedupedHints.length ? { hints: [...dedupedHints] } : {}),
|
|
224
225
|
},
|
|
225
226
|
structuredContent,
|
|
226
227
|
};
|
package/tool-prompts/copy.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Duplicate a range of lines at another anchor
|
|
1
|
+
Duplicate a range of lines at another anchor. The source rows and anchors stay live; the copied lines get fresh anchors in the destination's post-edit diff and keep their source line endings.
|
|
2
2
|
|
|
3
3
|
A copy duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
package/tool-prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read
|
|
1
|
+
Search text files with ripgrep. Hits come back as `lineNumber:│ anchor│content` rows and context lines as `lineNumber-│ anchor│content`, editable with replace, insert, copy, or move without a new read. A row returned by `grep` is already a served row, so you do not need to `read` the file to edit that line. The `=== path ===` header and line numbers locate the match. Patterns use ripgrep's Rust regex syntax (no backreferences or look-around) through a linear-time engine. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When output is cut by the match limit, raise `limit`; when cut by rows or bytes, refine `pattern` or narrow `path`.
|
package/tool-prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert text
|
|
1
|
+
Insert text next to an existing line without removing anything. The anchor line stays in place, and `anchor` is a bare 4-character anchor from any served `anchor│content` row.
|
package/tool-prompts/move.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Relocate a range of lines to another anchor
|
|
1
|
+
Relocate a range of lines to another anchor. Within one file the destination must sit outside the source range; moving a range onto itself reports `No changes made` and leaves the anchors alone. Rows between the source and the destination keep their content but may be re-anchored, so read that span again if you plan to edit it.
|
|
2
2
|
|
|
3
3
|
A cross-file move commits on its own when its source file also has batched edits in the message.
|
|
@@ -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 `
|
|
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`: `
|
|
2
|
-
- `replace_match`:
|
|
3
|
-
- `replace_match`: when the change covers
|
|
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 `
|
|
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
|
|
1
|
+
Replace part of a line — or the same fragment on several lines of a range — without retyping the rest. `match` is the exact text to find inside the anchored range, and `replacement` replaces every occurrence; the text around each match is left untouched. Send only the fragment that changes — never text that `replacement` leaves unchanged — and narrow `replace_from`/`replace_to` when only one occurrence should change. `match` only needs to be unique inside the anchored range, not file-wide. For a whole-line rewrite, use `replace` with the same anchors.
|
|
2
2
|
|
|
3
|
-
The two boundary anchors must still match what was last shown; lines strictly inside the range are
|
|
3
|
+
The two boundary anchors must still match what was last shown; lines strictly inside the range are searched as they are on disk.
|
package/tool-prompts/replace.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file by anchor
|
|
1
|
+
Replace a range of lines (or a single line) in a text file by anchor. Deleting every line empties the file, and the result names the empty-line anchor, so a follow-up `replace` can seed content without a `read`.
|
|
2
2
|
|
|
3
3
|
To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
- `copy`/`move`: only the
|
|
1
|
+
- `copy`/`move`: only the first and last marker rows and the destination line need anchors — grep the markers, the body is copied; to append to the end of a file, anchor that file's last line with `direction: "after"`.
|
|
2
2
|
- `copy`/`move`: within one file the insertion point must fall before the source range's first line or after its last; anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) targets the gap right beside the block.
|
|
3
3
|
- `copy`/`move`: `source_from` and `source_to` may live in a different file than `anchor`; an empty destination file is seeded with the copied or moved lines.
|
|
4
4
|
- `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
|