pi-hashline-edit-pro 6.4.2 → 6.4.4

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 CHANGED
@@ -159,7 +159,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
159
159
 
160
160
  ### read
161
161
 
162
- `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. Anchor offsets are stable across edits and return the same editable rows.
162
+ `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return starting at that anchor row. Anchor offsets are stable across edits and return the same editable rows.
163
163
 
164
164
  | Parameter | Description |
165
165
  | --- | --- |
@@ -207,7 +207,7 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
207
207
 
208
208
  The extension checks the request before any file I/O, so a bad request never touches the file.
209
209
 
210
- Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embedded newlines, and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning: a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
210
+ Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`) and diff-preview rows pasted into the replacement are stripped with a warning.
211
211
 
212
212
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to the `text` field of both `replace` and `insert`.
213
213
 
@@ -219,13 +219,13 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
219
 
220
220
  ### replace_match
221
221
 
222
- `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
222
+ `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range; if `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line change. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
223
223
 
224
224
  `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
225
 
226
226
  `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
227
227
 
228
- `replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged, and `[W_REPLACE_MATCH_MULTI]` when `old_string` matched more than one occurrence. In strict-input mode the first two are refused with `[E_BAD_SHAPE]`.
228
+ `replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, and `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged. Both are refused in strict-input mode with `[E_BAD_SHAPE]`.
229
229
 
230
230
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
231
231
 
@@ -465,7 +465,6 @@ Full reference:
465
465
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
466
466
  | `[W_REPLACE_MATCH_WHOLE_RANGE]` | `replace_match` `old_string` is the entire selected range. The anchors already identify it; use `replace` with the same anchors, or send only the changed fragment. Refused in strict-input mode. |
467
467
  | `[W_REPLACE_MATCH_RETYPE]` | `replace_match` `old_string` retypes a long run of text that `new_string` leaves unchanged. Send only the fragment that changes; refused in strict-input mode. |
468
- | `[W_REPLACE_MATCH_MULTI]` | `replace_match` `old_string` matched more than one occurrence in the range and every one was replaced. Narrow the range or send a longer fragment if you meant only one. |
469
468
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
470
469
  | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
471
470
  | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
@@ -474,6 +473,7 @@ Full reference:
474
473
  | `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
475
474
  | `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
476
475
  | `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
