pi-hashline-edit-pro 1.0.3 → 1.0.5

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
@@ -66,6 +66,7 @@ Edge cases:
66
66
 
67
67
  - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
68
68
  - **Binary and directory paths** are rejected with a descriptive error.
69
+ - **UTF-16/UTF-32 encoded text** (detected via BOM) is rejected with `[E_NOT_TEXT]` — editing such a file would decode it as `U+FFFD` garbage and rewrite it as corrupted UTF-8.
69
70
  - **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
71
  - **BOMs** are stripped for display; **non-UTF-8 bytes** are shown as `U+FFFD` (editing such a file rewrites it as UTF-8, with a warning).
71
72
  - **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
@@ -120,13 +121,14 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
120
121
  - 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.
121
122
  - 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.
122
123
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
124
+ - 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.
123
125
 
124
126
  ## Undo
125
127
 
126
128
  `undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content — BOM and line endings included — and the previous anchors.
127
129
 
128
130
  - History is per-file and single-level: only the most recent replace can be reverted.
129
- - History is in-memory and is lost when the session ends or the extension reloads.
131
+ - 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.
130
132
  - A successful `write` clears the history for that file.
131
133
  - Call `read` after an undo to get fresh anchors for follow-up edits.
132
134
  - **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.
@@ -160,7 +162,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
160
162
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
161
163
  | `[E_NOT_FOUND]` | The path does not exist. |
162
164
  | `[E_ACCESS]` | The file is not readable or writable. |
163
- | `[E_NOT_TEXT]` | The path is a directory, binary file, or image; hashline editing only supports text files. |
165
+ | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
164
166
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
165
167
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
166
168
 
@@ -176,7 +178,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
176
178
 
177
179
  - **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.
178
180
  - **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).
179
- - **Byte-exact preservation.** UTF-8 BOMs, CRLF vs LF endings, file permissions, and trailing newlines survive edits and undo.
181
+ - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
180
182
  - **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.
181
183
  - **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.
182
184
 
package/index.ts CHANGED
@@ -59,7 +59,7 @@ export default function (pi: ExtensionAPI): void {
59
59
  const writtenPath = (event.input as Record<string, unknown>)?.path;
60
60
  if (typeof writtenPath === "string") {
61
61
  try {
62
- clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
62
+ await clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
63
63
  } catch (error) {
64
64
  console.error("Failed to clear undo after write:", error);
65
65
  }
@@ -108,6 +108,13 @@ export default function (pi: ExtensionAPI): void {
108
108
  };
109
109
  } catch (error) {
110
110
  console.error("Auto-read after write/replace failed:", error);
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return {
113
+ content: [
114
+ ...(event.content ?? []),
115
+ { type: "text", text: `\n\n--- Auto-read failed: ${message} ---` },
116
+ ],
117
+ };
111
118
  }
112
119
  });
113
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
1
+ Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
package/src/file-kind.ts CHANGED
@@ -15,6 +15,26 @@ const TEXT_TYPES = new Set<string>([
15
15
  "application/x-ms-regedit",
16
16
  ]);
17
17
 
18
+ function detectTextBom(sample: Uint8Array): string | undefined {
19
+ if (
20
+ sample.length >= 4 &&
21
+ sample[0] === 0xff &&
22
+ sample[1] === 0xfe &&
23
+ sample[2] === 0x00 &&
24
+ sample[3] === 0x00
25
+ ) return "UTF-32LE";
26
+ if (
27
+ sample.length >= 4 &&
28
+ sample[0] === 0x00 &&
29
+ sample[1] === 0x00 &&
30
+ sample[2] === 0xfe &&
31
+ sample[3] === 0xff
32
+ ) return "UTF-32BE";
33
+ if (sample.length >= 2 && sample[0] === 0xff && sample[1] === 0xfe) return "UTF-16LE";
34
+ if (sample.length >= 2 && sample[0] === 0xfe && sample[1] === 0xff) return "UTF-16BE";
35
+ return undefined;
36
+ }
37
+
18
38
  function isTextType(mimeType: string): boolean {
19
39
  return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
20
40
  }
@@ -66,6 +86,13 @@ export async function loadFileKindAndText(
66
86
  }
67
87
 
68
88
  const sample = buffer.subarray(0, bytesRead);
89
+ const textBom = detectTextBom(sample);
90
+ if (textBom) {
91
+ return {
92
+ kind: "binary",
93
+ description: `${textBom} encoded text`
94
+ };
95
+ }
69
96
  const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
70
97
  if (
71
98
  detectedMimeType !== undefined &&
@@ -4,7 +4,7 @@ import { lineHashes } from "./hashline";
4
4
  import { loadFileKindAndText, type LFile } from "./file-kind";
5
5
  import { resolveTarget } from "./fs-write";
6
6
  import { toCwd } from "./paths";
7
- import { detectEnding, toLF, stripBOM } from "./replace-diff";
7
+ import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
8
8
  import { abortIf } from "./utils";
9
9
  import { valKind, valAccess } from "./validation";
10
10
  import { visLines } from "./utils";
@@ -13,7 +13,7 @@ export interface NormFile {
13
13
  absolutePath: string;
14
14
  normalized: string;
15
15
  bom: string;
16
- originalEnding: "\r\n" | "\n";
16
+ originalEnding: LineEnding;
17
17
  fileHashes: string[];
18
18
  hadUtf8DecodeErrors: boolean;
19
19
  }
package/src/hash-store.ts CHANGED
@@ -12,6 +12,9 @@ interface Prepared {
12
12
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
13
13
  deleteOne: (...params: SqlParams) => void;
14
14
  upsert: (...params: SqlParams) => void;
15
+ undoUpsert: (...params: SqlParams) => void;
16
+ undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
17
+ undoDelete: (...params: SqlParams) => void;
15
18
  }
16
19
 
17
20
  export interface HashStore {
@@ -19,6 +22,14 @@ export interface HashStore {
19
22
  readonly engine: "node:sqlite";
20
23
  }
21
24
 
25
+ export interface UndoRecord {
26
+ content: string;
27
+ bom: string;
28
+ ending: string;
29
+ hashes: string[];
30
+ resultContent: string;
31
+ }
32
+
22
33
  interface LegacySnapshot {
23
34
  content: string;
24
35
  hashes: string[];
@@ -72,27 +83,49 @@ function buildStore(
72
83
  "value TEXT NOT NULL" +
73
84
  ")"
74
85
  );
86
+ db.exec(
87
+ "CREATE TABLE IF NOT EXISTS undo (" +
88
+ "path TEXT PRIMARY KEY, " +
89
+ "content TEXT NOT NULL, " +
90
+ "bom TEXT NOT NULL, " +
91
+ "ending TEXT NOT NULL, " +
92
+ "hashes TEXT NOT NULL, " +
93
+ "result_content TEXT NOT NULL, " +
94
+ "updated_at INTEGER NOT NULL" +
95
+ ")"
96
+ );
75
97
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
76
98
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
77
99
  db.exec("DELETE FROM snapshots");
100
+ db.exec("DELETE FROM undo");
78
101
  }
79
102
  db.prepare(
80
103
  "INSERT INTO meta (key, value) VALUES ('version', ?) " +
81
104
  "ON CONFLICT(key) DO UPDATE SET value = excluded.value"
82
105
  ).run(String(HASH_STORE_VERSION));
83
106
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
84
- const allStmt = db.prepare("SELECT path FROM snapshots");
107
+ const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
85
108
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
86
109
  const upsertStmt = db.prepare(
87
110
  "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
88
111
  "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
89
112
  );
90
-
113
+ const undoUpsertStmt = db.prepare(
114
+ "INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
115
+ "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, updated_at = excluded.updated_at"
116
+ );
117
+ const undoGetStmt = db.prepare(
118
+ "SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
119
+ );
120
+ const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
91
121
  const stmts: Prepared = {
92
122
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
93
123
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
94
124
  deleteOne: (...params) => { delStmt.run(...params); },
95
125
  upsert: (...params) => { upsertStmt.run(...params); },
126
+ undoUpsert: (...params) => { undoUpsertStmt.run(...params); },
127
+ undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
128
+ undoDelete: (...params) => { undoDelStmt.run(...params); },
96
129
  };
97
130
 
98
131
  return { db, stmts };
@@ -295,6 +328,44 @@ export function deleteSnapshot(store: HashStore, path: string): void {
295
328
  store.stmts.deleteOne(path);
296
329
  }
297
330
 
331
+ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
332
+ store.stmts.undoUpsert(
333
+ path,
334
+ entry.content,
335
+ entry.bom,
336
+ entry.ending,
337
+ JSON.stringify(entry.hashes),
338
+ entry.resultContent,
339
+ Date.now(),
340
+ );
341
+ }
342
+
343
+ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
344
+ const row = store.stmts.undoGet(path);
345
+ if (!row) return undefined;
346
+ try {
347
+ const parsed = JSON.parse(row.hashes as string);
348
+ if (!Array.isArray(parsed) || !parsed.every((h) => typeof h === "string")) {
349
+ store.stmts.undoDelete(path);
350
+ return undefined;
351
+ }
352
+ return {
353
+ content: row.content as string,
354
+ bom: row.bom as string,
355
+ ending: row.ending as string,
356
+ hashes: parsed as string[],
357
+ resultContent: row.result_content as string,
358
+ };
359
+ } catch {
360
+ store.stmts.undoDelete(path);
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ export function deleteUndo(store: HashStore, path: string): void {
366
+ store.stmts.undoDelete(path);
367
+ }
368
+
298
369
  export async function pruneMissing(store: HashStore): Promise<void> {
299
370
  const rows = store.stmts.allPaths() as { path: string }[];
300
371
  const missing: string[] = [];
@@ -307,6 +378,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
307
378
  }
308
379
  if (missing.length === 0) return;
309
380
  withStore(() => {
310
- for (const path of missing) store.stmts.deleteOne(path);
381
+ for (const path of missing) {
382
+ store.stmts.deleteOne(path);
383
+ store.stmts.undoDelete(path);
384
+ }
311
385
  });
312
386
  }
@@ -5,10 +5,15 @@ import {
5
5
  HASH_SEP,
6
6
  } from "./hashline";
7
7
 
8
- export function detectEnding(content: string): "\r\n" | "\n" {
9
- const crlfIdx = content.indexOf("\r\n");
8
+ export type LineEnding = "\r\n" | "\n" | "\r";
9
+
10
+ export function detectEnding(content: string): LineEnding {
10
11
  const lfIdx = content.indexOf("\n");
11
- if (lfIdx === -1 || crlfIdx === -1) return "\n";
12
+ if (lfIdx === -1) {
13
+ return content.indexOf("\r") >= 0 ? "\r" : "\n";
14
+ }
15
+ const crlfIdx = content.indexOf("\r\n");
16
+ if (crlfIdx === -1) return "\n";
12
17
  return crlfIdx < lfIdx ? "\r\n" : "\n";
13
18
  }
14
19
 
@@ -18,9 +23,11 @@ export function toLF(text: string): string {
18
23
 
19
24
  export function restoreEndings(
20
25
  text: string,
21
- ending: "\r\n" | "\n",
26
+ ending: LineEnding,
22
27
  ): string {
23
- return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
28
+ if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
29
+ if (ending === "\r") return text.replace(/\n/g, "\r");
30
+ return text;
24
31
  }
25
32
 
26
33
  export function stripBOM(content: string): { bom: string; text: string } {
@@ -2,11 +2,11 @@ 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 } from "./hash-store";
5
+ import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
6
6
  import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
9
- import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
9
+ import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
10
10
  import { cntDiff, splitLines, errCode } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
@@ -14,26 +14,60 @@ import { changedRange } from "./hashline";
14
14
  export interface UndoEntry {
15
15
  content: string;
16
16
  bom: string;
17
- originalEnding: "\r\n" | "\n";
17
+ originalEnding: LineEnding;
18
18
  hashes: string[];
19
19
  resultContent: string;
20
20
  }
21
21
 
22
- const undoMap = new Map<string, UndoEntry>();
23
-
24
- export function saveUndo(path: string, entry: UndoEntry): void {
25
- undoMap.set(path, entry);
22
+ export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean> {
23
+ try {
24
+ const store = await loadHashStore();
25
+ upsertUndo(store, path, {
26
+ content: entry.content,
27
+ bom: entry.bom,
28
+ ending: entry.originalEnding,
29
+ hashes: entry.hashes,
30
+ resultContent: entry.resultContent,
31
+ });
32
+ return true;
33
+ } catch (error) {
34
+ console.error("Failed to persist undo entry:", error);
35
+ return false;
36
+ }
26
37
  }
27
38
 
28
- export function getUndo(path: string): UndoEntry | undefined {
29
- return undoMap.get(path);
39
+ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
40
+ try {
41
+ const store = await loadHashStore();
42
+ const record = getUndoEntry(store, path);
43
+ if (!record) return undefined;
44
+ const originalEnding = record.ending;
45
+ if (originalEnding !== "\r\n" && originalEnding !== "\n" && originalEnding !== "\r") {
46
+ await deleteUndo(store, path);
47
+ return undefined;
48
+ }
49
+ return {
50
+ content: record.content,
51
+ bom: record.bom,
52
+ originalEnding,
53
+ hashes: record.hashes,
54
+ resultContent: record.resultContent,
55
+ };
56
+ } catch (error) {
57
+ console.error("Failed to load undo entry:", error);
58
+ return undefined;
59
+ }
30
60
  }
31
61
 
32
- export function clearUndo(path: string): void {
33
- undoMap.delete(path);
62
+ export async function clearUndo(path: string): Promise<void> {
63
+ try {
64
+ const store = await loadHashStore();
65
+ deleteUndo(store, path);
66
+ } catch (error) {
67
+ console.error("Failed to clear undo entry:", error);
68
+ }
34
69
  }
35
70
 
36
-
37
71
  export function regReplaceUndo(pi: ExtensionAPI): void {
38
72
  pi.registerTool({
39
73
  name: "undo_last_replace",
@@ -52,7 +86,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
52
86
  const absolutePath = toCwd(path, ctx.cwd);
53
87
  const mutationTargetPath = await resolveTarget(absolutePath);
54
88
 
55
- const undo = getUndo(mutationTargetPath);
89
+ const undo = await getUndo(mutationTargetPath);
56
90
  if (!undo) {
57
91
  return {
58
92
  content: [
@@ -118,7 +152,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
118
152
  console.error("Failed to restore hash store snapshot after undo:", error);
119
153
  }
120
154
 
121
- clearUndo(mutationTargetPath);
155
+ await clearUndo(mutationTargetPath);
122
156
 
123
157
  const parts: string[] = [
124
158
  `Undone last replace on ${path}.`,
package/src/replace.ts CHANGED
@@ -9,6 +9,7 @@ import { constants } from "fs";
9
9
  import {
10
10
  genDiff,
11
11
  restoreEndings,
12
+ type LineEnding,
12
13
  } from "./replace-diff";
13
14
  import { readNormFile } from "./file-reader";
14
15
  import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
@@ -86,7 +87,7 @@ interface PipelineResult {
86
87
  originalNormalized: string;
87
88
  result: string;
88
89
  bom: string;
89
- originalEnding: "\r\n" | "\n";
90
+ originalEnding: LineEnding;
90
91
  hadUtf8DecodeErrors: boolean;
91
92
  warnings: string[];
92
93
  noopEdit?: NEdit;
@@ -465,13 +466,18 @@ export function buildToolDef(): ToolDef {
465
466
  absolutePath,
466
467
  bom + restoreEndings(result, originalEnding),
467
468
  );
468
- saveUndo(mutationTargetPath, {
469
+ const undoPersisted = await saveUndo(mutationTargetPath, {
469
470
  content: originalNormalized,
470
471
  bom,
471
472
  originalEnding,
472
473
  hashes: originalHashes,
473
474
  resultContent: result,
474
475
  });
476
+ if (!undoPersisted) {
477
+ warnings.push(
478
+ "Undo history could not be persisted; undo_last_replace will not be available for this edit.",
479
+ );
480
+ }
475
481
  const updatedSnapshotId = (await fileSnap(absolutePath))
476
482
  .snapshotId;
477
483