pi-hashline-edit-pro 4.5.3 → 5.0.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 +40 -24
- package/index.ts +13 -1
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -1
- package/prompts/replace-guidelines.md +2 -4
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace-within-guidelines.md +3 -0
- package/prompts/replace-within-snippet.md +1 -0
- package/prompts/replace-within.md +5 -0
- package/prompts/replace.md +3 -2
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +6 -0
- package/src/copy-move.ts +11 -11
- package/src/edit-common.ts +66 -15
- package/src/edit-fidelity.ts +108 -0
- package/src/grep.ts +6 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +7 -0
- package/src/hashline/resolve.ts +65 -38
- package/src/insert.ts +18 -33
- package/src/payload-contract.ts +155 -28
- package/src/replace-render.ts +4 -4
- package/src/replace-response.ts +3 -1
- package/src/replace-within.ts +227 -0
- package/src/replace.ts +8 -13
- package/src/utils.ts +28 -14
- package/prompts/grep-guidelines.md +0 -2
package/src/payload-contract.ts
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
|
|
2
|
+
import { isRec, normalizeFilePath, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
|
|
3
|
+
import { LINES_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
|
|
3
4
|
|
|
4
|
-
const replacementLinesSchema = Type.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}),
|
|
9
|
-
{
|
|
10
|
-
description:
|
|
11
|
-
"One string per line. Use [] to delete the range; [\"\"] is a single blank line.",
|
|
12
|
-
},
|
|
13
|
-
);
|
|
5
|
+
const replacementLinesSchema = Type.String({
|
|
6
|
+
description:
|
|
7
|
+
'The exact text to write in place of the removed range. "" deletes the range, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line.',
|
|
8
|
+
});
|
|
14
9
|
|
|
15
10
|
const removeFromSchema = Type.String({
|
|
16
11
|
description:
|
|
17
|
-
"
|
|
12
|
+
"4-char anchor of the FIRST line to remove (never the row content).",
|
|
18
13
|
});
|
|
19
14
|
|
|
20
15
|
const removeToSchema = Type.String({
|
|
21
16
|
description:
|
|
22
|
-
"
|
|
17
|
+
"4-char anchor of the LAST line to remove.",
|
|
23
18
|
});
|
|
24
19
|
const pathRequiredSchema = Type.String({
|
|
25
20
|
description:
|
|
26
|
-
"Path to the file the anchors were served for; required and must match anchor ownership.
|
|
21
|
+
"Path to the file the anchors were served for; required and must match anchor ownership.",
|
|
27
22
|
});
|
|
28
23
|
|
|
29
24
|
export const editToolSchema = Type.Object(
|
|
@@ -52,7 +47,14 @@ export type ReqParams = {
|
|
|
52
47
|
path?: string;
|
|
53
48
|
remove_from: string;
|
|
54
49
|
remove_to: string;
|
|
55
|
-
replacement_lines: string
|
|
50
|
+
replacement_lines: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type RawReqParams = {
|
|
54
|
+
path?: string;
|
|
55
|
+
remove_from: string;
|
|
56
|
+
remove_to: string;
|
|
57
|
+
replacement_lines: string | string[];
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
|
|
@@ -66,20 +68,21 @@ export function assertReq(request: unknown): asserts request is ReqParams {
|
|
|
66
68
|
}
|
|
67
69
|
if (
|
|
68
70
|
typeof request.remove_from !== "string" ||
|
|
69
|
-
typeof request.remove_to !== "string"
|
|
70
|
-
!Array.isArray(request.replacement_lines) ||
|
|
71
|
-
request.replacement_lines.some((line) => typeof line !== "string")
|
|
71
|
+
typeof request.remove_to !== "string"
|
|
72
72
|
) {
|
|
73
73
|
throw new Error(
|
|
74
|
-
'[E_BAD_SHAPE] Edit request requires "remove_from"
|
|
74
|
+
'[E_BAD_SHAPE] Edit request requires "remove_from" and "remove_to" anchor strings and a "replacement_lines" string with the exact text to write.',
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
if (typeof request.replacement_lines !== "string") {
|
|
78
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
79
|
+
}
|
|
80
|
+
assertNoNul([request.replacement_lines]);
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export { normalizeRequest as normReq } from "./utils";
|
|
81
84
|
|
|
82
|
-
export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; replacement_lines: string
|
|
85
|
+
export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; replacement_lines: string } | null {
|
|
83
86
|
let normalized: unknown;
|
|
84
87
|
try {
|
|
85
88
|
normalized = normalizeRequest(args);
|
|
@@ -90,8 +93,7 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
|
|
|
90
93
|
if (
|
|
91
94
|
typeof normalized.remove_from !== "string" ||
|
|
92
95
|
typeof normalized.remove_to !== "string" ||
|
|
93
|
-
|
|
94
|
-
normalized.replacement_lines.some((line) => typeof line !== "string")
|
|
96
|
+
typeof normalized.replacement_lines !== "string"
|
|
95
97
|
) {
|
|
96
98
|
return null;
|
|
97
99
|
}
|
|
@@ -99,7 +101,7 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
|
|
|
99
101
|
...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
|
|
100
102
|
remove_from: normalized.remove_from as string,
|
|
101
103
|
remove_to: normalized.remove_to as string,
|
|
102
|
-
replacement_lines: normalized.replacement_lines
|
|
104
|
+
replacement_lines: normalized.replacement_lines,
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -109,7 +111,7 @@ export interface InsertReq {
|
|
|
109
111
|
path?: string;
|
|
110
112
|
anchor: string;
|
|
111
113
|
direction: "before" | "after";
|
|
112
|
-
lines: string
|
|
114
|
+
lines: string;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
@@ -126,10 +128,10 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
126
128
|
if (request.direction !== "before" && request.direction !== "after") {
|
|
127
129
|
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
128
130
|
}
|
|
129
|
-
if (
|
|
130
|
-
throw new Error(
|
|
131
|
+
if (typeof request.lines !== "string") {
|
|
132
|
+
throw new Error(LINES_NOT_STRING_MSG);
|
|
131
133
|
}
|
|
132
|
-
assertNoNul(request.lines);
|
|
134
|
+
assertNoNul([request.lines]);
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "insert_after"]);
|
|
@@ -159,3 +161,128 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
|
|
|
159
161
|
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "insert_after" string (4-char anchor from read output).');
|
|
160
162
|
}
|
|
161
163
|
}
|
|
164
|
+
|
|
165
|
+
const WITHIN_KS = new Set(["path", "replace_from", "replace_to", "replace_old", "replace_new"]);
|
|
166
|
+
|
|
167
|
+
export interface ReplaceWithinReq {
|
|
168
|
+
path?: string;
|
|
169
|
+
replace_from: string;
|
|
170
|
+
replace_to: string;
|
|
171
|
+
replace_old: string;
|
|
172
|
+
replace_new: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function assertReplaceWithinReq(request: unknown): asserts request is ReplaceWithinReq {
|
|
176
|
+
if (!isRec(request)) {
|
|
177
|
+
throw new Error("[E_BAD_SHAPE] Replace-within request must be an object.");
|
|
178
|
+
}
|
|
179
|
+
rejectUnknownFields(request, WITHIN_KS, "Replace-within request");
|
|
180
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
181
|
+
throw new Error('[E_BAD_SHAPE] Replace-within request field "path" must be a string when provided.');
|
|
182
|
+
}
|
|
183
|
+
for (const key of ["replace_from", "replace_to", "replace_old", "replace_new"] as const) {
|
|
184
|
+
if (typeof (request as Record<string, unknown>)[key] !== "string") {
|
|
185
|
+
throw new Error(`[E_BAD_SHAPE] Replace-within request requires a "${key}" string.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const within = request as unknown as ReplaceWithinReq;
|
|
189
|
+
if (within.replace_from.length === 0 || within.replace_to.length === 0) {
|
|
190
|
+
throw new Error('[E_BAD_SHAPE] Replace-within request requires non-empty "replace_from" and "replace_to" anchors.');
|
|
191
|
+
}
|
|
192
|
+
if (within.replace_old.length === 0) {
|
|
193
|
+
throw new Error('[E_BAD_SHAPE] Replace-within request field "replace_old" must be a non-empty string holding the exact text to find.');
|
|
194
|
+
}
|
|
195
|
+
assertNoNul([within.replace_new]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const WITHIN_ANCHOR_ALIASES: Array<[string, "replace_from" | "replace_to"]> = [
|
|
199
|
+
["replace_from", "replace_from"],
|
|
200
|
+
["remove_from", "replace_from"],
|
|
201
|
+
["from", "replace_from"],
|
|
202
|
+
["replace_to", "replace_to"],
|
|
203
|
+
["remove_to", "replace_to"],
|
|
204
|
+
["to", "replace_to"],
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
export function normalizeReplaceWithinRequest(input: unknown): unknown {
|
|
208
|
+
if (!isRec(input)) return input;
|
|
209
|
+
const record: Record<string, unknown> = { ...input };
|
|
210
|
+
normalizeFilePath(record);
|
|
211
|
+
for (const [alias, canonical] of WITHIN_ANCHOR_ALIASES) {
|
|
212
|
+
if (typeof record[canonical] !== "string" && typeof record[alias] === "string") {
|
|
213
|
+
record[canonical] = record[alias];
|
|
214
|
+
}
|
|
215
|
+
if (alias !== canonical) delete record[alias];
|
|
216
|
+
}
|
|
217
|
+
return record;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getReplaceWithinInput(args: unknown): { path?: string; replace_from: string; replace_to: string; replace_old: string; replace_new: string } | null {
|
|
221
|
+
let normalized: unknown;
|
|
222
|
+
try {
|
|
223
|
+
normalized = normalizeReplaceWithinRequest(args);
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (!isRec(normalized)) return null;
|
|
228
|
+
if (
|
|
229
|
+
typeof normalized.replace_from !== "string" ||
|
|
230
|
+
typeof normalized.replace_to !== "string" ||
|
|
231
|
+
typeof normalized.replace_old !== "string" ||
|
|
232
|
+
typeof normalized.replace_new !== "string"
|
|
233
|
+
) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
|
|
238
|
+
replace_from: normalized.replace_from,
|
|
239
|
+
replace_to: normalized.replace_to,
|
|
240
|
+
replace_old: normalized.replace_old,
|
|
241
|
+
replace_new: normalized.replace_new,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const replaceWithinFromSchema = Type.String({
|
|
246
|
+
description:
|
|
247
|
+
"4-char anchor of the FIRST line of the range searched (never the row content).",
|
|
248
|
+
});
|
|
249
|
+
const replaceWithinToSchema = Type.String({
|
|
250
|
+
description:
|
|
251
|
+
"4-char anchor of the LAST line of the range searched; same as replace_from for a single line.",
|
|
252
|
+
});
|
|
253
|
+
const replaceWithinOldSchema = Type.String({
|
|
254
|
+
description:
|
|
255
|
+
"The exact text to find inside the selected line(s), copied from the served row. It must occur exactly once; the text around it is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
|
|
256
|
+
});
|
|
257
|
+
const replaceWithinNewSchema = Type.String({
|
|
258
|
+
description:
|
|
259
|
+
'The exact replacement for the matched text. "" deletes the match, "\\n" inserts one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line; the rest of the range is kept byte-for-byte.',
|
|
260
|
+
});
|
|
261
|
+
const replaceWithinPathRequiredSchema = Type.String({
|
|
262
|
+
description:
|
|
263
|
+
"Path to the file the anchors were served for; required and must match anchor ownership.",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export const replaceWithinToolSchema = Type.Object(
|
|
267
|
+
{
|
|
268
|
+
replace_from: replaceWithinFromSchema,
|
|
269
|
+
replace_to: replaceWithinToSchema,
|
|
270
|
+
replace_old: replaceWithinOldSchema,
|
|
271
|
+
replace_new: replaceWithinNewSchema,
|
|
272
|
+
},
|
|
273
|
+
{ additionalProperties: true },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
export function buildReplaceWithinToolSchema(requirePath: boolean): typeof replaceWithinToolSchema {
|
|
277
|
+
if (!requirePath) return replaceWithinToolSchema;
|
|
278
|
+
return Type.Object(
|
|
279
|
+
{
|
|
280
|
+
path: replaceWithinPathRequiredSchema,
|
|
281
|
+
replace_from: replaceWithinFromSchema,
|
|
282
|
+
replace_to: replaceWithinToSchema,
|
|
283
|
+
replace_old: replaceWithinOldSchema,
|
|
284
|
+
replace_new: replaceWithinNewSchema,
|
|
285
|
+
},
|
|
286
|
+
{ additionalProperties: true },
|
|
287
|
+
) as typeof replaceWithinToolSchema;
|
|
288
|
+
}
|
package/src/replace-render.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function highlightBatchRefs(text: string, theme: FgT): string {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function fmtCall(
|
|
84
|
-
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | undefined,
|
|
84
|
+
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string } | undefined,
|
|
85
85
|
state: RRState,
|
|
86
86
|
expanded: boolean,
|
|
87
87
|
theme: CallT,
|
|
@@ -89,7 +89,7 @@ export function fmtCall(
|
|
|
89
89
|
): string {
|
|
90
90
|
const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
|
|
91
91
|
const path = args?.path ?? state.resolvedPath ?? previewPath;
|
|
92
|
-
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.source_from === "string" && typeof args?.insert_after === "string" ? `${args.source_from}→${args.insert_after}` : undefined;
|
|
92
|
+
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.source_from === "string" && typeof args?.insert_after === "string" ? `${args.source_from}→${args.insert_after}` : typeof args?.replace_from === "string" && typeof args?.replace_to === "string" ? `${args.replace_from}→${args.replace_to}` : undefined;
|
|
93
93
|
const pathDisplay =
|
|
94
94
|
typeof path === "string" && path.length > 0
|
|
95
95
|
? theme.fg("accent", path)
|
|
@@ -272,9 +272,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
272
272
|
export function makeRenderCall(
|
|
273
273
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
274
274
|
options: {
|
|
275
|
-
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | null;
|
|
275
|
+
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string } | null;
|
|
276
276
|
toolName?: string;
|
|
277
|
-
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string }) => string | undefined;
|
|
277
|
+
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string; replace_from?: string; replace_to?: string }) => string | undefined;
|
|
278
278
|
} = {},
|
|
279
279
|
) {
|
|
280
280
|
const getInput = options.getInput ?? getPreviewInput;
|
package/src/replace-response.ts
CHANGED
|
@@ -2,6 +2,7 @@ import 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 { fidelityHints } from "./edit-fidelity";
|
|
5
6
|
|
|
6
7
|
export type TResult = {
|
|
7
8
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -139,7 +140,8 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
139
140
|
const diffResult = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
|
|
140
141
|
const addedLines = editMeta.addedLines;
|
|
141
142
|
const removedLines = editMeta.removedLines;
|
|
142
|
-
const
|
|
143
|
+
const fidelity = fidelityHints(originalNormalized, result, spans);
|
|
144
|
+
const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
|
|
143
145
|
const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
|
|
144
146
|
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
145
147
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
|
|
4
|
+
import { commitEdit } from "./commit";
|
|
5
|
+
import { readNormFile, type NormFile } from "./file-reader";
|
|
6
|
+
import { fmtRegion, MAX_HASH_LINES, parseHashRef, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
7
|
+
import { withAnchorSession } from "./anchor-registry";
|
|
8
|
+
import { loadP, loadGuide } from "./prompts";
|
|
9
|
+
import {
|
|
10
|
+
assertReplaceWithinReq,
|
|
11
|
+
buildReplaceWithinToolSchema,
|
|
12
|
+
getReplaceWithinInput,
|
|
13
|
+
normalizeReplaceWithinRequest,
|
|
14
|
+
type ReplaceWithinReq,
|
|
15
|
+
} from "./payload-contract";
|
|
16
|
+
import { literalEscapeHint, splitLines } from "./utils";
|
|
17
|
+
import { toLF } from "./normalize";
|
|
18
|
+
import { MAX_RANGE_STALE_LINES } from "./constants";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_EDIT_FLAGS,
|
|
21
|
+
editRenderResultWrapper,
|
|
22
|
+
editToolBase,
|
|
23
|
+
queuedEdit,
|
|
24
|
+
resolveEditTargetWithRequirement,
|
|
25
|
+
throwIfStrictInput,
|
|
26
|
+
tryResolveEditTarget,
|
|
27
|
+
withReplaceWithinPrompts,
|
|
28
|
+
type EditToolFlags,
|
|
29
|
+
} from "./edit-common";
|
|
30
|
+
import { makeRenderCall, type RPreview, type RRState } from "./replace-render";
|
|
31
|
+
|
|
32
|
+
interface WithinRefs {
|
|
33
|
+
from: Anchor;
|
|
34
|
+
to: Anchor;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WithinPlan {
|
|
38
|
+
editParams: { remove_from: string; remove_to: string; replacement_lines: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatWithinRange(start: number, end: number): string {
|
|
42
|
+
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function offsetLineNumber(rangeLines: string[], offset: number): number {
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
for (let index = 0; index < rangeLines.length; index++) {
|
|
48
|
+
const length = rangeLines[index]!.length;
|
|
49
|
+
if (offset <= cursor + length) return index + 1;
|
|
50
|
+
cursor += length + 1;
|
|
51
|
+
}
|
|
52
|
+
return rangeLines.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchOffsets(text: string, oldText: string): number[] {
|
|
56
|
+
const offsets: number[] = [];
|
|
57
|
+
let from = 0;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const found = text.indexOf(oldText, from);
|
|
60
|
+
if (found < 0) return offsets;
|
|
61
|
+
offsets.push(found);
|
|
62
|
+
from = found + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[]): string {
|
|
67
|
+
const rangeLength = end - start + 1;
|
|
68
|
+
const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
|
|
69
|
+
const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
|
|
70
|
+
const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
|
|
71
|
+
return `[E_SUBSTRING_NOT_FOUND] "replace_old" was not found in ${formatWithinRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\nCopy replace_old exactly from the served row (comparison uses LF breaks and excludes the last line's terminator) and retry.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ambiguousMessage(displayPath: string, start: number, end: number, matchLines: number[]): string {
|
|
75
|
+
const shownCount = 8;
|
|
76
|
+
const shown = matchLines.slice(0, shownCount).join(", ");
|
|
77
|
+
const more = matchLines.length > shownCount ? ` (+${matchLines.length - shownCount} more)` : "";
|
|
78
|
+
return `[E_SUBSTRING_AMBIGUOUS] "replace_old" occurs ${matchLines.length} times in ${formatWithinRange(start, end)} of ${displayPath} (matching lines ${shown}${more}). Narrow replace_from/replace_to to one line, or extend replace_old so it matches exactly once.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseWithinAnchors(req: ReplaceWithinReq): { refs: WithinRefs; warnings: string[] } {
|
|
82
|
+
const warnings: string[] = [];
|
|
83
|
+
const from = stripAnchorRow(req.replace_from.trim(), "replace_from entry", warnings);
|
|
84
|
+
const to = stripAnchorRow(req.replace_to.trim(), "replace_to entry", warnings);
|
|
85
|
+
return { refs: { from: parseHashRef(from), to: parseHashRef(to) }, warnings };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildReplaceWithinEdit(req: ReplaceWithinReq, refs: WithinRefs, preload: NormFile, displayPath: string): WithinPlan {
|
|
89
|
+
const fileLines = splitLines(preload.normalized);
|
|
90
|
+
const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
|
|
91
|
+
const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
|
|
92
|
+
const start = Math.min(fromLine, toLine);
|
|
93
|
+
const end = Math.max(fromLine, toLine);
|
|
94
|
+
const rangeLines = fileLines.slice(start - 1, end);
|
|
95
|
+
const rangeText = rangeLines.join("\n");
|
|
96
|
+
const oldText = toLF(req.replace_old);
|
|
97
|
+
const offsets = matchOffsets(rangeText, oldText);
|
|
98
|
+
if (offsets.length === 0) {
|
|
99
|
+
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines));
|
|
100
|
+
}
|
|
101
|
+
if (offsets.length > 1) {
|
|
102
|
+
throw new Error(ambiguousMessage(displayPath, start, end, offsets.map((offset) => start - 1 + offsetLineNumber(rangeLines, offset))));
|
|
103
|
+
}
|
|
104
|
+
const matchOffset = offsets[0]!;
|
|
105
|
+
const replacement = rangeText.slice(0, matchOffset) + req.replace_new + rangeText.slice(matchOffset + oldText.length);
|
|
106
|
+
const startRef = fromLine <= toLine ? refs.from : refs.to;
|
|
107
|
+
const endRef = fromLine <= toLine ? refs.to : refs.from;
|
|
108
|
+
return {
|
|
109
|
+
editParams: {
|
|
110
|
+
remove_from: startRef.hash,
|
|
111
|
+
remove_to: endRef.hash,
|
|
112
|
+
replacement_lines: replacement,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function replaceWithinPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
118
|
+
try {
|
|
119
|
+
const normalized = normalizeReplaceWithinRequest(request);
|
|
120
|
+
assertReplaceWithinReq(normalized);
|
|
121
|
+
const req = normalized;
|
|
122
|
+
const { refs, warnings } = parseWithinAnchors(req);
|
|
123
|
+
await throwIfStrictInput(warnings);
|
|
124
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
125
|
+
removeFrom: req.replace_from,
|
|
126
|
+
removeTo: req.replace_to,
|
|
127
|
+
providedPath: req.path,
|
|
128
|
+
cwd,
|
|
129
|
+
});
|
|
130
|
+
const preload = await readNormFile(targetPath, cwd, {
|
|
131
|
+
accessMode: constants.R_OK,
|
|
132
|
+
maxLines: MAX_HASH_LINES,
|
|
133
|
+
noPersist: true,
|
|
134
|
+
allocation: "shadow",
|
|
135
|
+
signal,
|
|
136
|
+
});
|
|
137
|
+
const plan = buildReplaceWithinEdit(req, refs, preload, targetPath);
|
|
138
|
+
const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
|
|
139
|
+
accessMode: constants.R_OK,
|
|
140
|
+
noPersist: true,
|
|
141
|
+
preloadedNorm: preload,
|
|
142
|
+
signal,
|
|
143
|
+
});
|
|
144
|
+
return previewFromPipe(pipe);
|
|
145
|
+
} catch (error: unknown) {
|
|
146
|
+
if (signal?.aborted) throw error;
|
|
147
|
+
return previewError(error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type ReplaceWithinToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
152
|
+
|
|
153
|
+
export function buildReplaceWithinToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ReplaceWithinToolDef {
|
|
154
|
+
const prompted = withReplaceWithinPrompts({
|
|
155
|
+
description: loadP("../prompts/replace-within.md"),
|
|
156
|
+
snippet: loadP("../prompts/replace-within-snippet.md"),
|
|
157
|
+
guidelines: loadGuide("../prompts/replace-within-guidelines.md"),
|
|
158
|
+
}, flags);
|
|
159
|
+
return {
|
|
160
|
+
name: "replace_within",
|
|
161
|
+
label: "Replace Within",
|
|
162
|
+
description: prompted.description,
|
|
163
|
+
promptSnippet: prompted.snippet,
|
|
164
|
+
promptGuidelines: prompted.guidelines,
|
|
165
|
+
...editToolBase,
|
|
166
|
+
prepareArguments: normalizeReplaceWithinRequest,
|
|
167
|
+
parameters: buildReplaceWithinToolSchema(flags.requirePath),
|
|
168
|
+
renderCall: makeRenderCall(replaceWithinPreview, {
|
|
169
|
+
getInput: getReplaceWithinInput,
|
|
170
|
+
toolName: "replace_within",
|
|
171
|
+
resolveTarget: (input) => (typeof input.replace_from === "string" ? tryResolveEditTarget(input.replace_from, input.replace_to) : undefined),
|
|
172
|
+
}),
|
|
173
|
+
renderResult: editRenderResultWrapper,
|
|
174
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
175
|
+
return withAnchorSession(ctx, async () => {
|
|
176
|
+
const normalized = normalizeReplaceWithinRequest(params);
|
|
177
|
+
assertReplaceWithinReq(normalized);
|
|
178
|
+
const req = normalized;
|
|
179
|
+
const { refs, warnings } = parseWithinAnchors(req);
|
|
180
|
+
await throwIfStrictInput(warnings);
|
|
181
|
+
const hints = [
|
|
182
|
+
literalEscapeHint([req.replace_old], "replace_old"),
|
|
183
|
+
literalEscapeHint([req.replace_new], "replace_new"),
|
|
184
|
+
].filter((hint): hint is string => hint !== undefined);
|
|
185
|
+
const targetPath = await resolveEditTargetWithRequirement({
|
|
186
|
+
removeFrom: req.replace_from,
|
|
187
|
+
removeTo: req.replace_to,
|
|
188
|
+
providedPath: req.path,
|
|
189
|
+
cwd: ctx.cwd,
|
|
190
|
+
});
|
|
191
|
+
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
192
|
+
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
193
|
+
signal,
|
|
194
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
195
|
+
maxLines: MAX_HASH_LINES,
|
|
196
|
+
});
|
|
197
|
+
let plan: WithinPlan;
|
|
198
|
+
try {
|
|
199
|
+
plan = buildReplaceWithinEdit(req, refs, preload, targetPath);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await noteAnchorError(preload.absolutePath, error);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
|
|
205
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
206
|
+
signal,
|
|
207
|
+
preloadedNorm: preload,
|
|
208
|
+
});
|
|
209
|
+
return commitEdit(pipe, {
|
|
210
|
+
path: pipe.path,
|
|
211
|
+
absolutePath,
|
|
212
|
+
mutationTargetPath,
|
|
213
|
+
editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
|
|
214
|
+
prefixWarnings: [...warnings, ...hints],
|
|
215
|
+
signal,
|
|
216
|
+
verb: "replaced",
|
|
217
|
+
noopNoun: "Replacement",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function regReplaceWithin(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
226
|
+
pi.registerTool(buildReplaceWithinToolDef(flags));
|
|
227
|
+
}
|
package/src/replace.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type LineEnding,
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
|
-
import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
12
|
+
import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
13
|
import { literalEscapeHint, splitLines } from "./utils";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { type FileIdentity } from "./fs-write";
|
|
@@ -121,17 +121,12 @@ function countLineChanges(
|
|
|
121
121
|
totalRemovedLines,
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
|
-
|
|
125
|
-
export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: string[] } {
|
|
124
|
+
export function buildReplaceHEdit(params: RawReqParams): { edit: HEdit; warnings: string[] } {
|
|
126
125
|
const editWarnings: string[] = [];
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
replacement_lines: params.replacement_lines,
|
|
132
|
-
},
|
|
133
|
-
editWarnings,
|
|
134
|
-
);
|
|
126
|
+
const anchors = { remove_from: params.remove_from, remove_to: params.remove_to };
|
|
127
|
+
const edit = typeof params.replacement_lines === "string"
|
|
128
|
+
? resEdit({ ...anchors, replacement_lines: params.replacement_lines }, editWarnings)
|
|
129
|
+
: resEdit({ ...anchors, replacement_lines: params.replacement_lines }, editWarnings);
|
|
135
130
|
return { edit, warnings: editWarnings };
|
|
136
131
|
}
|
|
137
132
|
|
|
@@ -148,7 +143,7 @@ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[]
|
|
|
148
143
|
|
|
149
144
|
export async function execPipeline(
|
|
150
145
|
targetPath: string,
|
|
151
|
-
params:
|
|
146
|
+
params: RawReqParams,
|
|
152
147
|
cwd: string,
|
|
153
148
|
options?: ExecPipelineOptions,
|
|
154
149
|
): Promise<PipelineResult> {
|
|
@@ -290,7 +285,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
290
285
|
const canonical = normReq(params);
|
|
291
286
|
assertReq(canonical);
|
|
292
287
|
const normalizedParams = canonical;
|
|
293
|
-
const literalEscape = literalEscapeHint(normalizedParams.replacement_lines, "replacement_lines");
|
|
288
|
+
const literalEscape = literalEscapeHint([normalizedParams.replacement_lines], "replacement_lines");
|
|
294
289
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
295
290
|
removeFrom: normalizedParams.remove_from,
|
|
296
291
|
removeTo: normalizedParams.remove_to,
|
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NUL_CONTENT_MSG, MAX_BYTES } from "./constants";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
|
+
import { splitWithEndings } from "./line-endings";
|
|
4
|
+
import type { LineEnding } from "./normalize";
|
|
3
5
|
|
|
4
6
|
export function isRec(value: unknown): value is Record<string, unknown> {
|
|
5
7
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -342,24 +344,36 @@ export function decodeStringArray(value: unknown, warnings?: string[], label = "
|
|
|
342
344
|
return undefined;
|
|
343
345
|
}
|
|
344
346
|
|
|
345
|
-
function
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
347
|
+
function legacyLinesToText(lines: string[]): string {
|
|
348
|
+
const decoded = decodeStringArray(lines) ?? lines;
|
|
349
|
+
const parsedLines: string[] = [];
|
|
350
|
+
const parsedSeparators: (LineEnding | undefined)[] = [];
|
|
351
|
+
for (const element of decoded) {
|
|
352
|
+
const parsed = splitWithEndings(element);
|
|
353
|
+
const lineCount = element.endsWith("\n") || element.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
|
|
354
|
+
for (let index = 0; index < lineCount; index++) {
|
|
355
|
+
parsedLines.push(parsed.lines[index]!);
|
|
356
|
+
parsedSeparators.push(parsed.endings[index]);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
let text = "";
|
|
360
|
+
for (let index = 0; index < parsedLines.length; index++) {
|
|
361
|
+
const line = parsedLines[index]!;
|
|
362
|
+
const separator = parsedSeparators[index];
|
|
363
|
+
text += line;
|
|
364
|
+
if (separator !== undefined) text += separator;
|
|
365
|
+
else if (index < parsedLines.length - 1) text += "\n";
|
|
366
|
+
else if (line === "") text += "\n";
|
|
367
|
+
}
|
|
368
|
+
return text;
|
|
356
369
|
}
|
|
357
370
|
|
|
358
371
|
function normalizeEditLines(record: Record<string, unknown>): void {
|
|
359
372
|
for (const key of ["replacement_lines", "lines"]) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
373
|
+
const value = record[key];
|
|
374
|
+
if (Array.isArray(value) && value.every((entry): entry is string => typeof entry === "string")) {
|
|
375
|
+
record[key] = legacyLinesToText(value);
|
|
376
|
+
}
|
|
363
377
|
}
|
|
364
378
|
}
|
|
365
379
|
|