476
+ | `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
477
477
  | `[E_NOT_FOUND]` | The path does not exist. |
478
478
  | `[E_ACCESS]` | The file is not readable or writable. |
479
479
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
package/index.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  setDisableOnModelsFromText,
34
34
  } from "./src/config";
35
35
  import { loadHashStore, pruneMissing } from "./src/hash-store";
36
- import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
36
+ import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
37
37
  import { serveRows } from "./src/served";
38
38
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
39
39
  import { currentEditFlags } from "./src/edit-common";
@@ -72,6 +72,7 @@ export default function (pi: ExtensionAPI): void {
72
72
  let grepFlavorCwd: string | undefined;
73
73
  const baseAnchorTools = new Set<string>();
74
74
  let gateApplied = false;
75
+ const pendingRegistrySessionKeys: Array<string | undefined> = [];
75
76
 
76
77
  async function refreshEditTools(): Promise<void> {
77
78
  try {
@@ -144,7 +145,12 @@ export default function (pi: ExtensionAPI): void {
144
145
  pi.setActiveTools([...new Set([...base, ...enabled])]);
145
146
  }
146
147
 
147
- pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
148
+ async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
149
+ pendingRegistrySessionKeys.push(sessionKeyFor(ctx));
150
+ return withAnchorSession(ctx, fn);
151
+ }
152
+
153
+ pi.on("session_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
148
154
  const active = pi.getActiveTools();
149
155
  baseAnchorTools.clear();
150
156
  for (const tool of ANCHOR_TOOL_NAMES) {
@@ -154,8 +160,7 @@ export default function (pi: ExtensionAPI): void {
154
160
  await initHasher();
155
161
  loadHashStore()
156
162
  .then(async store => {
157
- const missing = await pruneMissing(store);
158
- for (const path of missing) freeAnchors(path);
163
+ freeAnchorsForPaths(await pruneMissing(store));
159
164
  })
160
165
  .catch(err => {
161
166
  console.error("Failed to load hash store:", err);
@@ -192,9 +197,9 @@ export default function (pi: ExtensionAPI): void {
192
197
  }
193
198
  }));
194
199
 
195
- pi.on("session_shutdown", async (_event, ctx) => {
200
+ pi.on("session_shutdown", async () => {
196
201
  try {
197
- const key = sessionKeyFor(ctx);
202
+ const key = pendingRegistrySessionKeys.shift();
198
203
  if (key !== undefined) releaseRegistrySession(key);
199
204
  } catch (error) {
200
205
  console.error("Failed to release anchor registry session:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.2",
3
+ "version": "6.4.4",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -512,7 +512,7 @@ function hasOwnedPath(state: SessionState, path: string): boolean {
512
512
 
513
513
  function evictPath(state: SessionState, path: string): string[] {
514
514
  const anchors: string[] = [];
515
- for (const [anchor, entry] of [...state.owned]) {
515
+ for (const [anchor, entry] of state.owned) {
516
516
  if (entry.path === path) {
517
517
  anchors.push(anchor);
518
518
  state.owned.delete(anchor);
@@ -617,6 +617,28 @@ export function freeAnchors(path: string, anchors?: string[]): void {
617
617
  }
618
618
  }
619
619
 
620
+ export function freeAnchorsForPaths(paths: Iterable<string>): void {
621
+ const state = current();
622
+ if (!state) return;
623
+ const targets = new Set(paths);
624
+ if (targets.size === 0) return;
625
+ const freedByPath = new Map<string, string[]>();
626
+ for (const [anchor, entry] of state.owned) {
627
+ if (!targets.has(entry.path)) continue;
628
+ state.owned.delete(anchor);
629
+ const freed = freedByPath.get(entry.path);
630
+ if (freed === undefined) freedByPath.set(entry.path, [anchor]);
631
+ else freed.push(anchor);
632
+ }
633
+ for (const path of targets) {
634
+ state.served.delete(path);
635
+ state.lastUsedAt.delete(path);
636
+ }
637
+ for (const [path, anchors] of freedByPath) {
638
+ appendEvent({ kind: "free", path, anchors });
639
+ }
640
+ }
641
+
620
642
  export function clearRegistry(): void {
621
643
  const state = current();
622
644
  if (!state) return;
@@ -47,7 +47,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
47
47
 
48
48
  function preferenceGuideline(flags: EditToolFlags): string {
49
49
  const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
50
- return `Prefer the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
50
+ return `Remember to use the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
51
51
  }
52
52
 
53
53
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
package/src/hash-store.ts CHANGED
@@ -128,7 +128,7 @@ function requireEngine(): EngineSelection {
128
128
  interface Prepared {
129
129
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
130
130
  getState: (...params: SqlParams) => Record<string, unknown> | undefined;
131
- allPaths: (...params: SqlParams) => Record<string, unknown>[];
131
+ snapshotPaths: (...params: SqlParams) => Record<string, unknown>[];
132
132
  deleteOne: (...params: SqlParams) => void;
133
133
  upsert: (...params: SqlParams) => void;
134
134
  undoUpsert: (...params: SqlParams) => void;
@@ -226,7 +226,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
226
226
  ).run(String(HASH_STORE_VERSION));
227
227
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
228
228
  const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
229
- const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
229
+ const snapshotPathsStmt = db.prepare("SELECT path FROM snapshots");
230
230
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
231
231
  const upsertStmt = db.prepare(
232
232
  "INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
@@ -245,7 +245,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
245
245
  const stmts: Prepared = {
246
246
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
247
247
  getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
248
- allPaths: (...params) => withBusyRetry(() => allStmt.all(...params) as Record<string, unknown>[]),
248
+ snapshotPaths: (...params) => withBusyRetry(() => snapshotPathsStmt.all(...params) as Record<string, unknown>[]),
249
249
  deleteOne: retriedWrite(delStmt),
250
250
  upsert: retriedWrite(upsertStmt),
251
251
  undoUpsert: retriedWrite(undoUpsertStmt),
@@ -640,7 +640,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
640
640
  }
641
641
 
642
642
  export async function pruneMissing(store: HashStore): Promise<string[]> {
643
- const rows = store.stmts.allPaths() as { path: string }[];
643
+ const rows = store.stmts.snapshotPaths() as { path: string }[];
644
644
  const missing = await statMissing(rows);
645
645
  if (missing.length === 0) return [];
646
646
  withStore(() => {
@@ -166,7 +166,7 @@ export function planEdit(
166
166
  const fileHashes = precomputedHashes;
167
167
  const warnings: string[] = [];
168
168
 
169
- const rangeFixed = swapReversedRanges(edit, fileHashes);
169
+ const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
170
170
  const prefixFixed = stripDiffPrefixes(
171
171
  stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
172
172
  warnings,
@@ -265,6 +265,7 @@ export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: Str
265
265
  export function swapReversedRanges(
266
266
  edit: HEdit,
267
267
  fileHashes: string[],
268
+ warnings?: string[],
268
269
  ): HEdit {
269
270
  const lineByHash = new Map<string, number>();
270
271
  for (let i = 0; i < fileHashes.length; i++) {
@@ -280,6 +281,7 @@ export function swapReversedRanges(
280
281
  ) {
281
282
  return edit;
282
283
  }
284
+ warnings?.push(`[H_RANGE_REVERSED] "remove_from" refers to line ${startLine} after "remove_to" (line ${endLine}); lines ${endLine}-${startLine} were replaced in document order.`);
283
285
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
284
286
  }
285
287
 
@@ -234,7 +234,7 @@ const replaceMatchToSchema = Type.String({
234
234
  });
235
235
  const replaceMatchOldStringSchema = Type.String({
236
236
  description:
237
- "The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator. For a whole-line or whole-range rewrite use `replace` with the same anchors.",
237
+ "The exact text to find inside the selected line(s), copied from the served row: 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 match: use `replace` instead. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
238
238
  });
239
239
  const replaceMatchNewStringSchema = Type.String({
240
240
  description:
@@ -4,7 +4,7 @@ import * as Diff from "diff";
4
4
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
6
  import { readNormFile, type NormFile } from "./file-reader";
7
- import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
7
+ import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
8
8
  import { servedForPath, withAnchorSession } from "./anchor-registry";
9
9
  import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
10
10
  import { loadP, loadGuidelines } from "./prompts";
@@ -16,9 +16,9 @@ import {
16
16
  type ReplaceMatchReq,
17
17
  } from "./payload-contract";
18
18
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
19
- import { toLF } from "./normalize";
19
+ import { toLF, type LineEnding } from "./normalize";
20
20
  import { isFidelitySensitiveChar } from "./edit-fidelity";
21
- import { countNewlines } from "./line-endings";
21
+ import { countNewlines, splitWithEndings } from "./line-endings";
22
22
  import { MAX_RANGE_STALE_LINES } from "./constants";
23
23
  import {
24
24
  assertBoundaryLinesServed,
@@ -44,6 +44,7 @@ interface MatchRefs {
44
44
  export interface MatchPlan {
45
45
  editParams: { remove_from: string; remove_to: string; text: string };
46
46
  warnings: string[];
47
+ contentSeparators: (LineEnding | undefined)[];
47
48
  servedOverride?: ReadonlyMap<string, string>;
48
49
  }
49
50
 
@@ -51,18 +52,46 @@ function formatMatchRange(start: number, end: number): string {
51
52
  return start === end ? `line ${start}` : `lines ${start}-${end}`;
52
53
  }
53
54
 
54
- function replaceAllOccurrences(text: string, oldText: string, newText: string): { text: string; count: number } {
55
- let out = "";
55
+ function replaceAllOccurrences(
56
+ text: string,
57
+ oldText: string,
58
+ newText: string,
59
+ ): { text: string; separators: (LineEnding | undefined)[]; count: number } {
60
+ 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
+ };
56
68
  let cursor = 0;
57
69
  let count = 0;
58
70
  for (;;) {
59
71
  const found = text.indexOf(oldText, cursor);
60
72
  if (found < 0) break;
61
- out += text.slice(cursor, found) + newText;
73
+ append(text.slice(cursor, found), false);
74
+ append(newText, true);
62
75
  cursor = found + oldText.length;
63
76
  count += 1;
64
77
  }
65
- return count === 0 ? { text, count: 0 } : { text: out + text.slice(cursor), count };
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 };
66
95
  }
67
96
 
68
97
  const RETYPE_DIFF_MAX_CHARS = 20_000;
@@ -150,10 +179,6 @@ function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number
150
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.`;
151
180
  }
