pi-hashline-edit-pro 6.4.1 → 6.4.3

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; 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, 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
+
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,13 @@ 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. |
464
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. |
465
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. |
466
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. |
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 { 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";
@@ -154,8 +154,7 @@ export default function (pi: ExtensionAPI): void {
154
154
  await initHasher();
155
155
  loadHashStore()
156
156
  .then(async store => {
157
- const missing = await pruneMissing(store);
158
- for (const path of missing) freeAnchors(path);
157
+ freeAnchorsForPaths(await pruneMissing(store));
159
158
  })
160
159
  .catch(err => {
161
160
  console.error("Failed to load hash store:", err);
@@ -210,7 +209,7 @@ export default function (pi: ExtensionAPI): void {
210
209
  if (modelDisabled(ctx.model, disableOnModels)) return;
211
210
  autoReadAllInjected = true;
212
211
  try {
213
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, autoReadAllRequireGit);
212
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model, autoReadAll), autoReadAll, autoReadAllIgnore, autoReadAllRequireGit);
214
213
  if (!injection) return;
215
214
  if (ctx.hasUI) ctx.ui.notify(`Auto-read all: ${isOutlineAutoReadAll(autoReadAll) ? "outlined" : "attached"} ${injection.files} file(s)`, "info");
216
215
  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.3",
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",
@@ -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;
@@ -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
  }
@@ -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"];
@@ -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,14 +111,24 @@ 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;
120
130
  getState: (...params: SqlParams) => Record<string, unknown> | undefined;
121
- allPaths: (...params: SqlParams) => Record<string, unknown>[];
131
+ snapshotPaths: (...params: SqlParams) => Record<string, unknown>[];
122
132
  deleteOne: (...params: SqlParams) => void;
123
133
  upsert: (...params: SqlParams) => void;
124
134
  undoUpsert: (...params: SqlParams) => void;
@@ -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);
@@ -216,7 +226,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
216
226
  ).run(String(HASH_STORE_VERSION));
217
227
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
218
228
  const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
219
- const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
229
+ const snapshotPathsStmt = db.prepare("SELECT path FROM snapshots");
220
230
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
221
231
  const upsertStmt = db.prepare(
222
232
  "INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
@@ -235,7 +245,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
235
245
  const stmts: Prepared = {
236
246
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
237
247
  getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
238
- allPaths: (...params) => withBusyRetry(() => allStmt.all(...params) as Record<string, unknown>[]),
248
+ snapshotPaths: (...params) => withBusyRetry(() => snapshotPathsStmt.all(...params) as Record<string, unknown>[]),
239
249
  deleteOne: retriedWrite(delStmt),
240
250
  upsert: retriedWrite(upsertStmt),
241
251
  undoUpsert: retriedWrite(undoUpsertStmt),
@@ -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;
@@ -629,7 +640,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
629
640
  }
630
641
 
631
642
  export async function pruneMissing(store: HashStore): Promise<string[]> {
632
- const rows = store.stmts.allPaths() as { path: string }[];
643
+ const rows = store.stmts.snapshotPaths() as { path: string }[];
633
644
  const missing = await statMissing(rows);
634
645
  if (missing.length === 0) return [];
635
646
  withStore(() => {
@@ -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. 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:
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,121 @@ 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 describeChar(char: string | undefined): string {
154
+ if (char === undefined) return "the end of the text";
155
+ const codePoint = char.codePointAt(0)!;
156
+ const shown = JSON.stringify(char);
157
+ return isFidelitySensitiveChar(char) || codePoint < 0x20 || codePoint > 0x7e
158
+ ? `${shown} (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`
159
+ : shown;
160
+ }
161
+
162
+ function nearMissDetail(oldText: string, rangeText: string, start: number): string | undefined {
163
+ if (oldText.length < NEAR_MISS_MIN_OLD_CHARS && !oldText.includes("\n")) return undefined;
164
+ const prefix = commonPrefixLength(oldText, rangeText);
165
+ const suffix = commonSuffixLength(oldText, rangeText, prefix);
166
+ const comparable = Math.min(oldText.length, rangeText.length);
167
+ if (prefix + suffix < Math.floor(comparable * NEAR_MISS_MATCH_RATIO)) return undefined;
168
+ const lineOffset = countNewlines(rangeText.slice(0, prefix));
169
+ const column = prefix - rangeText.lastIndexOf("\n", prefix - 1);
170
+ return `the first mismatch is at line ${start + lineOffset}, col ${column}: the file has ${describeChar(rangeText[prefix])} where old_string has ${describeChar(oldText[prefix])}.`;
171
+ }
172
+
173
+ function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, oldText: string): string {
65
174
  const rangeLength = end - start + 1;
66
175
  const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
67
176
  const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
68
177
  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.`;
178
+ const nearMiss = nearMissDetail(oldText, rangeText, start);
179
+ const advice = nearMiss !== undefined
180
+ ? `${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.`
181
+ : "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.";
182
+ 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
183
  }
71
184
 
72
185
  export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warnings: string[] } {
@@ -92,19 +205,29 @@ export function buildReplaceMatchEdit(
92
205
  const rangeLines = fileLines.slice(start - 1, end);
93
206
  const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
94
207
  const oldText = toLF(req.old_string);
208
+ const newText = toLF(req.new_string);
95
209
  const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
96
210
  if (count === 0) {
97
- throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines));
211
+ throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
98
212
  }
99
213
  const startRef = fromLine <= toLine ? refs.from : refs.to;
100
214
  const endRef = fromLine <= toLine ? refs.to : refs.from;
101
215
  const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
216
+ const warnings: string[] = [];
217
+ const retype = retypeWarning(oldText, newText);
218
+ if (retype !== undefined) {
219
+ warnings.push(retype);
220
+ } else {
221
+ const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
222
+ if (wholeRange !== undefined) warnings.push(wholeRange);
223
+ }
102
224
  return {
103
225
  editParams: {
104
226
  remove_from: startRef.hash,
105
227
  remove_to: endRef.hash,
106
228
  text: replacement,
107
229
  },
230
+ warnings,
108
231
  ...(servedOverride !== undefined ? { servedOverride } : {}),
109
232
  };
110
233
  }
@@ -127,7 +250,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
127
250
  });
128
251
  return {
129
252
  hedit,
130
- warnings: [...warnings, ...hints, ...resWarnings],
253
+ warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
131
254
  planned,
132
255
  foldedLines: 0,
133
256
  ...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
@@ -157,6 +280,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
157
280
  signal,
158
281
  });
159
282
  const plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
283
+ await throwIfStrictInput(plan.warnings);
160
284
  const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
161
285
  accessMode: constants.R_OK,
162
286
  noPersist: true,
@@ -237,6 +361,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
237
361
  await noteAnchorError(mutationTargetPath, error);
238
362
  throw error;
239
363
  }
364
+ await throwIfStrictInput(plan.warnings);
240
365
  const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
241
366
  accessMode: constants.R_OK | constants.W_OK,
242
367
  signal,
@@ -248,7 +373,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
248
373
  absolutePath,
249
374
  mutationTargetPath,
250
375
  editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
251
- prefixWarnings: [...warnings, ...hints],
376
+ prefixWarnings: [...warnings, ...hints, ...plan.warnings],
252
377
  signal,
253
378
  verb: "replaced",
254
379
  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 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), `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 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.