pi-hashline-edit-pro 1.0.0 → 1.0.2

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
@@ -127,6 +127,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
127
127
  - History is in-memory and is lost when the session ends or the extension reloads.
128
128
  - A successful `write` clears the history for that file.
129
129
  - Call `read` after an undo to get fresh anchors for follow-up edits.
130
+ - **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.
130
131
 
131
132
  ## Commands and configuration
132
133
 
@@ -155,6 +156,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
155
156
  | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
156
157
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
157
158
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
159
+ | `[E_NOT_FOUND]` | The path does not exist. |
160
+ | `[E_ACCESS]` | The file is not readable or writable. |
161
+ | `[E_NOT_TEXT]` | The path is a directory, binary file, or image; hashline editing only supports text files. |
162
+ | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
158
163
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
159
164
 
160
165
  ## Hashing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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
  "test": "vitest run",
48
48
  "test:watch": "vitest",
49
49
  "test:coverage": "vitest run --coverage",
50
- "lint": "eslint 'src/**/*.ts' 'index.ts' 'test/**/*.ts'",
50
+ "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
51
51
  "typecheck": "tsc --noEmit"
52
52
  },
53
53
  "devDependencies": {
@@ -1,4 +1,3 @@
1
- - `replace`: sends exactly one edit per call — hash_range_inclusive and content_lines at the top level of the request, not inside a changes array.
2
1
  - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
3
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.
4
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.
package/src/hash-store.ts CHANGED
@@ -42,6 +42,19 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
42
42
  const db = new DatabaseSync(storePath, {
43
43
  timeout: HASH_STORE_BUSY_TIMEOUT,
44
44
  });
45
+ try {
46
+ return buildStore(db);
47
+ } catch (error) {
48
+ try {
49
+ db.close();
50
+ } catch {}
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ function buildStore(
56
+ db: DatabaseSync,
57
+ ): { db: DatabaseSync; stmts: Prepared } {
45
58
  db.exec("PRAGMA journal_mode = WAL");
46
59
  db.exec("PRAGMA synchronous = NORMAL");
47
60
  db.exec(
@@ -40,6 +40,10 @@ function fmtDiffLine(
40
40
  return `${prefix}${hash}${HASH_SEP}${line}`;
41
41
  }
42
42
 
43
+ const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
44
+ const isEllipsisMarker = (line: string | symbol): line is symbol =>
45
+ line === ELLIPSIS_MARKER;
46
+
43
47
  export function genDiff(
44
48
  oldContent: string,
45
49
  newContent: string,
@@ -78,7 +82,7 @@ export function genDiff(
78
82
  const nextPartIsChange =
79
83
  i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
80
84
  if (lastWasChange || nextPartIsChange) {
81
- let linesToShow = displayLines;
85
+ let linesToShow: (string | symbol)[] = displayLines;
82
86
  let skipStart = 0;
83
87
  let skipMiddle = 0;
84
88
 
@@ -87,7 +91,7 @@ export function genDiff(
87
91
  linesToShow = displayLines.slice(skipStart);
88
92
  } else if (nextPartIsChange && displayLines.length > contextLines * 2) {
89
93
  const tail = displayLines.slice(-contextLines);
90
- linesToShow = [...displayLines.slice(0, contextLines), "__ELLIPSIS__", ...tail];
94
+ linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
91
95
  skipMiddle = displayLines.length - contextLines * 2;
92
96
  } else if (linesToShow.length > contextLines) {
93
97
  linesToShow = linesToShow.slice(0, contextLines);
@@ -98,7 +102,7 @@ export function genDiff(
98
102
  newLineNum += skipStart;
99
103
  }
100
104
  for (const line of linesToShow) {
101
- if (line === "__ELLIPSIS__") {
105
+ if (isEllipsisMarker(line)) {
102
106
  output.push(" ...");
103
107
  newLineNum += skipMiddle;
104
108
  continue;
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
9
9
  import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
10
- import { cntDiff, splitLines } from "./utils";
10
+ import { cntDiff, splitLines, errCode } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
13
13
  import { changedRange } from "./hashline";
@@ -16,6 +16,7 @@ export interface UndoEntry {
16
16
  bom: string;
17
17
  originalEnding: "\r\n" | "\n";
18
18
  hashes: string[];
19
+ resultContent: string;
19
20
  }
20
21
 
21
22
  const undoMap = new Map<string, UndoEntry>();
@@ -66,13 +67,38 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
66
67
  }
67
68
 
68
69
  return withFileMutationQueue(mutationTargetPath, async () => {
69
- let currentNormalized = "";
70
+ let currentNormalized: string | undefined;
70
71
  try {
71
72
  const currentRaw = await readFile(mutationTargetPath, "utf-8");
72
73
  const { text: currentStripped } = stripBOM(currentRaw);
73
74
  currentNormalized = toLF(currentStripped);
74
- } catch {
75
- currentNormalized = "";
75
+ } catch (error) {
76
+ if (errCode(error) !== "ENOENT") throw error;
77
+ }
78
+
79
+ if (currentNormalized === undefined) {
80
+ return {
81
+ content: [
82
+ {
83
+ type: "text",
84
+ text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists. Call read() to inspect the current state.`
85
+ },
86
+ ],
87
+ isError: true,
88
+ details: {},
89
+ };
90
+ }
91
+ if (currentNormalized !== undo.resultContent) {
92
+ return {
93
+ content: [
94
+ {
95
+ type: "text",
96
+ text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file was modified after the replace, so undoing would overwrite those changes. Call read() to inspect the current state.`
97
+ },
98
+ ],
99
+ isError: true,
100
+ details: {},
101
+ };
76
102
  }
77
103
 
78
104
  const diffResult = genDiff(undo.content, currentNormalized, 0);
package/src/replace.ts CHANGED
@@ -466,6 +466,7 @@ export function buildToolDef(): ToolDef {
466
466
  bom,
467
467
  originalEnding,
468
468
  hashes: originalHashes,
469
+ resultContent: result,
469
470
  });
470
471
  const updatedSnapshotId = (await fileSnap(absolutePath))
471
472
  .snapshotId;
package/src/validation.ts CHANGED
@@ -13,25 +13,25 @@ export async function valAccess(
13
13
  } catch (error: unknown) {
14
14
  const code = errCode(error);
15
15
  if (code === "ENOENT") {
16
- throw new Error(`File not found: ${path}`);
16
+ throw new Error(`[E_NOT_FOUND] File not found: ${path}`);
17
17
  }
18
18
  if (code === "EACCES" || code === "EPERM") {
19
19
  const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
20
- throw new Error(`File is ${accessLabel}: ${path}`);
20
+ throw new Error(`[E_ACCESS] File is ${accessLabel}: ${path}`);
21
21
  }
22
- throw new Error(`Cannot access file: ${path}`);
22
+ throw new Error(`[E_ACCESS] Cannot access file: ${path}`);
23
23
  }
24
24
  }
25
25
 
26
26
  export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
27
27
  if (file.kind === "directory") {
28
- throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
28
+ throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
29
29
  }
30
30
  if (file.kind === "binary") {
31
- throw new Error(`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
31
+ throw new Error(`[E_NOT_TEXT] Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
32
32
  }
33
33
  if (file.kind === "image") {
34
- throw new Error(`Path is an image file: ${path}. Hashline edit only supports text files.`);
34
+ throw new Error(`[E_NOT_TEXT] Path is an image file: ${path}. Hashline edit only supports text files.`);
35
35
  }
36
36
  }
37
37