152
181
 
153
- function multiMatchWarning(count: number, start: number, end: number, displayPath: string): string {
154
- return `[W_REPLACE_MATCH_MULTI] "old_string" matched ${count} occurrences in ${formatMatchRange(start, end)} of ${displayPath}; every one was replaced. Narrow the range or send a longer fragment if you meant only one.`;
155
- }
156
-
157
182
  function describeChar(char: string | undefined): string {
158
183
  if (char === undefined) return "the end of the text";
159
184
  const codePoint = char.codePointAt(0)!;
@@ -210,10 +235,11 @@ export function buildReplaceMatchEdit(
210
235
  const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
211
236
  const oldText = toLF(req.old_string);
212
237
  const newText = toLF(req.new_string);
213
- const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
238
+ const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
214
239
  if (count === 0) {
215
240
  throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
216
241
  }
242
+ const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
217
243
  const startRef = fromLine <= toLine ? refs.from : refs.to;
218
244
  const endRef = fromLine <= toLine ? refs.to : refs.from;
219
245
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
@@ -225,7 +251,6 @@ export function buildReplaceMatchEdit(
225
251
  const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
226
252
  if (wholeRange !== undefined) warnings.push(wholeRange);
227
253
  }
228
- if (count > 1) warnings.push(multiMatchWarning(count, start, end, displayPath));
229
254
  return {
230
255
  editParams: {
231
256
  remove_from: startRef.hash,
@@ -233,6 +258,7 @@ export function buildReplaceMatchEdit(
233
258
  text: replacement,
234
259
  },
235
260
  warnings,
261
+ contentSeparators,
236
262
  ...(servedOverride !== undefined ? { servedOverride } : {}),
237
263
  };
238
264
  }
@@ -256,6 +282,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
256
282
  return {
257
283
  hedit,
258
284
  warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
285
+ contentSeparators: matchPlan.contentSeparators,
259
286
  planned,
260
287
  foldedLines: 0,
261
288
  ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
@@ -291,6 +318,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
291
318
  noPersist: true,
292
319
  preloadedNorm: preload,
293
320
  served: plan.servedOverride,
321
+ endingOverrides: plan.contentSeparators,
294
322
  signal,
295
323
  });
296
324
  return previewFromPipe(pipe);
@@ -372,6 +400,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
372
400
  signal,
373
401
  preloadedNorm: preload,
374
402
  served: plan.servedOverride,
403
+ endingOverrides: plan.contentSeparators,
375
404
  });
