pi-hashline-edit-pro 1.0.6 → 1.0.8

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
@@ -90,7 +90,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
90
90
  | Field | Description |
91
91
  | --- | --- |
92
92
  | `hash_range_inclusive` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
- | `content_lines` | Replacement content, one string per line. Use `[]` to delete the range. |
93
+ | `content_lines` | Replacement content, one string per line; entries must not contain line breaks. Use `[]` to delete the range. |
94
94
 
95
95
  Behavior:
96
96
 
@@ -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.
@@ -154,7 +155,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
154
155
 
155
156
  | Code | Meaning |
156
157
  | --- | --- |
157
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
158
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields, or a `content_lines` entry contains a line break. |
158
159
  | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
159
160
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
160
161
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
@@ -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
@@ -181,10 +183,18 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
181
183
 
182
184
  - **Stale anchors fail, per line.** A hash mismatch means that line's content changed since the last `read`. The error says so and, when only one anchor of a pair is stale, shows the current lines around the still-valid anchor so the range can be re-located without a full re-read. Mismatched anchors are never silently relocated to a "close enough" line — correctness over convenience.
183
185
  - **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
184
- - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
186
+ - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
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.8",
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",
@@ -1,3 +1,4 @@
1
1
  - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
2
2
  - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
3
- - `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
3
+ - `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
4
+ - `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
package/src/file-kind.ts CHANGED
@@ -39,12 +39,6 @@ function isTextType(mimeType: string): boolean {
39
39
  return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
40
40
  }
41
41
 
42
- export type FKind =
43
- | { kind: "directory" }
44
- | { kind: "image"; mimeType: string }
45
- | { kind: "text" }
46
- | { kind: "binary"; description: string };
47
-
48
42
  export type LFile =
49
43
  | { kind: "directory" }
50
44
  | { kind: "image"; mimeType: string }
@@ -149,17 +143,3 @@ export async function loadFileKindAndText(
149
143
  await fileHandle.close();
150
144
  }
151
145
  }
152
-
153
- export async function classifyFileKind(filePath: string): Promise<FKind> {
154
- const loaded = await loadFileKindAndText(filePath);
155
- switch (loaded.kind) {
156
- case "directory":
157
- return loaded;
158
- case "image":
159
- return loaded;
160
- case "binary":
161
- return loaded;
162
- case "text":
163
- return { kind: "text" };
164
- }
165
- }
package/src/fs-write.ts CHANGED
@@ -9,8 +9,6 @@ import {
9
9
  rm,
10
10
  stat,
11
11
  writeFile,
12
- copyFile,
13
- chmod,
14
12
  } from "fs/promises";
15
13
  import { dirname, join, parse, resolve, sep } from "path";
16
14
  import { errCode } from "./utils";
@@ -153,20 +151,6 @@ export async function writeAtomic(
153
151
  await rename(tempPath, targetPath);
154
152
  await syncDir(dir);
155
153
  } catch (error: unknown) {
156
- if (errCode(error) === "EXDEV") {
157
- try {
158
- await copyFile(tempPath, targetPath);
159
- if (existingStats) {
160
- await chmod(targetPath, existingStats.mode & 0o7777);
161
- }
162
- await rm(tempPath, { force: true });
163
- await syncDir(dir);
164
- return;
165
- } catch {
166
- try { await rm(tempPath, { force: true }); } catch {}
167
- throw error;
168
- }
169
- }
170
154
  if (process.platform === "win32" && errCode(error) === "EPERM") {
171
155
  try {
172
156
  await writeFile(targetPath, content, "utf-8");
package/src/hash-store.ts CHANGED
@@ -324,10 +324,6 @@ export function upsertSnapshot(
324
324
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
325
325
  }
326
326
 
327
- export function deleteSnapshot(store: HashStore, path: string): void {
328
- store.stmts.deleteOne(path);
329
- }
330
-
331
327
  export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
332
328
  store.stmts.undoUpsert(
333
329
  path,
@@ -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;
@@ -46,5 +46,11 @@ export function parseText(edit: string[] | string | null): string[] {
46
46
  if (typeof edit === "string") {
47
47
  throw new Error(CONTENT_LINES_NOT_STRING_MSG);
48
48
  }
49
+ const lineBreakIndex = edit.findIndex((line) => /[\r\n]/.test(line));
50
+ if (lineBreakIndex >= 0) {
51
+ throw new Error(
52
+ `[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
53
+ );
54
+ }
49
55
  return edit;
50
56
  }
@@ -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
@@ -47,7 +47,7 @@ import { loadHashStore, type HashStore } from "./hash-store";
47
47
 
48
48
  const contentLinesSchema = Type.Array(Type.String(), {
49
49
  description:
50
- "Replacement content, one string per line. Use [] to delete the range."
50
+ "Replacement content, one string per line; entries must not contain line breaks. Use [] to delete the range."
51
51
  });
