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.
@@ -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.Array(
5
- Type.String({
6
- description:
7
- "One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
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
- "Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the FIRST line to remove (inclusive)",
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
- "Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the LAST line to remove (inclusive)",
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. Anchors still resolve the target.",
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", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
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
- assertNoNul(request.replacement_lines);
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[] } | null {
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
- !Array.isArray(normalized.replacement_lines) ||
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 as string[],
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 (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
130
- throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
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
+ }
@@ -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;
@@ -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 { warnings: noticeWarnings, hints } = splitNotices(warnings);
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 edit = resEdit(
128
- {
129
- remove_from: params.remove_from,
130
- remove_to: params.remove_to,
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: ReqParams,
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 normalizeLineFieldValue(value: unknown): string[] | undefined {
346
- const candidate = typeof value === "string"
347
- ? value
348
- : Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
349
- ? value[0]
350
- : undefined;
351
- if (candidate === undefined) return undefined;
352
- const decoded = decodeStringArray(candidate);
353
- if (decoded !== undefined) return decoded;
354
- if (/^\[\s*\]$/.test(stripTrailingMemberCall(stripCodeFence(candidate)))) return [];
355
- return [candidate];
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
- if (!(key in record)) continue;
361
- const lines = normalizeLineFieldValue(record[key]);
362
- if (lines !== undefined) record[key] = lines;
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
 
@@ -1,2 +0,0 @@
1
- - `anchor_grep`: hits and `context` rows come back as `lineNumber │ anchor│content` — target them with `replace`/`insert` directly; `lineNumber` enables jump-to-line.
2
- - `anchor_grep`: binary/image files are skipped silently.