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 +8 -4
- package/index.ts +3 -4
- package/package.json +2 -2
- package/src/anchor-registry.ts +23 -1
- package/src/auto-read-all.ts +7 -2
- package/src/edit-common.ts +7 -5
- package/src/file-kind.ts +1 -1
- package/src/file-reader.ts +2 -2
- package/src/fs-write.ts +6 -1
- package/src/grep.ts +0 -1
- package/src/hash-store.ts +21 -10
- package/src/payload-contract.ts +1 -1
- package/src/prompts.ts +1 -1
- package/src/replace-match.ts +130 -5
- package/tool-prompts/replace-match-guidelines.md +3 -2
- package/tool-prompts/replace-match.md +1 -1
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/edit-common.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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,
|
package/src/file-reader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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(() => {
|
package/src/payload-contract.ts
CHANGED
|
@@ -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"],
|
package/src/replace-match.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
- `replace_match`:
|
|
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
|
|
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.
|