52
52
 
53
53
  const hashRangeInclSchema = Type.Array(
@@ -78,7 +78,6 @@ export type ReplaceDetails = {
78
78
  firstChangedLine?: number;
79
79
  snapshotId?: string;
80
80
  classification?: "noop";
81
- structureOutline?: string[];
82
81
  metrics?: RMetrics;
83
82
  };
84
83
 
@@ -99,6 +98,8 @@ interface PipelineResult {
99
98
  totalRemovedLines: number;
100
99
  }
101
100
 
101
+ const PREVIEW_DEBOUNCE_MS = 150;
102
+
102
103
  const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
103
104
 
104
105
  const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
@@ -318,12 +319,20 @@ export function buildToolDef(): ToolDef {
318
319
  renderShell: "default",
319
320
  renderCall(args, theme, context) {
320
321
  const previewInput = getPreviewInput(args);
322
+ const cancelPendingPreview = () => {
323
+ if (context.state.previewTimer) {
324
+ clearTimeout(context.state.previewTimer);
325
+ context.state.previewTimer = undefined;
326
+ }
327
+ };
321
328
  if (context.executionStarted) {
329
+ cancelPendingPreview();
322
330
  context.state.argsKey = undefined;
323
331
  context.state.preview = undefined;
324
332
  context.state.previewGeneration =
325
333
  (context.state.previewGeneration ?? 0) + 1;
326
334
  } else if (!context.argsComplete || !previewInput) {
335
+ cancelPendingPreview();
327
336
  context.state.argsKey = undefined;
328
337
  context.state.preview = undefined;
329
338
  context.state.previewGeneration =
@@ -331,31 +340,35 @@ export function buildToolDef(): ToolDef {
331
340
  } else {
332
341
  const argsKey = JSON.stringify(previewInput);
333
342
  if (context.state.argsKey !== argsKey) {
343
+ cancelPendingPreview();
334
344
  context.state.argsKey = argsKey;
335
345
  context.state.preview = undefined;
336
346
  const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
337
347
  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
- });
348
+ context.state.previewTimer = setTimeout(() => {
349
+ context.state.previewTimer = undefined;
350
+ compPreview(args, context.cwd)
351
+ .then((preview) => {
352
+ if (
353
+ context.state.argsKey === argsKey &&
354
+ context.state.previewGeneration === previewGeneration
355
+ ) {
356
+ context.state.preview = preview;
357
+ context.invalidate();
358
+ }
359
+ })
360
+ .catch((err: unknown) => {
361
+ if (
362
+ context.state.argsKey === argsKey &&
363
+ context.state.previewGeneration === previewGeneration
364
+ ) {
365
+ context.state.preview = {
366
+ error: err instanceof Error ? err.message : String(err),
367
+ };
368
+ context.invalidate();
369
+ }
370
+ });
371
+ }, PREVIEW_DEBOUNCE_MS);
359
372
  }
360
373
  }
361
374
  const text =
@@ -384,6 +397,10 @@ export function buildToolDef(): ToolDef {
384
397
 
385
398
  const renderState = context.state as RRState | undefined;
386
399
  if (renderState) {
400
+ if (renderState.previewTimer) {
401
+ clearTimeout(renderState.previewTimer);
402
+ renderState.previewTimer = undefined;
403
+ }
387
404
  renderState.preview = undefined;
388
405
  renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
389
406
  }
@@ -458,21 +475,27 @@ export function buildToolDef(): ToolDef {
458
475
  }
459
476
 
460
477
  abortIf(signal);
461
- await writeAtomic(
462
- absolutePath,
463
- bom + restoreEndings(result, originalEnding),
464
- );
465
- const undoPersisted = await saveUndo(mutationTargetPath, {
478
+ const undo = await saveUndo(mutationTargetPath, {
466
479
  content: originalNormalized,
467
480
  bom,
468
481
  originalEnding,
469
482
  hashes: originalHashes,
470
483
  resultContent: result,
471
484
  });
472
- if (!undoPersisted) {
473
- warnings.push(
474
- "Undo history could not be persisted; undo_last_replace will not be available for this edit.",
485
+ if (!undo.persisted) {
486
+ throw new Error(
487
+ `[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.`
488
+ );
489
+ }
490
+ try {
491
+ abortIf(signal);
492
+ await writeAtomic(
493
+ absolutePath,
494
+ bom + restoreEndings(result, originalEnding),
475
495
  );
496
+ } catch (error) {
497
+ await undo.restore();
498
+ throw error;
476
499
  }
477
500
  const updatedSnapshotId = (await fileSnap(absolutePath))
478
501
  .snapshotId;
package/src/validation.ts CHANGED
@@ -35,8 +35,3 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
35
35
  }
36
36
  }
37
37
 
38
-
39
- export function isText(file: LFile): file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
40
- return file.kind === "text";
41
- }
42
-