pi-hashline-edit-pro 4.5.2 → 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 +43 -25
- package/index.ts +15 -2
- 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/batch.ts +29 -7
- package/src/commit.ts +16 -13
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +6 -0
- package/src/copy-move.ts +15 -11
- package/src/edit-common.ts +66 -15
- package/src/edit-fidelity.ts +108 -0
- package/src/grep.ts +6 -5
- package/src/hashline/apply.ts +7 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +15 -4
- package/src/hashline/resolve.ts +67 -38
- package/src/insert.ts +21 -34
- 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 +26 -15
- package/src/utils.ts +28 -14
- package/prompts/grep-guidelines.md +0 -2
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, clipLine, decodeStringArray, assertNoNul } from "../utils";
|
|
2
|
-
import { parseHashRef, parseTextWithSeparators, type Anchor } from "./parse";
|
|
2
|
+
import { parseHashRef, parsePayloadText, parseTextWithSeparators, type Anchor, type ParsedText } from "./parse";
|
|
3
3
|
import { HASH_SEP, stripRowPrefix, lineChecksum, type RowPrefixKind } from "./hash";
|
|
4
4
|
import { HASH_RUN } from "./alphabet";
|
|
5
|
-
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
|
+
import { NEW_CONTENT_NOT_ARRAY_MSG, NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
6
6
|
import type { LineEnding } from "../normalize";
|
|
7
7
|
|
|
8
8
|
export type RAnchor = {
|
|
@@ -18,6 +18,7 @@ export type HEdit = {
|
|
|
18
18
|
export type RHEdit = {
|
|
19
19
|
content_lines: string[];
|
|
20
20
|
hash_bounds: [RAnchor, RAnchor];
|
|
21
|
+
content_separators?: (LineEnding | undefined)[];
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
interface HMismatch {
|
|
@@ -37,6 +38,12 @@ export type HTEdit = {
|
|
|
37
38
|
remove_to: string;
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
export type HTPayloadEdit = {
|
|
42
|
+
replacement_lines: string;
|
|
43
|
+
remove_from: string;
|
|
44
|
+
remove_to: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
40
47
|
function resAnchorFromMap(
|
|
41
48
|
ref: Anchor,
|
|
42
49
|
hashIndex: Map<string, number[]>,
|
|
@@ -115,30 +122,57 @@ export function fmtMismatchWithHashes(
|
|
|
115
122
|
|
|
116
123
|
const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
|
|
117
124
|
|
|
125
|
+
function assertBounds(edit: Record<string, unknown>): void {
|
|
126
|
+
if ("remove_from" in edit && typeof edit.remove_from !== "string") {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if ("remove_to" in edit && typeof edit.remove_to !== "string") {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
function assertItem(edit: Record<string, unknown>): void {
|
|
119
|
-
|
|
144
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
145
|
+
assertBounds(edit);
|
|
146
|
+
if (!("replacement_lines" in edit)) {
|
|
147
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
|
|
150
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
120
153
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
function assertPayloadItem(edit: Record<string, unknown>): void {
|
|
155
|
+
rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
|
|
156
|
+
assertBounds(edit);
|
|
157
|
+
if (!("replacement_lines" in edit)) {
|
|
158
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" string (use "" to delete).`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof edit.replacement_lines !== "string") {
|
|
161
|
+
throw new Error(NEW_CONTENT_NOT_STRING_MSG);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveParsedEdit(removeFrom: string, removeTo: string, parsed: ParsedText, warnings?: string[]): HEdit {
|
|
166
|
+
const replaceLines = parsed.lines;
|
|
167
|
+
assertNoNul(replaceLines);
|
|
168
|
+
const bounds = [removeFrom, removeTo].map((ref) => {
|
|
169
|
+
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
170
|
+
}) as [string, string];
|
|
171
|
+
return {
|
|
172
|
+
content_lines: replaceLines,
|
|
173
|
+
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
174
|
+
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
175
|
+
};
|
|
142
176
|
}
|
|
143
177
|
|
|
144
178
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
@@ -160,20 +194,14 @@ export function stripAnchorRow(
|
|
|
160
194
|
return match[2]!;
|
|
161
195
|
}
|
|
162
196
|
|
|
163
|
-
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}) as [string, string];
|
|
172
|
-
return {
|
|
173
|
-
content_lines: replaceLines,
|
|
174
|
-
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
175
|
-
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
176
|
-
};
|
|
197
|
+
export function resEdit(edit: HTEdit | HTPayloadEdit, warnings?: string[]): HEdit {
|
|
198
|
+
if (typeof edit.replacement_lines === "string") {
|
|
199
|
+
assertPayloadItem(edit as unknown as Record<string, unknown>);
|
|
200
|
+
return resolveParsedEdit(edit.remove_from, edit.remove_to, parsePayloadText(edit.replacement_lines), warnings);
|
|
201
|
+
}
|
|
202
|
+
assertItem(edit as Record<string, unknown>);
|
|
203
|
+
const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
|
|
204
|
+
return resolveParsedEdit(edit.remove_from, edit.remove_to, parsed, warnings);
|
|
177
205
|
}
|
|
178
206
|
|
|
179
207
|
function warnUnicodeEsc(
|
|
@@ -288,6 +316,7 @@ export function valEdit(
|
|
|
288
316
|
resolved: {
|
|
289
317
|
content_lines: edit.content_lines,
|
|
290
318
|
hash_bounds: [startResolved, endResolved],
|
|
319
|
+
...(edit.content_separators !== undefined ? { content_separators: edit.content_separators } : {}),
|
|
291
320
|
},
|
|
292
321
|
mismatches,
|
|
293
322
|
};
|
package/src/insert.ts
CHANGED
|
@@ -1,43 +1,38 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { execPipeline, type
|
|
4
|
+
import { execPipeline, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
6
|
import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { MAX_HASH_LINES, parseHashRef,
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, parsePayloadText, resEdit, resolveAnchorLine, type Anchor, type HEdit, type HTEdit, type StripWarningLocation } from "./hashline";
|
|
9
9
|
import type { LineEnding } from "./normalize";
|
|
10
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
-
import {
|
|
14
|
+
import { isRec, literalEscapeHint, splitLines } from "./utils";
|
|
15
15
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
16
16
|
import type { RPreview, RRState } from "./replace-render";
|
|
17
17
|
export { assertInsertReq, type InsertReq };
|
|
18
18
|
|
|
19
19
|
const insertAnchorSchema = Type.String({
|
|
20
20
|
description:
|
|
21
|
-
|
|
21
|
+
"4-char anchor of the line to insert next to (never the row content). A pasted diff row is stripped with a warning; the anchor line is preserved.",
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const insertDirectionSchema = Type.Union(
|
|
25
25
|
[Type.Literal("after"), Type.Literal("before")],
|
|
26
26
|
{ description: '"after" or "before"' },
|
|
27
27
|
);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}),
|
|
33
|
-
{
|
|
34
|
-
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
35
|
-
}
|
|
36
|
-
);
|
|
28
|
+
const insertLinesSchema = Type.String({
|
|
29
|
+
description:
|
|
30
|
+
'The exact text to insert. "" inserts nothing, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor line.',
|
|
31
|
+
});
|
|
37
32
|
|
|
38
33
|
const insertPathRequiredSchema = Type.String({
|
|
39
34
|
description:
|
|
40
|
-
"Path to the file the anchor was served for; required and must match anchor ownership.
|
|
35
|
+
"Path to the file the anchor was served for; required and must match anchor ownership.",
|
|
41
36
|
});
|
|
42
37
|
|
|
43
38
|
const insertToolSchema = Type.Object(
|
|
@@ -74,12 +69,12 @@ export function buildInsertEdit(
|
|
|
74
69
|
preload: NormFile,
|
|
75
70
|
ref: Anchor,
|
|
76
71
|
path: string,
|
|
77
|
-
): { editParams:
|
|
78
|
-
const parsed =
|
|
72
|
+
): { editParams: HTEdit; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
|
|
73
|
+
const parsed = parsePayloadText(req.lines);
|
|
79
74
|
const fileLines = splitLines(preload.normalized);
|
|
80
75
|
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
|
|
81
76
|
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
82
|
-
const editParams:
|
|
77
|
+
const editParams: HTEdit = {
|
|
83
78
|
remove_from: ref.hash,
|
|
84
79
|
remove_to: ref.hash,
|
|
85
80
|
replacement_lines:
|
|
@@ -104,15 +99,10 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
|
|
|
104
99
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
105
100
|
try {
|
|
106
101
|
const normalized = normReq(request);
|
|
107
|
-
const previewFixes: string[] = [];
|
|
108
|
-
if (isRec(normalized)) {
|
|
109
|
-
const expanded = decodeStringArray(normalized.lines, previewFixes, "lines");
|
|
110
|
-
if (expanded) normalized.lines = expanded;
|
|
111
|
-
}
|
|
112
102
|
assertInsertReq(normalized);
|
|
113
103
|
const previewReq = normalized as InsertReq;
|
|
114
104
|
const { ref, warnings: previewAnchorWarnings } = parseInsertAnchor(previewReq.anchor);
|
|
115
|
-
await throwIfStrictInput(
|
|
105
|
+
await throwIfStrictInput(previewAnchorWarnings);
|
|
116
106
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
117
107
|
anchor: previewReq.anchor,
|
|
118
108
|
providedPath: previewReq.path,
|
|
@@ -125,13 +115,14 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
125
115
|
allocation: "shadow",
|
|
126
116
|
signal,
|
|
127
117
|
});
|
|
128
|
-
const { editParams, anchorLine } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
118
|
+
const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
129
119
|
const pipe = await execPipeline(targetPath, editParams, cwd, {
|
|
130
120
|
accessMode: constants.R_OK,
|
|
131
121
|
noPersist: true,
|
|
132
122
|
preloadedNorm: preload,
|
|
133
123
|
signal,
|
|
134
124
|
stripWarning: insertStripWarning(anchorLine, normalized.direction),
|
|
125
|
+
endingOverrides: contentSeparators,
|
|
135
126
|
});
|
|
136
127
|
return previewFromPipe(pipe);
|
|
137
128
|
} catch (error: unknown) {
|
|
@@ -140,7 +131,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
140
131
|
}
|
|
141
132
|
}
|
|
142
133
|
|
|
143
|
-
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string
|
|
134
|
+
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string } | null {
|
|
144
135
|
let normalized: unknown;
|
|
145
136
|
try {
|
|
146
137
|
normalized = normReq(args);
|
|
@@ -151,8 +142,7 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
151
142
|
if (
|
|
152
143
|
typeof normalized.anchor !== "string" ||
|
|
153
144
|
(normalized.direction !== "before" && normalized.direction !== "after") ||
|
|
154
|
-
|
|
155
|
-
normalized.lines.some((line) => typeof line !== "string")
|
|
145
|
+
typeof normalized.lines !== "string"
|
|
156
146
|
) {
|
|
157
147
|
return null;
|
|
158
148
|
}
|
|
@@ -160,7 +150,7 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
|
|
|
160
150
|
...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
|
|
161
151
|
anchor: normalized.anchor as string,
|
|
162
152
|
direction: normalized.direction as "before" | "after",
|
|
163
|
-
lines: normalized.lines
|
|
153
|
+
lines: normalized.lines,
|
|
164
154
|
};
|
|
165
155
|
}
|
|
166
156
|
|
|
@@ -185,14 +175,10 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
185
175
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
186
176
|
return withAnchorSession(ctx, async () => {
|
|
187
177
|
const canonical = normReq(params);
|
|
188
|
-
const insertWarnings: string[] = [];
|
|
189
|
-
if (isRec(canonical)) {
|
|
190
|
-
const expanded = decodeStringArray(canonical.lines, insertWarnings, "lines");
|
|
191
|
-
if (expanded) canonical.lines = expanded;
|
|
192
|
-
}
|
|
193
178
|
assertInsertReq(canonical);
|
|
194
179
|
const req = canonical;
|
|
195
|
-
const
|
|
180
|
+
const insertWarnings: string[] = [];
|
|
181
|
+
const literalEscape = literalEscapeHint([req.lines], "lines");
|
|
196
182
|
if (literalEscape !== undefined) insertWarnings.push(literalEscape);
|
|
197
183
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
198
184
|
anchor: req.anchor,
|
|
@@ -253,6 +239,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
253
239
|
signal,
|
|
254
240
|
preloadedNorm: preload,
|
|
255
241
|
stripWarning: insertStripWarning(anchorLine, req.direction),
|
|
242
|
+
endingOverrides: contentSeparators,
|
|
256
243
|
});
|
|
257
244
|
return commitEdit(pipe, {
|
|
258
245
|
path: pipe.path,
|
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
|