pi-hashline-edit-pro 1.0.4 → 1.0.6

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
@@ -62,10 +62,13 @@ Optional parameters:
62
62
 
63
63
  Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
64
64
 
65
+ Lines up to 200KB are displayed in full; larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`) since hash anchors require full lines.
66
+
65
67
  Edge cases:
66
68
 
67
69
  - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
68
70
  - **Binary and directory paths** are rejected with a descriptive error.
71
+ - **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
72
  - **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
73
  - **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
74
  - **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
@@ -92,7 +95,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
92
95
  Behavior:
93
96
 
94
97
  - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
95
- - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_BAD_SHAPE]` / `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
98
+ - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
96
99
  - **Autocorrections** (all accompanied by a warning unless noted):
97
100
  - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
98
101
  - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
@@ -119,14 +122,16 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
119
122
 
120
123
  - 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
124
  - 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.
125
+ - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
122
126
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
127
+ - 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
128
 
124
129
  ## Undo
125
130
 
126
131
  `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
132
 
128
133
  - 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.
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.
130
135
  - A successful `write` clears the history for that file.
131
136
  - Call `read` after an undo to get fresh anchors for follow-up edits.
132
137
  - **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.
@@ -155,12 +160,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
155
160
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
156
161
  | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
157
162
  | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
158
- | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
163
+ | `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
159
164
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
160
165
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
161
166
  | `[E_NOT_FOUND]` | The path does not exist. |
162
167
  | `[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. |
168
+ | `[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
169
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
165
170
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
166
171
 
@@ -176,7 +181,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
176
181
 
177
182
  - **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
183
  - **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.
184
+ - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
180
185
  - **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
186
  - **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
187
 
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
3
  import { initHasher } from "./src/hashline";
3
4
  import { regReplace } from "./src/replace";
4
5
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
@@ -59,7 +60,7 @@ export default function (pi: ExtensionAPI): void {
59
60
  const writtenPath = (event.input as Record<string, unknown>)?.path;
60
61
  if (typeof writtenPath === "string") {
61
62
  try {
62
- clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
63
+ await clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
63
64
  } catch (error) {
64
65
  console.error("Failed to clear undo after write:", error);
65
66
  }
@@ -98,6 +99,7 @@ export default function (pi: ExtensionAPI): void {
98
99
  { offset, limit },
99
100
  fileHashes,
100
101
  absolutePath,
102
+ DEFAULT_MAX_BYTES,
101
103
  );
102
104
 
103
105
  return {
@@ -108,6 +110,13 @@ export default function (pi: ExtensionAPI): void {
108
110
  };
109
111
  } catch (error) {
110
112
  console.error("Auto-read after write/replace failed:", error);
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ return {
115
+ content: [
116
+ ...(event.content ?? []),
117
+ { type: "text", text: `\n\n--- Auto-read failed: ${message} ---` },
118
+ ],
119
+ };
111
120
  }
112
121
  });
113
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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
  "scripts": {
48
48
  "test": "vitest run",
49
49
  "test:watch": "vitest",
50
- "test:coverage": "vitest run --coverage",
50
+ "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
51
51
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
52
52
  "typecheck": "tsc --noEmit"
53
53
  },
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/constants.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
+ export const MAX_READ_LINE_BYTES = 200 * 1024;
4
5
 
5
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
6
7
  export const HASH_STORE_VERSION = 4;
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
  }
@@ -143,6 +143,7 @@ export async function lineHashes(
143
143
  store?: HashStore,
144
144
  persist?: boolean,
145
145
  ): Promise<string[]> {
146
+ await initHasher();
146
147
  if (!path) {
147
148
  return _lineHashesPure(content);
148
149
  }
@@ -9,7 +9,7 @@ let hasher: Hasher | null = null;
9
9
 
10
10
  export function getH(): Hasher {
11
11
  if (hasher) return hasher;
12
- throw new Error("xxhash-wasm not initialized yet. This should not happen.");
12
+ throw new Error("xxhash-wasm hasher not initialized; await initHasher() before calling hashline APIs.");
13
13
  }
14
14
 
15
15
  const hasherP: Promise<Hasher> = xxhash().then((h) => {
package/src/read.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type TruncationResult,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
+ import { MAX_READ_LINE_BYTES } from "./constants";
9
10
  import { loadFileKindAndText } from "./file-kind";
10
11
  import { readNormFile } from "./file-reader";
11
12
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
@@ -58,6 +59,7 @@ export async function fmtReadPreview(
58
59
  options: { offset?: number; limit?: number },
59
60
  precomputedHashes?: string[],
60
61
  path?: string,
62
+ maxLineBytes = MAX_READ_LINE_BYTES,
61
63
  ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
62
64
  const allLines = visLines(text);
63
65
  const totalLines = allLines.length;
@@ -88,15 +90,42 @@ export async function fmtReadPreview(
88
90
  const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
89
91
  const selectedHashes = allHashes.slice(startLine - 1, endIdx);
90
92
  const formatted = fmtRegion(selectedHashes, selected);
91
-
92
- const truncation = truncateHead(formatted);
93
- if (truncation.firstLineExceedsLimit) {
93
+ const maxBytes = maxLineBytes;
94
+ const rowSizes = selected.map((line, index) => ({
95
+ lineNumber: startLine + index,
96
+ bytes: Buffer.byteLength(`${selectedHashes[index]}${HASH_SEP}${line}`, "utf-8"),
97
+ }));
98
+ if (rowSizes.some((row) => row.bytes > maxBytes)) {
99
+ const oversized = rowSizes.filter((row) => row.bytes > maxBytes);
100
+ const rows = rowSizes.map((row, index) =>
101
+ row.bytes > maxBytes
102
+ ? `[Line ${row.lineNumber} is ${formatSize(row.bytes)}, exceeds ${formatSize(maxBytes)}; content not shown. Use bash: sed -n '${row.lineNumber}p' <path> | head -c ${maxBytes}]`
103
+ : fmtRegion([selectedHashes[index]!], [selected[index]!]),
104
+ );
105
+ const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
106
+ const shownRows = rowSizes.filter((row) => row.bytes <= maxBytes);
107
+ const lastShownLine = shownRows.at(-1)?.lineNumber ?? startLine - 1;
108
+ const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
109
+ const verb = oversized.length === 1 ? "exceeds" : "exceed";
110
+ const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
111
+ const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
112
+ let preview = skippedTruncation.content;
113
+ let nextOffset: number | undefined;
114
+ if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
115
+ nextOffset = lastShownLine + 1;
116
+ preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
117
+ } else {
118
+ preview += `\n\n${warning}`;
119
+ }
94
120
  return {
95
- text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`,
96
- truncation,
121
+ text: preview,
122
+ truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
123
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
97
124
  };
