pi-hashline-edit-pro 6.4.1 → 6.4.2

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
@@ -219,12 +219,14 @@ 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. 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. 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]`.
229
+
228
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`.
229
231
 
230
232
  ### insert
@@ -339,7 +341,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
339
341
 
340
342
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing; `outline` attaches an anchor-stamped structural outline per file; and `full` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk) and attaches full contents. `Git repos only` is on by default and keeps the whole injection inside a git repository; turn it off to attach files in any directory. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
341
343
 
342
- Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
344
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
343
345
 
344
346
  Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
345
347
 
@@ -406,7 +408,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
406
408
  | Grep output | 2000 rows and 50KB | `grep` |
407
409
  | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
408
410
  | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
409
- | Auto-read all budget | 200KB floor, 2MB ceiling | total injection size, derived from the model context window |
411
+ | Auto-read all budget | 200KB floor; 2MB ceiling in `full` | total injection size derived from the model context window; `outline` is not capped at 2MB |
410
412
  | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
411
413
  | Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
412
414
 
@@ -456,11 +458,14 @@ Full reference:
456
458
  | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
457
459
  | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
458
460
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
459
- | `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `old_string` in the selected range. The current `anchor│content` rows are returned; copy `old_string` exactly from the served row and retry. |
461
+ | `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `old_string` in the selected range. The current `anchor│content` rows are returned, and when `old_string` looks like a retyped copy of the range the message names the first mismatching line and column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
460
462
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
461
463
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
462
464
  | `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
463
465
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
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
+ | `[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. |
464
469
  | `[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. |
465
470
  | `[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. |
466
471
  | `[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. |
package/index.ts CHANGED
@@ -210,7 +210,7 @@ export default function (pi: ExtensionAPI): void {
210
210
  if (modelDisabled(ctx.model, disableOnModels)) return;
211
211
  autoReadAllInjected = true;
212
212
  try {
213
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, autoReadAllRequireGit);
213
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model, autoReadAll), autoReadAll, autoReadAllIgnore, autoReadAllRequireGit);
214
214
  if (!injection) return;
215
215
  if (ctx.hasUI) ctx.ui.notify(`Auto-read all: ${isOutlineAutoReadAll(autoReadAll) ? "outlined" : "attached"} ${injection.files} file(s)`, "info");
216
216
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.1",
3
+ "version": "6.4.2",
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",
@@ -61,7 +61,7 @@
61
61
  "typecheck": "tsc --noEmit"
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^1.0.3",
64
+ "@earendil-works/pi-coding-agent": "^1.2.0",
65
65
  "@eslint/js": "^10.0.1",
66
66
  "@types/node": "^24",
67
67
  "@vitest/coverage-v8": "^4.1.11",
@@ -433,6 +433,10 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
433
433
  let bytes = 0;
434
434
  let completeFiles = 0;
435
435
  for (const file of discovery.files) {
436
+ if (sections.length > 0 && bytes >= budgetBytes) {
437
+ omitted.push(file);
438
+ continue;
439
+ }
436
440
  if (!outline && sections.length > 0) {
437
441
  const size = await candidateFileBytes(cwd, file);
438
442
  if (size !== undefined && bytes + size > budgetBytes) {
@@ -462,8 +466,9 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
462
466
  return { text, files: sections.length, bytes, omitted, completeFiles };
463
467
  }
464
468
 
465
- export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
469
+ export function autoReadAllBudget(model: { contextWindow?: number } | undefined, mode: AutoReadAllMode = "full"): number {
466
470
  const contextWindow = typeof model?.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow : 0;
467
471
  const fromContext = Math.floor(contextWindow * 1.5);
468
- return Math.min(AUTO_READ_ALL_MAX_BUDGET_BYTES, Math.max(AUTO_READ_ALL_MIN_BUDGET_BYTES, fromContext));
472
+ const budget = Math.max(AUTO_READ_ALL_MIN_BUDGET_BYTES, fromContext);
473
+ return mode === "outline" ? budget : Math.min(AUTO_READ_ALL_MAX_BUDGET_BYTES, budget);
469
474
  }
@@ -160,7 +160,9 @@ export function withInsertPrompts(base: { description: string; snippet: string;
160
160
 
161
161
  export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
162
162
  const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
163
- return finalizePrompts(base.description, base.snippet, guidelines, flags);
163
+ const prompted = finalizePrompts(base.description, base.snippet, guidelines, flags);
164
+ if (flags.strictInput) prompted.guidelines.push("`replace_match`: strict-input mode rejects an `old_string` that covers the whole range or retypes unchanged text.");
165
+ return prompted;
164
166
  }
165
167
 
166
168
  function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
@@ -299,13 +301,13 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
299
301
  return anchorTarget;
300
302
  }
301
303
 
302
- const AUTO_FIX_WARNING_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
304
+ const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]", "[W_REPLACE_MATCH_WHOLE_RANGE]", "[W_REPLACE_MATCH_RETYPE]"];
303
305
  export async function throwIfStrictInput(warnings: string[]): Promise<void> {
304
- const fixes = warnings.filter((warning) => AUTO_FIX_WARNING_CODES.some((code) => warning.startsWith(code)));
306
+ const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
305
307
  if (fixes.length === 0) return;
306
308
  const { strictInput } = await readConfig();
307
309
  if (strictInput === true) {
308
- throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input:\n${fixes.join("\n")}`);
310
+ throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input and replace_match retyping warnings:\n${fixes.join("\n")}`);
309
311
  }
310
312
  }
311
313
 
package/src/file-kind.ts CHANGED
@@ -89,7 +89,7 @@ export async function loadFileKindAndText(
89
89
  const fileHandle = await fsOpen(filePath, "r");
90
90
  try {
91
91
  const openedStats = await fileHandle.stat();
92
- const identity = { dev: openedStats.dev, ino: openedStats.ino };
92
+ const identity = { dev: openedStats.dev, ino: openedStats.ino, mtimeMs: openedStats.mtimeMs, size: openedStats.size };
93
93
  const buffer = Buffer.alloc(SNIFF_BYTES);
94
94
  const { bytesRead } = await fileHandle.read(
95
95
  buffer,
@@ -101,8 +101,8 @@ export async function readNormFile(
101
101
  const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true, options?.allocation === "shadow");
102
102
  let identity = file.identity;
103
103
  if (!identity) {
104
- const { dev, ino } = await stat(resolvedPath);
105
- identity = { dev, ino };
104
+ const { dev, ino, mtimeMs, size } = await stat(resolvedPath);
105
+ identity = { dev, ino, mtimeMs, size };
106
106
  }
107
107
  return {
108
108
  absolutePath: resolvedPath,
package/src/fs-write.ts CHANGED
@@ -18,13 +18,18 @@ import { errCode, isModeUnsupported, mapIoError } from "./utils";
18
18
  export interface FileIdentity {
19
19
  dev: number;
20
20
  ino: number;
21
+ mtimeMs?: number;
22
+ size?: number;
21
23
  }
22
24
 
23
25
  function sameIdentity(
24
26
  actual: FileIdentity,
25
27
  expected: FileIdentity,
26
28
  ): boolean {
27
- return actual.dev === expected.dev && actual.ino === expected.ino;
29
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino) return false;
30
+ if (expected.mtimeMs !== undefined && actual.mtimeMs !== expected.mtimeMs) return false;
31
+ if (expected.size !== undefined && actual.size !== expected.size) return false;
32
+ return true;
28
33
  }
29
34
 
30
35
  function pathChanged(path: string): Error {
package/src/grep.ts CHANGED
@@ -633,7 +633,6 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
633
633
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
634
634
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
635
635
  if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
636
- if (context > 0 && hits.length > 0) notes.push('[grep: match rows are "line:│", context rows are "line-│".]');
637
636
  const truncated = limitTruncated || rowTruncated;
638
637
  const truncation: TruncationResult | undefined = rowTruncated
639
638
  ? {
package/src/hash-store.ts CHANGED
@@ -111,9 +111,19 @@ async function selectSqliteEngine(): Promise<{ engine: SqliteEngine; open: (path
111
111
  throw new Error(`[E_STORE_UNAVAILABLE] No SQLite runtime available (node:sqlite and bun:sqlite both failed to load): ${detail}`);
112
112
  }
113
113
 
114
- const selectedEngine = await selectSqliteEngine();
115
- const sqliteEngine: SqliteEngine = selectedEngine.engine;
116
- const openDbFn = selectedEngine.open;
114
+ type EngineSelection = { engine: SqliteEngine; open: (path: string) => RawDb };
115
+ let engineSelection: EngineSelection | undefined;
116
+ let engineSelectionError: unknown;
117
+ try {
118
+ engineSelection = await selectSqliteEngine();
119
+ } catch (error) {
120
+ engineSelectionError = error;
121
+ }
122
+ function requireEngine(): EngineSelection {
123
+ if (engineSelection !== undefined) return engineSelection;
124
+ if (engineSelectionError instanceof Error) throw engineSelectionError;
125
+ throw new Error("[E_STORE_UNAVAILABLE] No SQLite runtime available.");
126
+ }
117
127
 
118
128
  interface Prepared {
119
129
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
@@ -151,7 +161,7 @@ let storeEpoch = 0;
151
161
  const liveDbs = new Set<RawDb>();
152
162
 
153
163
  function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
154
- const db = openDbFn(storePath);
164
+ const db = requireEngine().open(storePath);
155
165
  liveDbs.add(db);
156
166
  try {
157
167
  return buildStore(db);
@@ -285,6 +295,7 @@ function shutdownDb(db: RawDb): void {
285
295
  async function openStore(storePath: string): Promise<HashStore> {
286
296
  if (cachedDb) shutdownHashStore();
287
297
  const epoch = storeEpoch;
298
+ const engine = requireEngine();
288
299
  await initHasher();
289
300
  await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
290
301
  if (process.platform !== "win32") {
@@ -362,13 +373,13 @@ async function openStore(storePath: string): Promise<HashStore> {
362
373
  }
363
374
  }
364
375
 
365
- return { stmts, engine: sqliteEngine };
376
+ return { stmts, engine: engine.engine };
366
377
  }
367
378
 
368
379
  export function loadHashStore(): Promise<HashStore> {
369
380
  const storePath = hashStorePath();
370
381
  if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
371
- return Promise.resolve({ stmts: cachedDb.stmts, engine: sqliteEngine });
382
+ return Promise.resolve({ stmts: cachedDb.stmts, engine: requireEngine().engine });
372
383
  }
373
384
  if (opening && opening.path === storePath) {
374
385
  return opening.promise;
@@ -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. 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.",
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.",
238
238
  });
239
239
  const replaceMatchNewStringSchema = Type.String({
240
240
  description:
package/src/prompts.ts CHANGED
@@ -9,7 +9,7 @@ export interface Guideline {
9
9
  const GUIDELINE_IDS_BY_NAME: Record<string, readonly string[]> = {
10
10
  "read-guidelines.md": ["read-reread", "read-outline", "read-match-served"],
11
11
  "replace-guidelines.md": ["replace-disjoint", "replace-deletion", "replace-single-line"],
12
- "replace-match-guidelines.md": ["replace-match-fragment", "replace-match-wide-range"],
12
+ "replace-match-guidelines.md": ["replace-match-fragment", "replace-match-wide-range", "replace-match-whole-range"],
13
13
  "insert-guidelines.md": ["insert-blank-edges"],
14
14
  "copy-guidelines.md": ["copy-single-anchor"],
15
15
  "move-guidelines.md": ["move-same-place"],
@@ -1,5 +1,6 @@
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";
3
4
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
4
5
  import { commitEdit } from "./commit";
5
6
  import { readNormFile, type NormFile } from "./file-reader";
@@ -16,6 +17,8 @@ import {
16
17
  } from "./payload-contract";
17
18
  import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
18
19
  import { toLF } from "./normalize";
20
+ import { isFidelitySensitiveChar } from "./edit-fidelity";
21
+ import { countNewlines } from "./line-endings";
19
22
  import { MAX_RANGE_STALE_LINES } from "./constants";
20
23
  import {
21
24
  assertBoundaryLinesServed,
@@ -40,6 +43,7 @@ interface MatchRefs {
40
43
 
41
44
  export interface MatchPlan {
42
45
  editParams: { remove_from: string; remove_to: string; text: string };
46
+ warnings: string[];
43
47
  servedOverride?: ReadonlyMap<string, string>;
44
48
  }
45
49
 
@@ -61,12 +65,125 @@ function replaceAllOccurrences(text: string, oldText: string, newText: string):
61
65
  return count === 0 ? { text, count: 0 } : { text: out + text.slice(cursor), count };
62
66
  }
63
67
 
64
- function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[]): string {
68
+ const RETYPE_DIFF_MAX_CHARS = 20_000;
69
+ const RETYPE_MAX_EDIT_LENGTH = 2000;
70
+ const RETYPE_MIN_UNCHANGED_CHARS = 40;
71
+ const RETYPE_MIN_UNCHANGED_MULTILINE = 20;
72
+ const RETYPE_EXAMPLE_MAX_CHARS = 40;
73
+ const WHOLE_RANGE_MIN_CHARS = 40;
74
+ const WHOLE_RANGE_MIN_LINES = 2;
75
+ const NEAR_MISS_MIN_OLD_CHARS = 20;
76
+ const NEAR_MISS_MATCH_RATIO = 0.8;
77
+
78
+ interface RetypeAnalysis {
79
+ changed: number;
80
+ unchanged: number;
81
+ middle: { oldFragment: string; newFragment: string };
82
+ }
83
+
84
+ function commonPrefixLength(left: string, right: string): number {
85
+ const limit = Math.min(left.length, right.length);
86
+ let index = 0;
87
+ while (index < limit && left[index] === right[index]) index += 1;
88
+ return index;
89
+ }
90
+
91
+ function commonSuffixLength(left: string, right: string, prefix: number): number {
92
+ const limit = Math.min(left.length, right.length) - prefix;
93
+ let suffix = 0;
94
+ while (suffix < limit && left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix += 1;
95
+ return suffix;
96
+ }
97
+
98
+ function analyzeRetype(oldText: string, newText: string): RetypeAnalysis {
99
+ const prefix = commonPrefixLength(oldText, newText);
100
+ const suffix = commonSuffixLength(oldText, newText, prefix);
101
+ const middle = {
102
+ oldFragment: oldText.slice(prefix, oldText.length - suffix),
103
+ newFragment: newText.slice(prefix, newText.length - suffix),
104
+ };
105
+ if (oldText.length + newText.length <= RETYPE_DIFF_MAX_CHARS) {
106
+ const parts = Diff.diffChars(oldText, newText, { maxEditLength: RETYPE_MAX_EDIT_LENGTH }) as Array<{ added?: boolean; removed?: boolean; value: string }> | undefined;
107
+ if (parts !== undefined) {
108
+ let changed = 0;
109
+ let removed = 0;
110
+ for (const part of parts) {
111
+ if (part.added === true) changed += part.value.length;
112
+ else if (part.removed === true) {
113
+ changed += part.value.length;
114
+ removed += part.value.length;
115
+ }
116
+ }
117
+ return { changed, unchanged: oldText.length - removed, middle };
118
+ }
119
+ }
120
+ return { changed: middle.oldFragment.length + middle.newFragment.length, unchanged: prefix + suffix, middle };
121
+ }
122
+
123
+ function retypeSuggestion(analysis: RetypeAnalysis): string {
124
+ const { oldFragment, newFragment } = analysis.middle;
125
+ if (oldFragment.length === 0 && newFragment.length === 0) return "Send only the fragment that changes instead.";
126
+ if (oldFragment.length === 0) return "Send the added text with insert instead.";
127
+ if (
128
+ oldFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
129
+ newFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
130
+ oldFragment.includes("\n") ||
131
+ newFragment.includes("\n")
132
+ ) {
133
+ return "Send only the fragment that changes instead.";
134
+ }
135
+ return `Send old_string ${JSON.stringify(oldFragment)} and new_string ${JSON.stringify(newFragment)} instead.`;
136
+ }
137
+
138
+ function retypeWarning(oldText: string, newText: string): string | undefined {
139
+ const analysis = analyzeRetype(oldText, newText);
140
+ if (analysis.changed === 0) return undefined;
141
+ const minUnchanged = oldText.includes("\n") ? RETYPE_MIN_UNCHANGED_MULTILINE : RETYPE_MIN_UNCHANGED_CHARS;
142
+ if (analysis.unchanged < minUnchanged) return undefined;
143
+ const lineCount = oldText.split("\n").length;
144
+ return `[W_REPLACE_MATCH_RETYPE] "old_string" retypes ${analysis.unchanged} unchanged character(s) across ${lineCount} line(s). ${retypeSuggestion(analysis)}`;
145
+ }
146
+
147
+ function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number, start: number, end: number, startHash: string, endHash: string): string | undefined {
148
+ if (oldText !== rangeText) return undefined;
149
+ if (lineCount < WHOLE_RANGE_MIN_LINES && oldText.length < WHOLE_RANGE_MIN_CHARS) return undefined;
150
+ 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
+ }
152
+
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
+ function describeChar(char: string | undefined): string {
158
+ if (char === undefined) return "the end of the text";
159
+ const codePoint = char.codePointAt(0)!;
160
+ const shown = JSON.stringify(char);
161
+ return isFidelitySensitiveChar(char) || codePoint < 0x20 || codePoint > 0x7e
162
+ ? `${shown} (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`
163
+ : shown;
164
+ }
165
+
166
+ function nearMissDetail(oldText: string, rangeText: string, start: number): string | undefined {
167
+ if (oldText.length < NEAR_MISS_MIN_OLD_CHARS && !oldText.includes("\n")) return undefined;
168
+ const prefix = commonPrefixLength(oldText, rangeText);
169
+ const suffix = commonSuffixLength(oldText, rangeText, prefix);
170
+ const comparable = Math.min(oldText.length, rangeText.length);
171
+ if (prefix + suffix < Math.floor(comparable * NEAR_MISS_MATCH_RATIO)) return undefined;
172
+ const lineOffset = countNewlines(rangeText.slice(0, prefix));
173
+ const column = prefix - rangeText.lastIndexOf("\n", prefix - 1);
174
+ return `the first mismatch is at line ${start + lineOffset}, col ${column}: the file has ${describeChar(rangeText[prefix])} where old_string has ${describeChar(oldText[prefix])}.`;
175
+ }
176
+
177
+ function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, oldText: string): string {
65
178
  const rangeLength = end - start + 1;
66
179
  const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
67
180
  const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
68
181
  const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
69
- return `[E_SUBSTRING_NOT_FOUND] "old_string" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\nCopy old_string exactly from the served row (comparison uses LF breaks and excludes the last line's terminator) and retry.`;
182
+ const nearMiss = nearMissDetail(oldText, rangeText, start);
183
+ const advice = nearMiss !== undefined
184
+ ? `${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.`
185
+ : "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.";
186
+ 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}`;
70
187
  }
71
188
 
72
189
  export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warnings: string[] } {
@@ -92,19 +209,30 @@ export function buildReplaceMatchEdit(
92
209
  const rangeLines = fileLines.slice(start - 1, end);
93
210
  const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
94
211
  const oldText = toLF(req.old_string);
212
+ const newText = toLF(req.new_string);
95
213
  const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
96
214
  if (count === 0) {
97
- throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines));
215
+ throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
98
216
  }
99
217
  const startRef = fromLine <= toLine ? refs.from : refs.to;
100
218
  const endRef = fromLine <= toLine ? refs.to : refs.from;
101
219
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
220
+ const warnings: string[] = [];
221
+ const retype = retypeWarning(oldText, newText);
222
+ if (retype !== undefined) {
223
+ warnings.push(retype);
224
+ } else {
225
+ const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
226
+ if (wholeRange !== undefined) warnings.push(wholeRange);
227
+ }
228
+ if (count > 1) warnings.push(multiMatchWarning(count, start, end, displayPath));
102
229
  return {
103
230
  editParams: {
104
231
  remove_from: startRef.hash,
105
232
  remove_to: endRef.hash,
106
233
  text: replacement,
107
234
  },
235
+ warnings,
108
236
  ...(servedOverride !== undefined ? { servedOverride } : {}),
109
237
  };
110
238
  }
@@ -127,7 +255,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
127
255
  });
128
256
  return {
129
257
  hedit,
130
- warnings: [...warnings, ...hints, ...resWarnings],
258
+ warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
131
259
  planned,
132
260
  foldedLines: 0,
133
261
  ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
@@ -157,6 +285,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
157
285
  signal,
158
286
  });
159
287
  const plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
288
+ await throwIfStrictInput(plan.warnings);
160
289
  const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
161
290
  accessMode: constants.R_OK,
162
291
  noPersist: true,
@@ -237,6 +366,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
237
366
  await noteAnchorError(mutationTargetPath, error);
238
367
  throw error;
239
368
  }
369
+ await throwIfStrictInput(plan.warnings);
240
370
  const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
241
371
  accessMode: constants.R_OK | constants.W_OK,
242
372
  signal,
@@ -248,7 +378,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
248
378
  absolutePath,
249
379
  mutationTargetPath,
250
380
  editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
251
- prefixWarnings: [...warnings, ...hints],
381
+ prefixWarnings: [...warnings, ...hints, ...plan.warnings],
252
382
  signal,
253
383
  verb: "replaced",
254
384
  noopNoun: "Replacement",
@@ -1,2 +1,3 @@
1
- - `replace_match`: `old_string` is usually a fragment of the line, not the whole line; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
2
- - `replace_match`: when you intend to change every matching line in a range, anchor the first and last matching lines of that range so one call replaces them all, instead of one call per line.
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,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), `old_string` is the text to find inside it, and `new_string` replaces every occurrence. `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 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.
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.