376
405
  return commitEdit(pipe, {
377
406
  path: pipe.path,
package/src/replace.ts CHANGED
@@ -158,9 +158,11 @@ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[]
158
158
  const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
159
159
  const separators: (LineEnding | undefined)[] = new Array(length);
160
160
  for (let index = 0; index < length; index++) {
161
- separators[index] = overrides[index] ?? edit.content_separators?.[index];
161
+ separators[index] = index < overrides.length ? overrides[index] : edit.content_separators?.[index];
162
+ }
163
+ if (separators.every((ending) => ending === undefined)) {
164
+ return edit.content_separators === undefined ? edit : { ...edit, content_separators: undefined };
162
165
  }
163
- if (separators.every((ending) => ending === undefined)) return edit;
164
166
  return { ...edit, content_separators: separators };
165
167
  }
166
168
 
@@ -1 +1 @@
1
- Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines after it — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
1
+ Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines from that anchor row onward — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
@@ -1,3 +1,3 @@
1
- - `replace_match`: `old_string` is only the fragment of the line that changes — never the whole line or range, and never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
2
- - `replace_match`: when the same changed fragment appears on every matching line in a range, anchor the first and last matching lines so one call replaces them all instead of one call per line.
3
- - `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the new text.
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,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 smallest fragment that contains the change — never text that `new_string` leaves unchanged; for a whole-line or whole-range rewrite use `replace` with the same anchors instead. `remove_from`/`remove_to` are accepted as aliases.
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.
2
2
 
3
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.