pi-hashline-edit-pro 6.4.7 → 6.5.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,10 +1,9 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { constants } from "node:fs";
3
- import * as Diff from "diff";
4
3
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
5
4
  import { commitEdit } from "./commit";
6
5
  import { readNormFile, type NormFile } from "./file-reader";
7
- import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
6
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
8
7
  import { servedForPath, withAnchorSession } from "./anchor-registry";
9
8
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
10
9
  import { loadP, loadGuidelines } from "./prompts";
@@ -13,13 +12,13 @@ import {
13
12
  buildReplaceMatchToolSchema,
14
13
  getReplaceMatchInput,
15
14
  normReq,
15
+ singleLineFragment,
16
16
  type ReplaceMatchReq,
17
17
  } from "./payload-contract";
18
18
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
19
- import { toLF, type LineEnding } from "./normalize";
20
- import { isFidelitySensitiveChar } from "./edit-fidelity";
21
- import { countNewlines, splitWithEndings } from "./line-endings";
19
+ import { type LineEnding } from "./normalize";
22
20
  import { MAX_RANGE_STALE_LINES } from "./constants";
21
+ import { formatCharEscape, isFidelitySensitiveChar } from "./edit-fidelity";
23
22
  import {
24
23
  assertBoundaryLinesServed,
25
24
  DEFAULT_EDIT_FLAGS,
@@ -43,73 +42,43 @@ interface MatchRefs {
43
42
 
44
43
  export interface MatchPlan {
45
44
  editParams: { remove_from: string; remove_to: string; text: string };
46
- warnings: string[];
47
45
  contentSeparators: (LineEnding | undefined)[];
48
46
  servedOverride?: ReadonlyMap<string, string>;
49
47
  }
50
48
 
51
- function formatMatchRange(start: number, end: number): string {
52
- return start === end ? `line ${start}` : `lines ${start}-${end}`;
53
- }
54
-
55
49
  function replaceAllOccurrences(
56
- text: string,
57
- oldText: string,
58
- newText: string,
59
- ): { text: string; separators: (LineEnding | undefined)[]; count: number } {
50
+ line: string,
51
+ matchText: string,
52
+ replacementText: string,
53
+ ): { text: string; count: number } {
60
54
  const parts: string[] = [];
61
- const inserted: Array<{ start: number; end: number }> = [];
62
- let length = 0;
63
- const append = (chunk: string, fromNewText: boolean): void => {
64
- if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
65
- parts.push(chunk);
66
- length += chunk.length;
67
- };
68
55
  let cursor = 0;
69
56
  let count = 0;
70
57
  for (;;) {
71
- const found = text.indexOf(oldText, cursor);
58
+ const found = line.indexOf(matchText, cursor);
72
59
  if (found < 0) break;
73
- append(text.slice(cursor, found), false);
74
- append(newText, true);
75
- cursor = found + oldText.length;
60
+ parts.push(line.slice(cursor, found), replacementText);
61
+ cursor = found + matchText.length;
76
62
  count += 1;
77
63
  }
78
- if (count === 0) return { text, separators: [], count: 0 };
79
- append(text.slice(cursor), false);
80
- const replacement = parts.join("");
81
- const parsed = splitWithEndings(replacement);
82
- const separators: (LineEnding | undefined)[] = [];
83
- let offset = 0;
84
- let insertedIndex = 0;
85
- for (let index = 0; index < parsed.lines.length; index++) {
86
- offset += parsed.lines[index]!.length;
87
- const ending = parsed.endings[index];
88
- if (ending === undefined) continue;
89
- while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
90
- const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
91
- separators.push(fromNewText ? ending : undefined);
92
- offset += ending.length;
93
- }
94
- return { text: replacement, separators, count };
64
+ if (count === 0) return { text: line, count: 0 };
65
+ parts.push(line.slice(cursor));
66
+ return { text: parts.join(""), count };
95
67
  }
96
68
 
97
- const RETYPE_DIFF_MAX_CHARS = 20_000;
98
- const RETYPE_MAX_EDIT_LENGTH = 2000;
99
- const RETYPE_MIN_UNCHANGED_CHARS = 40;
100
- const RETYPE_MIN_UNCHANGED_MULTILINE = 20;
101
- const RETYPE_EXAMPLE_MAX_CHARS = 40;
102
- const WHOLE_RANGE_MIN_CHARS = 40;
103
- const WHOLE_RANGE_MIN_LINES = 2;
104
- const NEAR_MISS_MIN_OLD_CHARS = 20;
105
- const NEAR_MISS_MATCH_RATIO = 0.8;
69
+ function linesToPayloadText(lines: string[]): string {
70
+ if (lines.length === 0) return "";
71
+ const joined = lines.join("\n");
72
+ return lines[lines.length - 1] === "" ? `${joined}\n` : joined;
73
+ }
106
74
 
107
- interface RetypeAnalysis {
108
- changed: number;
109
- unchanged: number;
110
- middle: { oldFragment: string; newFragment: string };
75
+ function fileInheritedEndings(lineCount: number): (LineEnding | undefined)[] {
76
+ return new Array<LineEnding | undefined>(lineCount).fill(undefined);
111
77
  }
112
78
 
79
+ const NEAR_MISS_MIN_MATCH_CHARS = 20;
80
+ const NEAR_MISS_MATCH_RATIO = 0.8;
81
+
113
82
  function commonPrefixLength(left: string, right: string): number {
114
83
  const limit = Math.min(left.length, right.length);
115
84
  let index = 0;
@@ -124,91 +93,50 @@ function commonSuffixLength(left: string, right: string, prefix: number): number
124
93
  return suffix;
125
94
  }
126
95
 
127
- function analyzeRetype(oldText: string, newText: string): RetypeAnalysis {
128
- const prefix = commonPrefixLength(oldText, newText);
129
- const suffix = commonSuffixLength(oldText, newText, prefix);
130
- const middle = {
131
- oldFragment: oldText.slice(prefix, oldText.length - suffix),
132
- newFragment: newText.slice(prefix, newText.length - suffix),
133
- };
134
- if (oldText.length + newText.length <= RETYPE_DIFF_MAX_CHARS) {
135
- const parts = Diff.diffChars(oldText, newText, { maxEditLength: RETYPE_MAX_EDIT_LENGTH }) as Array<{ added?: boolean; removed?: boolean; value: string }> | undefined;
136
- if (parts !== undefined) {
137
- let changed = 0;
138
- let removed = 0;
139
- for (const part of parts) {
140
- if (part.added === true) changed += part.value.length;
141
- else if (part.removed === true) {
142
- changed += part.value.length;
143
- removed += part.value.length;
144
- }
145
- }
146
- return { changed, unchanged: oldText.length - removed, middle };
147
- }
148
- }
149
- return { changed: middle.oldFragment.length + middle.newFragment.length, unchanged: prefix + suffix, middle };
150
- }
151
-
152
- function retypeSuggestion(analysis: RetypeAnalysis): string {
153
- const { oldFragment, newFragment } = analysis.middle;
154
- if (oldFragment.length === 0 && newFragment.length === 0) return "Send only the fragment that changes instead.";
155
- if (oldFragment.length === 0) return "Send the added text with insert instead.";
156
- if (
157
- oldFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
158
- newFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
159
- oldFragment.includes("\n") ||
160
- newFragment.includes("\n")
161
- ) {
162
- return "Send only the fragment that changes instead.";
163
- }
164
- return `Send old_string ${JSON.stringify(oldFragment)} and new_string ${JSON.stringify(newFragment)} instead.`;
96
+ function charAt(text: string, index: number): string | undefined {
97
+ if (index >= text.length) return undefined;
98
+ return String.fromCodePoint(text.codePointAt(index)!);
165
99
  }
166
100
 
167
- function retypeWarning(oldText: string, newText: string): string | undefined {
168
- const analysis = analyzeRetype(oldText, newText);
169
- if (analysis.changed === 0) return undefined;
170
- const minUnchanged = oldText.includes("\n") ? RETYPE_MIN_UNCHANGED_MULTILINE : RETYPE_MIN_UNCHANGED_CHARS;
171
- if (analysis.unchanged < minUnchanged) return undefined;
172
- const lineCount = oldText.split("\n").length;
173
- return `[W_REPLACE_MATCH_RETYPE] "old_string" retypes ${analysis.unchanged} unchanged character(s) across ${lineCount} line(s). ${retypeSuggestion(analysis)}`;
174
- }
175
-
176
- function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number, start: number, end: number, startHash: string, endHash: string): string | undefined {
177
- if (oldText !== rangeText) return undefined;
178
- if (lineCount < WHOLE_RANGE_MIN_LINES && oldText.length < WHOLE_RANGE_MIN_CHARS) return undefined;
179
- return `[W_REPLACE_MATCH_WHOLE_RANGE] "old_string" is the entire selected range (${formatMatchRange(start, end)}); the anchors already identify it. Use replace with remove_from "${startHash}", remove_to "${endHash}" and the new text, or send only the fragment that changes.`;
101
+ function codePointStart(text: string, index: number): number {
102
+ if (index <= 0 || index >= text.length) return index;
103
+ const before = text.charCodeAt(index - 1);
104
+ const at = text.charCodeAt(index);
105
+ return before >= 0xd800 && before <= 0xdbff && at >= 0xdc00 && at <= 0xdfff ? index - 1 : index;
180
106
  }
181
107
 
182
108
  function describeChar(char: string | undefined): string {
183
109
  if (char === undefined) return "the end of the text";
184
110
  const codePoint = char.codePointAt(0)!;
185
- const shown = JSON.stringify(char);
186
- return isFidelitySensitiveChar(char) || codePoint < 0x20 || codePoint > 0x7e
187
- ? `${shown} (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`
188
- : shown;
111
+ return isFidelitySensitiveChar(char) || codePoint > 0x7e ? `"${formatCharEscape(char)}"` : JSON.stringify(char);
189
112
  }
190
113
 
191
- function nearMissDetail(oldText: string, rangeText: string, start: number): string | undefined {
192
- if (oldText.length < NEAR_MISS_MIN_OLD_CHARS && !oldText.includes("\n")) return undefined;
193
- const prefix = commonPrefixLength(oldText, rangeText);
194
- const suffix = commonSuffixLength(oldText, rangeText, prefix);
195
- const comparable = Math.min(oldText.length, rangeText.length);
114
+ function nearMissDetail(matchText: string, lineText: string, line: number): string | undefined {
115
+ if (matchText.length < NEAR_MISS_MIN_MATCH_CHARS) return undefined;
116
+ const prefix = commonPrefixLength(matchText, lineText);
117
+ const suffix = commonSuffixLength(matchText, lineText, prefix);
118
+ const comparable = Math.min(matchText.length, lineText.length);
196
119
  if (prefix + suffix < Math.floor(comparable * NEAR_MISS_MATCH_RATIO)) return undefined;
197
- const lineOffset = countNewlines(rangeText.slice(0, prefix));
198
- const column = prefix - rangeText.lastIndexOf("\n", prefix - 1);
199
- return `the first mismatch is at line ${start + lineOffset}, col ${column}: the file has ${describeChar(rangeText[prefix])} where old_string has ${describeChar(oldText[prefix])}.`;
120
+ const fileStart = codePointStart(lineText, prefix);
121
+ const matchStart = codePointStart(matchText, prefix);
122
+ const column = [...lineText.slice(0, fileStart)].length + 1;
123
+ return `the first mismatch is at line ${line}, col ${column}: the file has ${describeChar(charAt(lineText, fileStart))} where match has ${describeChar(charAt(matchText, matchStart))}.`;
200
124
  }
201
125
 
202
- function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, oldText: string): string {
126
+ function formatMatchRange(start: number, end: number): string {
127
+ return start === end ? `line ${start}` : `lines ${start}-${end}`;
128
+ }
129
+
130
+ function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, matchText: string): string {
203
131
  const rangeLength = end - start + 1;
204
132
  const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
205
133
  const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
206
- const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
207
- const nearMiss = nearMissDetail(oldText, rangeText, start);
134
+ const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}. Call read with offset=${start + shownCount} to see the rest.]` : "";
135
+ const nearMiss = start === end ? nearMissDetail(matchText, rangeText, start) : undefined;
208
136
  const advice = nearMiss !== undefined
209
- ? `${nearMiss} That looks like a retyped copy of the range; send only the fragment that changes (copy it from the served row), or use replace with the same anchors.`
210
- : "Copy only the fragment that changes from the served row (comparison uses LF breaks and excludes the last line's terminator) and retry; for a whole-line or whole-range rewrite use replace with the same anchors.";
211
- return `[E_SUBSTRING_NOT_FOUND] "old_string" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\n${advice}`;
137
+ ? `${nearMiss} That looks like a retyped copy of the line; send only the fragment that changes (copy it from the served row), or use replace with the same anchor.`
138
+ : "Copy only the fragment that changes from the served row (a trailing line break is accepted and dropped) and retry; for a whole-line rewrite use replace with the same anchors.";
139
+ return `[E_SUBSTRING_NOT_FOUND] "match" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\n${advice}`;
212
140
  }
213
141
 
214
142
  export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warnings: string[] } {
@@ -225,6 +153,8 @@ export function buildReplaceMatchEdit(
225
153
  displayPath: string,
226
154
  served?: ReadonlyMap<string, string>,
227
155
  ): MatchPlan {
156
+ const matchText = singleLineFragment("match", req.match);
157
+ const replacementText = singleLineFragment("replacement", req.replacement);
228
158
  const fileLines = splitLines(preload.normalized);
229
159
  const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
230
160
  const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
@@ -232,33 +162,26 @@ export function buildReplaceMatchEdit(
232
162
  const end = Math.max(fromLine, toLine);
233
163
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
234
164
  const rangeLines = fileLines.slice(start - 1, end);
235
- const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
236
- const oldText = toLF(req.old_string);
237
- const newText = toLF(req.new_string);
238
- const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
165
+ let count = 0;
166
+ const replacementLines = rangeLines.map((line) => {
167
+ const result = replaceAllOccurrences(line, matchText, replacementText);
168
+ count += result.count;
169
+ return result.text;
170
+ });
239
171
  if (count === 0) {
240
- throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
172
+ throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
241
173
  }
242
- const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
174
+ const replacement = linesToPayloadText(replacementLines);
243
175
  const startRef = fromLine <= toLine ? refs.from : refs.to;
244
176
  const endRef = fromLine <= toLine ? refs.to : refs.from;
245
177
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
246
- const warnings: string[] = [];
247
- const retype = retypeWarning(oldText, newText);
248
- if (retype !== undefined) {
249
- warnings.push(retype);
250
- } else {
251
- const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
252
- if (wholeRange !== undefined) warnings.push(wholeRange);
253
- }
254
178
  return {
255
179
  editParams: {
256
180
  remove_from: startRef.hash,
257
181
  remove_to: endRef.hash,
258
182
  text: replacement,
259
183
  },
260
- warnings,
261
- contentSeparators,
184
+ contentSeparators: fileInheritedEndings(replacementLines.length),
262
185
  ...(servedOverride !== undefined ? { servedOverride } : {}),
263
186
  };
264
187
  }
@@ -267,7 +190,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
267
190
  const req = normReq(input.member.args, "replace");
268
191
  assertReplaceMatchReq(req);
269
192
  const { refs, warnings } = parseMatchAnchors(req);
270
- const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
193
+ const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
271
194
  const preload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
272
195
  const matchPlan = buildReplaceMatchEdit(req, refs, preload, input.displayPath, input.served);
273
196
  const resWarnings: string[] = [];
@@ -281,11 +204,10 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
281
204
  });
282
205
  return {
283
206
  hedit,
284
- warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
207
+ warnings: [...warnings, ...hints, ...resWarnings],
285
208
  contentSeparators: matchPlan.contentSeparators,
286
209
  planned,
287
210
  foldedLines: 0,
288
- ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
289
211
  };
290
212
  }
291
213
 
@@ -312,7 +234,6 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
312
234
  signal,
313
235
  });
314
236
  const plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
315
- await throwIfStrictInput(plan.warnings);
316
237
  const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
317
238
  accessMode: constants.R_OK,
318
239
  noPersist: true,
@@ -367,7 +288,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
367
288
  if (member) noteBatchFailure(member, error);
368
289
  throw error;
369
290
  }
370
- const hints = [...literalEscapeHints([req.old_string], "old_string"), ...literalEscapeHints([req.new_string], "new_string")];
291
+ const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
371
292
  const targetPath = await resolveEditTargetWithRequirement({
372
293
  removeFrom: req.replace_from,
373
294
  removeTo: req.replace_to,
@@ -394,7 +315,6 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
394
315
  await noteAnchorError(mutationTargetPath, error);
395
316
  throw error;
396
317
  }
397
- await throwIfStrictInput(plan.warnings);
398
318
  const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
399
319
  accessMode: constants.R_OK | constants.W_OK,
400
320
  signal,
@@ -407,7 +327,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
407
327
  absolutePath,
408
328
  mutationTargetPath,
409
329
  editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
410
- prefixWarnings: [...warnings, ...hints, ...plan.warnings],
330
+ prefixWarnings: [...warnings, ...hints],
411
331
  signal,
412
332
  verb: "replaced",
413
333
  noopNoun: "Replacement",
package/src/replace.ts CHANGED
@@ -87,6 +87,7 @@ export interface ExecPipelineOptions {
87
87
  stripWarning?: StripWarningLocation;
88
88
  endingOverrides?: (LineEnding | undefined)[];
89
89
  preserveDeletionSeparators?: boolean;
90
+ anchorCarry?: number;
90
91
  }
91
92
 
92
93
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -205,20 +206,22 @@ export async function execPipeline(
205
206
  const result = anchorResult.content;
206
207
  const isNoop = result === originalNormalized;
207
208
 
209
+ const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
210
+ if (pipeSpan && options?.anchorCarry !== undefined) pipeSpan.carry = options.anchorCarry;
211
+ const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
212
+
208
213
  const resultHashes = isNoop
209
214
  ? originalHashes
210
215
  : await lineHashes(result, absolutePath, {
211
216
  content: originalNormalized,
212
217
  hashes: originalHashes,
218
+ ...(pipeSpans ? { spans: pipeSpans } : {}),
213
219
  }, hashStore, false, true);
214
220
  const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
215
221
  await throwIfStrictInput(warnings);
216
222
  const { totalAddedLines, totalRemovedLines } = countLineChanges(
217
223
  effectiveEdit, originalHashes, isNoop,
218
224
  );
219
-
220
- const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
221
- const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
222
225
  return {
223
226
  path: displayPath,
224
227
  originalNormalized,
package/src/utils.ts CHANGED
@@ -41,6 +41,24 @@ export function splitLines(text: string): string[] {
41
41
  return text.endsWith("\n") ? lines.slice(0, -1) : lines;
42
42
  }
43
43
 
44
+ export function spliceAll<T>(target: T[], start: number, deleteCount: number, items: readonly T[]): T[] {
45
+ const rawStart = Number.isNaN(start) ? 0 : Math.trunc(start);
46
+ const at = rawStart < 0 ? Math.max(target.length + rawStart, 0) : Math.min(rawStart, target.length);
47
+ const rawCount = Number.isNaN(deleteCount) ? 0 : Math.trunc(deleteCount);
48
+ const remove = Math.max(0, Math.min(rawCount, target.length - at));
49
+ const removed = target.slice(at, at + remove);
50
+ const tail = target.slice(at + remove);
51
+ const additions: readonly T[] = items === target ? [...items] : items;
52
+ target.length = at;
53
+ for (const item of additions) target.push(item);
54
+ for (const item of tail) target.push(item);
55
+ return removed;
56
+ }
57
+
58
+ export function anchorCarryIndex(direction: "after" | "before", lineCount: number): number {
59
+ return direction === "after" ? 0 : lineCount;
60
+ }
61
+
44
62
  export function visLines(text: string): string[] {
45
63
  return text.length === 0 ? [] : splitLines(text);
46
64
  }
@@ -194,10 +212,12 @@ export function clipLine(line: string, maxLen = 200): string {
194
212
  const flat = line.replace(/\n/g, "\\n");
195
213
  return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
196
214
  }
197
- export function assertLineLimit(content: string, displayPath: string, limit: number): void {
198
- const count = splitLines(content).length;
215
+ export function assertLineCount(count: number, displayPath: string, limit: number): void {
199
216
  if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
200
217
  }
218
+ export function assertLineLimit(content: string, displayPath: string, limit: number): void {
219
+ assertLineCount(splitLines(content).length, displayPath, limit);
220
+ }
201
221
  export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
202
222
  if (Buffer.byteLength(content, "utf-8") > limit) {
203
223
  throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);
@@ -1,3 +1,3 @@
1
1
  - `read`: call again after an edit when you need anchors you lack — post-edit diff `+`/` ` rows and served `anchor│content` rows already carry fresh anchors for the changed range.
2
2
  - `read`: an auto-read-all outline row shows a symbol label, or a single-line symbol's content; its `[limit N]` is the span to request.
3
- - `read`: `replace_match` needs both boundary lines served; copy `old_string` from a served row or read the lines first.
3
+ - `read`: `replace_match` needs both boundary lines served; copy `match` from a served row or read the lines first.
@@ -1,3 +1,3 @@
1
- - `replace_match`: `old_string` is only the fragment that changes — never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range when only some lines should change.
2
- - `replace_match`: if `old_string` is everything on the line except indentation and trailing punctuation, that match is the whole line — use `replace`. A shorter fragment repeated on several lines (a value, a name) is the intended multi-line use.
3
- - `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the exact text.
1
+ - `replace_match`: `match` is only the fragment that changes — never text that `replacement` leaves unchanged; every occurrence inside the range is replaced, so narrow the anchors when only some lines should change.
2
+ - `replace_match`: the anchors bound the range searched, and `match`/`replacement` must each stay within one line — a trailing line break is the line's terminator, an interior one belongs to `replace`, and a lone break cannot be a `match`.
3
+ - `replace_match`: when the change covers the whole line, use `replace` with the same anchor and the exact text.
@@ -1 +1 @@
1
- Replace part of a line or range by anchor: every `old_string` occurrence inside `replace_from`/`replace_to` becomes `new_string`; the rest of the line stays untouched
1
+ Replace part of a line or range by anchor: every `match` occurrence inside the range becomes `replacement`; the rest of each line stays untouched
@@ -1,3 +1,3 @@
1
- Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the fragment that changes — never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. The same short fragment on several lines (a value, a name) may be replaced in one call. `remove_from`/`remove_to` are accepted as aliases.
1
+ Replace part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, while a line break anywhere else is refused; a lone line break leaves nothing to find in `match` and counts as `""` in `replacement`. Send only the fragment that changes — never text that `replacement` leaves unchanged. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide; when only one occurrence on a range should change, narrow `replace_from`/`replace_to` instead of lengthening the fragment. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. `remove_from`/`remove_to` are accepted as aliases.
2
2
 
3
- The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
3
+ The two boundary anchors must still match what was last shown; lines strictly inside the range are searched as they are on disk.