98
125
  }
99
126
 
127
+ const truncation = truncateHead(formatted, { maxBytes });
128
+
100
129
  let preview = truncation.content;
101
130
  let nextOffset: number | undefined;
102
131
  if (truncation.truncated) {
@@ -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: [
@@ -67,16 +101,14 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
67
101
  }
68
102
 
69
103
  return withFileMutationQueue(mutationTargetPath, async () => {
70
- let currentNormalized: string | undefined;
104
+ let currentRaw: string | undefined;
71
105
  try {
72
- const currentRaw = await readFile(mutationTargetPath, "utf-8");
73
- const { text: currentStripped } = stripBOM(currentRaw);
74
- currentNormalized = toLF(currentStripped);
106
+ currentRaw = await readFile(mutationTargetPath, "utf-8");
75
107
  } catch (error) {
76
108
  if (errCode(error) !== "ENOENT") throw error;
77
109
  }
78
110
 
79
- if (currentNormalized === undefined) {
111
+ if (currentRaw === undefined) {
80
112
  return {
81
113
  content: [
82
114
  {
@@ -88,7 +120,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
88
120
  details: {},
89
121
  };
90
122
  }
91
- if (currentNormalized !== undo.resultContent) {
123
+ if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
92
124
  return {
93
125
  content: [
94
126
  {
@@ -101,6 +133,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
101
133
  };
102
134
  }
103
135
 
136
+ const { text: currentStripped } = stripBOM(currentRaw);
137
+ const currentNormalized = toLF(currentStripped);
104
138
  const diffResult = genDiff(undo.content, currentNormalized, 0);
105
139
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
106
140
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
@@ -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;
@@ -100,7 +101,7 @@ interface PipelineResult {
100
101
 
101
102
  const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
102
103
 
103
- const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
104
+ const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
104
105
 
105
106
  export function assertNoLegacyKeys(request: unknown): void {
106
107
  if (!isRec(request)) return;
@@ -250,11 +251,6 @@ export async function compPreview(
250
251
  ): Promise<RPreview> {
251
252
  try {
252
253
  const normalized = normReq(request);
253
- if (isRec(request) && Array.isArray(request.changes)) {
254
- return {
255
- error: `[E_BAD_SHAPE] The replace tool does not accept a "changes" array. Send hash_range_inclusive and content_lines at the top level (one edit per call).`
256
- };
257
- }
258
254
  assertReq(normalized);
259
255
  const { path, originalNormalized, result, resultHashes } = await execPipeline(
260
256
  normalized,
@@ -409,8 +405,9 @@ export function buildToolDef(): ToolDef {
409
405
 
410
406
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
411
407
  const canonical = normReq(params);
408
+ assertReq(canonical);
412
409
 
413
- const normalizedParams = canonical as ReqParams;
410
+ const normalizedParams = canonical;
414
411
  const path = normalizedParams.path;
415
412
  const absolutePath = toCwd(path, ctx.cwd);
416
413
  const mutationTargetPath = await resolveTarget(absolutePath);
@@ -465,13 +462,18 @@ export function buildToolDef(): ToolDef {
465
462
  absolutePath,
466
463
  bom + restoreEndings(result, originalEnding),
467
464
  );
468
- saveUndo(mutationTargetPath, {
465
+ const undoPersisted = await saveUndo(mutationTargetPath, {
469
466
  content: originalNormalized,
470
467
  bom,
471
468
  originalEnding,
472
469
  hashes: originalHashes,
473
470
  resultContent: result,
474
471
  });
472
+ if (!undoPersisted) {
473
+ warnings.push(
474
+ "Undo history could not be persisted; undo_last_replace will not be available for this edit.",
475
+ );
476
+ }
475
477
  const updatedSnapshotId = (await fileSnap(absolutePath))
476
478
  .snapshotId;
477
479