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.
@@ -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
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
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
- if ("remove_from" in edit && typeof edit.remove_from !== "string") {
122
- throw new Error(
123
- `[E_BAD_SHAPE] Field "remove_from" must be an anchor string (4-char anchor).`,
124
- );
125
- }
126
- if ("remove_to" in edit && typeof edit.remove_to !== "string") {
127
- throw new Error(
128
- `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (4-char anchor).`,
129
- );
130
- }
131
- if (!("replacement_lines" in edit)) {
132
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
133
- }
134
- if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
135
- throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
136
- }
137
- if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
138
- throw new Error(
139
- `[E_BAD_SHAPE] The edit requires "remove_from" and "remove_to" anchor strings (4-char anchors from read output).`,
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
- assertItem(edit as Record<string, unknown>);
165
-
166
- const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
167
- const replaceLines = parsed.lines;
168
- assertNoNul(replaceLines);
169
- const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
170
- return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
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 ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
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, parseTextWithSeparators, resEdit, resolveAnchorLine, type Anchor, type HEdit, type StripWarningLocation } from "./hashline";
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 { decodeStringArray, isRec, literalEscapeHint, splitLines } from "./utils";
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
- 'Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
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
- const insertLinesSchema = Type.Array(
30
- Type.String({
31
- description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings.",
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. The anchor still resolves the target.",
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: ReqParams; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
78
- const parsed = parseTextWithSeparators(req.lines);
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: ReqParams = {
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([...previewFixes, ...previewAnchorWarnings]);
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[] } | null {
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
- !Array.isArray(normalized.lines) ||
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 as string[],
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 literalEscape = literalEscapeHint(req.lines, "lines");
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,
@@ -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