pi-hashline-edit-pro 1.0.6 → 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 +10 -0
- package/package.json +1 -1
- package/src/hashline/hash.ts +12 -1
- package/src/hashline/resolve.ts +3 -3
- package/src/replace-render.ts +1 -0
- package/src/replace-undo.ts +22 -4
- package/src/replace.ts +53 -29
package/README.md
CHANGED
|
@@ -132,6 +132,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
132
132
|
|
|
133
133
|
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
134
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.
|
|
135
136
|
- A successful `write` clears the history for that file.
|
|
136
137
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
137
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.
|
|
@@ -167,6 +168,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
167
168
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
168
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. |
|
|
169
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. |
|
|
170
172
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
171
173
|
|
|
172
174
|
## Hashing
|
|
@@ -185,6 +187,14 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
185
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.
|
|
186
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.
|
|
187
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
|
+
|
|
188
198
|
## Development
|
|
189
199
|
|
|
190
200
|
Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
|
package/package.json
CHANGED
package/src/hashline/hash.ts
CHANGED
|
@@ -234,6 +234,16 @@ function mapStableHashes(
|
|
|
234
234
|
if (idx !== undefined) removedIndexes.add(idx);
|
|
235
235
|
}
|
|
236
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
|
+
|
|
237
247
|
const survivors: { index: number; hash: string }[] = [];
|
|
238
248
|
const removedEntries: { index: number; hash: string }[] = [];
|
|
239
249
|
for (let i = 0; i < oldLines.length; i++) {
|
|
@@ -261,7 +271,8 @@ function mapStableHashes(
|
|
|
261
271
|
for (const entry of survivors) {
|
|
262
272
|
const candidates = newByContent.get(canon(oldLines[entry.index]!));
|
|
263
273
|
if (!candidates || candidates.length === 0) continue;
|
|
264
|
-
const
|
|
274
|
+
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
275
|
+
const pos = nearestNew(candidates, target);
|
|
265
276
|
if (pos < 0) continue;
|
|
266
277
|
const newIdx = candidates.splice(pos, 1)[0]!;
|
|
267
278
|
newHashes[newIdx] = entry.hash;
|
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/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> {
|
|
@@ -109,6 +125,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
if (currentRaw === undefined) {
|
|
128
|
+
await clearUndo(mutationTargetPath);
|
|
112
129
|
return {
|
|
113
130
|
content: [
|
|
114
131
|
{
|
|
@@ -121,6 +138,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
121
138
|
};
|
|
122
139
|
}
|
|
123
140
|
if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
|
|
141
|
+
await clearUndo(mutationTargetPath);
|
|
124
142
|
return {
|
|
125
143
|
content: [
|
|
126
144
|
{
|
package/src/replace.ts
CHANGED
|
@@ -99,6 +99,8 @@ 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
106
|
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
|
|
@@ -318,12 +320,20 @@ export function buildToolDef(): ToolDef {
|
|
|
318
320
|
renderShell: "default",
|
|
319
321
|
renderCall(args, theme, context) {
|
|
320
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
|
+
};
|
|
321
329
|
if (context.executionStarted) {
|
|
330
|
+
cancelPendingPreview();
|
|
322
331
|
context.state.argsKey = undefined;
|
|
323
332
|
context.state.preview = undefined;
|
|
324
333
|
context.state.previewGeneration =
|
|
325
334
|
(context.state.previewGeneration ?? 0) + 1;
|
|
326
335
|
} else if (!context.argsComplete || !previewInput) {
|
|
336
|
+
cancelPendingPreview();
|
|
327
337
|
context.state.argsKey = undefined;
|
|
328
338
|
context.state.preview = undefined;
|
|
329
339
|
context.state.previewGeneration =
|
|
@@ -331,31 +341,35 @@ export function buildToolDef(): ToolDef {
|
|
|
331
341
|
} else {
|
|
332
342
|
const argsKey = JSON.stringify(previewInput);
|
|
333
343
|
if (context.state.argsKey !== argsKey) {
|
|
344
|
+
cancelPendingPreview();
|
|
334
345
|
context.state.argsKey = argsKey;
|
|
335
346
|
context.state.preview = undefined;
|
|
336
347
|
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
337
348
|
context.state.previewGeneration = previewGeneration;
|
|
338
|
-
|
|
339
|
-
.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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);
|
|
359
373
|
}
|
|
360
374
|
}
|
|
361
375
|
const text =
|
|
@@ -384,6 +398,10 @@ export function buildToolDef(): ToolDef {
|
|
|
384
398
|
|
|
385
399
|
const renderState = context.state as RRState | undefined;
|
|
386
400
|
if (renderState) {
|
|
401
|
+
if (renderState.previewTimer) {
|
|
402
|
+
clearTimeout(renderState.previewTimer);
|
|
403
|
+
renderState.previewTimer = undefined;
|
|
404
|
+
}
|
|
387
405
|
renderState.preview = undefined;
|
|
388
406
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
389
407
|
}
|
|
@@ -458,21 +476,27 @@ export function buildToolDef(): ToolDef {
|
|
|
458
476
|
}
|
|
459
477
|
|
|
460
478
|
abortIf(signal);
|
|
461
|
-
await
|
|
462
|
-
absolutePath,
|
|
463
|
-
bom + restoreEndings(result, originalEnding),
|
|
464
|
-
);
|
|
465
|
-
const undoPersisted = await saveUndo(mutationTargetPath, {
|
|
479
|
+
const undo = await saveUndo(mutationTargetPath, {
|
|
466
480
|
content: originalNormalized,
|
|
467
481
|
bom,
|
|
468
482
|
originalEnding,
|
|
469
483
|
hashes: originalHashes,
|
|
470
484
|
resultContent: result,
|
|
471
485
|
});
|
|
472
|
-
if (!
|
|
473
|
-
|
|
474
|
-
|
|
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),
|
|
475
496
|
);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
await undo.restore();
|
|
499
|
+
throw error;
|
|
476
500
|
}
|
|
477
501
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
478
502
|
.snapshotId;
|