pi-hashline-edit-pro 1.0.5 → 1.0.7
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 +15 -2
- package/index.ts +2 -0
- package/package.json +2 -2
- package/src/constants.ts +1 -0
- package/src/hashline/hash.ts +13 -1
- package/src/hashline/hasher.ts +1 -1
- package/src/hashline/resolve.ts +3 -3
- package/src/read.ts +34 -5
- package/src/replace-render.ts +1 -0
- package/src/replace-undo.ts +28 -10
- package/src/replace.ts +56 -36
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ Optional parameters:
|
|
|
62
62
|
|
|
63
63
|
Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
|
|
64
64
|
|
|
65
|
+
Lines up to 200KB are displayed in full; larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`) since hash anchors require full lines.
|
|
66
|
+
|
|
65
67
|
Edge cases:
|
|
66
68
|
|
|
67
69
|
- **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
|
|
@@ -93,7 +95,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
|
|
|
93
95
|
Behavior:
|
|
94
96
|
|
|
95
97
|
- **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
|
|
96
|
-
- **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[
|
|
98
|
+
- **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
|
|
97
99
|
- **Autocorrections** (all accompanied by a warning unless noted):
|
|
98
100
|
- A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
|
|
99
101
|
- Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
|
|
@@ -120,6 +122,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
120
122
|
|
|
121
123
|
- After `replace` / `undo_last_replace`, the block covers the changed span plus 2 lines of context above and below — the rest of the file keeps its anchors from the persistent store.
|
|
122
124
|
- After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
|
|
125
|
+
- Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
|
|
123
126
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
124
127
|
- If the auto-read itself fails (e.g. the file was deleted between the operation and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
|
|
125
128
|
|
|
@@ -129,6 +132,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
129
132
|
|
|
130
133
|
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
131
134
|
- History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
|
|
135
|
+
- **Undo is a precondition, not a convenience.** The undo record is persisted *before* the edit is written; if it cannot be persisted, the `replace` is refused with `[E_UNDO_UNAVAILABLE]` and the file is not touched, so every applied edit is undoable. If the file write itself then fails, the previous undo record is restored, so a refused edit never destroys earlier undo history.
|
|
132
136
|
- A successful `write` clears the history for that file.
|
|
133
137
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
134
138
|
- **Safety guard.** If the file was modified or deleted since the last replace, `undo_last_replace` refuses with `[E_UNDO_STALE]` rather than overwriting those changes.
|
|
@@ -157,13 +161,14 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
157
161
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
158
162
|
| `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
|
|
159
163
|
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
|
|
160
|
-
| `[E_LEGACY_SHAPE]` | The request uses
|
|
164
|
+
| `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
|
|
161
165
|
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
162
166
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
163
167
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
164
168
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
165
169
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
166
170
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
171
|
+
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
167
172
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
168
173
|
|
|
169
174
|
## Hashing
|
|
@@ -182,6 +187,14 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
182
187
|
- **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
|
|
183
188
|
- **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
|
|
184
189
|
|
|
190
|
+
## Troubleshooting
|
|
191
|
+
|
|
192
|
+
- **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
|
|
193
|
+
- **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
194
|
+
- **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
|
|
195
|
+
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
|
|
196
|
+
- **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
|
|
197
|
+
|
|
185
198
|
## Development
|
|
186
199
|
|
|
187
200
|
Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
|
package/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { initHasher } from "./src/hashline";
|
|
3
4
|
import { regReplace } from "./src/replace";
|
|
4
5
|
import { regReplaceUndo, clearUndo } from "./src/replace-undo";
|
|
@@ -98,6 +99,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
98
99
|
{ offset, limit },
|
|
99
100
|
fileHashes,
|
|
100
101
|
absolutePath,
|
|
102
|
+
DEFAULT_MAX_BYTES,
|
|
101
103
|
);
|
|
102
104
|
|
|
103
105
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"scripts": {
|
|
48
48
|
"test": "vitest run",
|
|
49
49
|
"test:watch": "vitest",
|
|
50
|
-
"test:coverage": "vitest run --coverage",
|
|
50
|
+
"test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
|
|
51
51
|
"lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
|
|
52
52
|
"typecheck": "tsc --noEmit"
|
|
53
53
|
},
|
package/src/constants.ts
CHANGED
package/src/hashline/hash.ts
CHANGED
|
@@ -143,6 +143,7 @@ export async function lineHashes(
|
|
|
143
143
|
store?: HashStore,
|
|
144
144
|
persist?: boolean,
|
|
145
145
|
): Promise<string[]> {
|
|
146
|
+
await initHasher();
|
|
146
147
|
if (!path) {
|
|
147
148
|
return _lineHashesPure(content);
|
|
148
149
|
}
|
|
@@ -233,6 +234,16 @@ function mapStableHashes(
|
|
|
233
234
|
if (idx !== undefined) removedIndexes.add(idx);
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
let spanStart = oldLines.length;
|
|
238
|
+
let spanEnd = -1;
|
|
239
|
+
for (const idx of removedIndexes) {
|
|
240
|
+
if (idx < spanStart) spanStart = idx;
|
|
241
|
+
if (idx > spanEnd) spanEnd = idx;
|
|
242
|
+
}
|
|
243
|
+
const spanLen = spanEnd >= spanStart ? spanEnd - spanStart + 1 : 0;
|
|
244
|
+
const replacementLen = newLines.length - oldLines.length + spanLen;
|
|
245
|
+
const shiftAfterSpan = spanEnd >= spanStart ? replacementLen - spanLen : 0;
|
|
246
|
+
|
|
236
247
|
const survivors: { index: number; hash: string }[] = [];
|
|
237
248
|
const removedEntries: { index: number; hash: string }[] = [];
|
|
238
249
|
for (let i = 0; i < oldLines.length; i++) {
|
|
@@ -260,7 +271,8 @@ function mapStableHashes(
|
|
|
260
271
|
for (const entry of survivors) {
|
|
261
272
|
const candidates = newByContent.get(canon(oldLines[entry.index]!));
|
|
262
273
|
if (!candidates || candidates.length === 0) continue;
|
|
263
|
-
const
|
|
274
|
+
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
275
|
+
const pos = nearestNew(candidates, target);
|
|
264
276
|
if (pos < 0) continue;
|
|
265
277
|
const newIdx = candidates.splice(pos, 1)[0]!;
|
|
266
278
|
newHashes[newIdx] = entry.hash;
|
package/src/hashline/hasher.ts
CHANGED
|
@@ -9,7 +9,7 @@ let hasher: Hasher | null = null;
|
|
|
9
9
|
|
|
10
10
|
export function getH(): Hasher {
|
|
11
11
|
if (hasher) return hasher;
|
|
12
|
-
throw new Error("xxhash-wasm not initialized
|
|
12
|
+
throw new Error("xxhash-wasm hasher not initialized; await initHasher() before calling hashline APIs.");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const hasherP: Promise<Hasher> = xxhash().then((h) => {
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -225,7 +225,7 @@ export function stripBarePrefixes(
|
|
|
225
225
|
? "none of the stripped hashes match current file lines"
|
|
226
226
|
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
227
227
|
warnings.push(
|
|
228
|
-
`Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
|
|
228
|
+
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
|
|
229
229
|
);
|
|
230
230
|
return { ...edit, content_lines: contentLines };
|
|
231
231
|
}
|
|
@@ -251,7 +251,7 @@ export function stripDiffPrefixes(
|
|
|
251
251
|
if (stripped.length === 0) return edit;
|
|
252
252
|
const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
|
|
253
253
|
warnings.push(
|
|
254
|
-
`Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
254
|
+
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
255
255
|
);
|
|
256
256
|
return { ...edit, content_lines: contentLines };
|
|
257
257
|
}
|
|
@@ -276,7 +276,7 @@ export function swapReversedRanges(
|
|
|
276
276
|
return edit;
|
|
277
277
|
}
|
|
278
278
|
warnings.push(
|
|
279
|
-
`Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
279
|
+
`[E_BAD_OP] Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
280
280
|
);
|
|
281
281
|
return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
|
|
282
282
|
}
|
package/src/read.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type TruncationResult,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
|
+
import { MAX_READ_LINE_BYTES } from "./constants";
|
|
9
10
|
import { loadFileKindAndText } from "./file-kind";
|
|
10
11
|
import { readNormFile } from "./file-reader";
|
|
11
12
|
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
@@ -58,6 +59,7 @@ export async function fmtReadPreview(
|
|
|
58
59
|
options: { offset?: number; limit?: number },
|
|
59
60
|
precomputedHashes?: string[],
|
|
60
61
|
path?: string,
|
|
62
|
+
maxLineBytes = MAX_READ_LINE_BYTES,
|
|
61
63
|
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
|
|
62
64
|
const allLines = visLines(text);
|
|
63
65
|
const totalLines = allLines.length;
|
|
@@ -88,15 +90,42 @@ export async function fmtReadPreview(
|
|
|
88
90
|
const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
|
|
89
91
|
const selectedHashes = allHashes.slice(startLine - 1, endIdx);
|
|
90
92
|
const formatted = fmtRegion(selectedHashes, selected);
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
93
|
+
const maxBytes = maxLineBytes;
|
|
94
|
+
const rowSizes = selected.map((line, index) => ({
|
|
95
|
+
lineNumber: startLine + index,
|
|
96
|
+
bytes: Buffer.byteLength(`${selectedHashes[index]}${HASH_SEP}${line}`, "utf-8"),
|
|
97
|
+
}));
|
|
98
|
+
if (rowSizes.some((row) => row.bytes > maxBytes)) {
|
|
99
|
+
const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
|
|
100
|
+
const rows = rowSizes.map((row, index) =>
|
|
101
|
+
row.bytes > maxBytes
|
|
102
|
+
? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
|
|
103
|
+
: fmtRegion([selectedHashes[index]!], [selected[index]!]),
|
|
104
|
+
);
|
|
105
|
+
const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
|
|
106
|
+
const shownRows = rowSizes.filter((row) => row.bytes <= maxBytes);
|
|
107
|
+
const lastShownLine = shownRows.at(-1)?.lineNumber ?? startLine - 1;
|
|
108
|
+
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
109
|
+
const verb = oversized.length === 1 ? "exceeds" : "exceed";
|
|
110
|
+
const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
|
|
111
|
+
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
|
|
112
|
+
let preview = skippedTruncation.content;
|
|
113
|
+
let nextOffset: number | undefined;
|
|
114
|
+
if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
|
115
|
+
nextOffset = lastShownLine + 1;
|
|
116
|
+
preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
|
|
117
|
+
} else {
|
|
118
|
+
preview += `\n\n${warning}`;
|
|
119
|
+
}
|
|
94
120
|
return {
|
|
95
|
-
text:
|
|
96
|
-
truncation,
|
|
121
|
+
text: preview,
|
|
122
|
+
truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
|
|
123
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
97
124
|
};
|
|
98
125
|
}
|
|
99
126
|
|
|
127
|
+
const truncation = truncateHead(formatted, { maxBytes });
|
|
128
|
+
|
|
100
129
|
let preview = truncation.content;
|
|
101
130
|
let nextOffset: number | undefined;
|
|
102
131
|
if (truncation.truncated) {
|
package/src/replace-render.ts
CHANGED
package/src/replace-undo.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
|
|
5
|
+
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
6
|
import { contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
@@ -19,9 +19,14 @@ export interface UndoEntry {
|
|
|
19
19
|
resultContent: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function saveUndo(
|
|
22
|
+
export async function saveUndo(
|
|
23
|
+
path: string,
|
|
24
|
+
entry: UndoEntry,
|
|
25
|
+
): Promise<{ persisted: boolean; restore: () => Promise<void> }> {
|
|
26
|
+
let previous: UndoRecord | undefined;
|
|
23
27
|
try {
|
|
24
28
|
const store = await loadHashStore();
|
|
29
|
+
previous = getUndoEntry(store, path);
|
|
25
30
|
upsertUndo(store, path, {
|
|
26
31
|
content: entry.content,
|
|
27
32
|
bom: entry.bom,
|
|
@@ -29,11 +34,22 @@ export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean>
|
|
|
29
34
|
hashes: entry.hashes,
|
|
30
35
|
resultContent: entry.resultContent,
|
|
31
36
|
});
|
|
32
|
-
return true;
|
|
33
37
|
} catch (error) {
|
|
34
38
|
console.error("Failed to persist undo entry:", error);
|
|
35
|
-
return false;
|
|
39
|
+
return { persisted: false, restore: async () => undefined };
|
|
36
40
|
}
|
|
41
|
+
return {
|
|
42
|
+
persisted: true,
|
|
43
|
+
restore: async () => {
|
|
44
|
+
try {
|
|
45
|
+
const store = await loadHashStore();
|
|
46
|
+
if (previous) upsertUndo(store, path, previous);
|
|
47
|
+
else deleteUndo(store, path);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error("Failed to restore previous undo entry:", error);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
@@ -101,16 +117,15 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
104
|
-
let
|
|
120
|
+
let currentRaw: string | undefined;
|
|
105
121
|
try {
|
|
106
|
-
|
|
107
|
-
const { text: currentStripped } = stripBOM(currentRaw);
|
|
108
|
-
currentNormalized = toLF(currentStripped);
|
|
122
|
+
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
109
123
|
} catch (error) {
|
|
110
124
|
if (errCode(error) !== "ENOENT") throw error;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
if (
|
|
127
|
+
if (currentRaw === undefined) {
|
|
128
|
+
await clearUndo(mutationTargetPath);
|
|
114
129
|
return {
|
|
115
130
|
content: [
|
|
116
131
|
{
|
|
@@ -122,7 +137,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
122
137
|
details: {},
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
|
-
if (
|
|
140
|
+
if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
|
|
141
|
+
await clearUndo(mutationTargetPath);
|
|
126
142
|
return {
|
|
127
143
|
content: [
|
|
128
144
|
{
|
|
@@ -135,6 +151,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
135
151
|
};
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
const { text: currentStripped } = stripBOM(currentRaw);
|
|
155
|
+
const currentNormalized = toLF(currentStripped);
|
|
138
156
|
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
139
157
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
140
158
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
package/src/replace.ts
CHANGED
|
@@ -99,9 +99,11 @@ interface PipelineResult {
|
|
|
99
99
|
totalRemovedLines: number;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
const PREVIEW_DEBOUNCE_MS = 150;
|
|
103
|
+
|
|
102
104
|
const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
|
|
103
105
|
|
|
104
|
-
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
|
|
106
|
+
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
|
|
105
107
|
|
|
106
108
|
export function assertNoLegacyKeys(request: unknown): void {
|
|
107
109
|
if (!isRec(request)) return;
|
|
@@ -251,11 +253,6 @@ export async function compPreview(
|
|
|
251
253
|
): Promise<RPreview> {
|
|
252
254
|
try {
|
|
253
255
|
const normalized = normReq(request);
|
|
254
|
-
if (isRec(request) && Array.isArray(request.changes)) {
|
|
255
|
-
return {
|
|
256
|
-
error: `[E_BAD_SHAPE] The replace tool does not accept a "changes" array. Send hash_range_inclusive and content_lines at the top level (one edit per call).`
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
256
|
assertReq(normalized);
|
|
260
257
|
const { path, originalNormalized, result, resultHashes } = await execPipeline(
|
|
261
258
|
normalized,
|
|
@@ -323,12 +320,20 @@ export function buildToolDef(): ToolDef {
|
|
|
323
320
|
renderShell: "default",
|
|
324
321
|
renderCall(args, theme, context) {
|
|
325
322
|
const previewInput = getPreviewInput(args);
|
|
323
|
+
const cancelPendingPreview = () => {
|
|
324
|
+
if (context.state.previewTimer) {
|
|
325
|
+
clearTimeout(context.state.previewTimer);
|
|
326
|
+
context.state.previewTimer = undefined;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
326
329
|
if (context.executionStarted) {
|
|
330
|
+
cancelPendingPreview();
|
|
327
331
|
context.state.argsKey = undefined;
|
|
328
332
|
context.state.preview = undefined;
|
|
329
333
|
context.state.previewGeneration =
|
|
330
334
|
(context.state.previewGeneration ?? 0) + 1;
|
|
331
335
|
} else if (!context.argsComplete || !previewInput) {
|
|
336
|
+
cancelPendingPreview();
|
|
332
337
|
context.state.argsKey = undefined;
|
|
333
338
|
context.state.preview = undefined;
|
|
334
339
|
context.state.previewGeneration =
|
|
@@ -336,31 +341,35 @@ export function buildToolDef(): ToolDef {
|
|
|
336
341
|
} else {
|
|
337
342
|
const argsKey = JSON.stringify(previewInput);
|
|
338
343
|
if (context.state.argsKey !== argsKey) {
|
|
344
|
+
cancelPendingPreview();
|
|
339
345
|
context.state.argsKey = argsKey;
|
|
340
346
|
context.state.preview = undefined;
|
|
341
347
|
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
342
348
|
context.state.previewGeneration = previewGeneration;
|
|
343
|
-
|
|
344
|
-
.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
349
|
+
context.state.previewTimer = setTimeout(() => {
|
|
350
|
+
context.state.previewTimer = undefined;
|
|
351
|
+
compPreview(args, context.cwd)
|
|
352
|
+
.then((preview) => {
|
|
353
|
+
if (
|
|
354
|
+
context.state.argsKey === argsKey &&
|
|
355
|
+
context.state.previewGeneration === previewGeneration
|
|
356
|
+
) {
|
|
357
|
+
context.state.preview = preview;
|
|
358
|
+
context.invalidate();
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
.catch((err: unknown) => {
|
|
362
|
+
if (
|
|
363
|
+
context.state.argsKey === argsKey &&
|
|
364
|
+
context.state.previewGeneration === previewGeneration
|
|
365
|
+
) {
|
|
366
|
+
context.state.preview = {
|
|
367
|
+
error: err instanceof Error ? err.message : String(err),
|
|
368
|
+
};
|
|
369
|
+
context.invalidate();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
364
373
|
}
|
|
365
374
|
}
|
|
366
375
|
const text =
|
|
@@ -389,6 +398,10 @@ export function buildToolDef(): ToolDef {
|
|
|
389
398
|
|
|
390
399
|
const renderState = context.state as RRState | undefined;
|
|
391
400
|
if (renderState) {
|
|
401
|
+
if (renderState.previewTimer) {
|
|
402
|
+
clearTimeout(renderState.previewTimer);
|
|
403
|
+
renderState.previewTimer = undefined;
|
|
404
|
+
}
|
|
392
405
|
renderState.preview = undefined;
|
|
393
406
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
394
407
|
}
|
|
@@ -410,8 +423,9 @@ export function buildToolDef(): ToolDef {
|
|
|
410
423
|
|
|
411
424
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
412
425
|
const canonical = normReq(params);
|
|
426
|
+
assertReq(canonical);
|
|
413
427
|
|
|
414
|
-
const normalizedParams = canonical
|
|
428
|
+
const normalizedParams = canonical;
|
|
415
429
|
const path = normalizedParams.path;
|
|
416
430
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
417
431
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
@@ -462,21 +476,27 @@ export function buildToolDef(): ToolDef {
|
|
|
462
476
|
}
|
|
463
477
|
|
|
464
478
|
abortIf(signal);
|
|
465
|
-
await
|
|
466
|
-
absolutePath,
|
|
467
|
-
bom + restoreEndings(result, originalEnding),
|
|
468
|
-
);
|
|
469
|
-
const undoPersisted = await saveUndo(mutationTargetPath, {
|
|
479
|
+
const undo = await saveUndo(mutationTargetPath, {
|
|
470
480
|
content: originalNormalized,
|
|
471
481
|
bom,
|
|
472
482
|
originalEnding,
|
|
473
483
|
hashes: originalHashes,
|
|
474
484
|
resultContent: result,
|
|
475
485
|
});
|
|
476
|
-
if (!
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
if (!undo.persisted) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`[E_UNDO_UNAVAILABLE] Cannot persist undo history to the hash store; the edit was NOT applied and ${path} is unchanged. Retry the replace, or use write if the store cannot be recovered.`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
abortIf(signal);
|
|
493
|
+
await writeAtomic(
|
|
494
|
+
absolutePath,
|
|
495
|
+
bom + restoreEndings(result, originalEnding),
|
|
479
496
|
);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
await undo.restore();
|
|
499
|
+
throw error;
|
|
480
500
|
}
|
|
481
501
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
482
502
|
.snapshotId;
|