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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.6",
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",
@@ -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 pos = nearestNew(candidates, entry.index);
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;
@@ -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
  }
@@ -16,6 +16,7 @@ export type RRState = {
16
16
  argsKey?: string;
17
17
  preview?: RPreview;
18
18
  previewGeneration?: number;
19
+ previewTimer?: ReturnType<typeof setTimeout>;
19
20
  };
20
21
 
21
22
  export function getPreviewInput(
@@ -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(path: string, entry: UndoEntry): Promise<boolean> {
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
- compPreview(args, context.cwd)
339
- .then((preview) => {
340
- if (
341
- context.state.argsKey === argsKey &&
342
- context.state.previewGeneration === previewGeneration
343
- ) {
344
- context.state.preview = preview;
345
- context.invalidate();
346
- }
347
- })
348
- .catch((err: unknown) => {
349
- if (
350
- context.state.argsKey === argsKey &&
351
- context.state.previewGeneration === previewGeneration
352
- ) {
353
- context.state.preview = {
354
- error: err instanceof Error ? err.message : String(err),
355
- };
356
- context.invalidate();
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 writeAtomic(
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 (!undoPersisted) {
473
- warnings.push(
474
- "Undo history could not be persisted; undo_last_replace will not be available for this edit.",
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;