pi-hashline-edit-pro 1.0.8 → 1.1.1

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
@@ -11,7 +11,7 @@ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by Rimur
11
11
  - **Stable anchors.** Editing one part of a file leaves the hashes of untouched lines unchanged, so anchors from earlier reads stay valid.
12
12
  - **Autocorrection with warnings.** Unambiguous copy-paste mistakes — hash prefixes, diff-preview rows, reversed ranges — are fixed automatically and reported.
13
13
  - **Safe writes.** Atomic temp-file-then-rename writes preserve permissions, BOMs, line endings, symlinks, and hard links.
14
- - **Auto-read.** Fresh anchors are appended to the result of every `write`, `replace`, and `undo_last_replace`.
14
+ - **Auto-read.** Fresh anchors are appended to the result of every `write` that changes the file; after `replace` and `undo_last_replace`, the post-edit diff is shown instead.
15
15
 
16
16
  ## Installation
17
17
 
@@ -47,7 +47,7 @@ kQm│}
47
47
  }
48
48
  ```
49
49
 
50
- 3. Keep editing. Anchors for untouched lines remain valid across edits, so hashes from earlier reads keep working; changed lines get fresh anchors, which auto-read appends to each result.
50
+ 3. Keep editing. Anchors for untouched lines remain valid across edits, so hashes from earlier reads keep working; changed lines get fresh anchors, which auto-read appends after each `write`.
51
51
 
52
52
  ## The `read` tool
53
53
 
@@ -102,7 +102,7 @@ Behavior:
102
102
  - A reversed range (start hash after end hash) is swapped and applied.
103
103
  - A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
104
104
  - `file_path` is accepted as an alias for `path`; a JSON-string `content_lines` is parsed into an array.
105
- - **Response.** A successful edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` only it is intentionally not part of the model-visible text.
105
+ - **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it and reaches the model-visible text only while auto-read is on.
106
106
  - **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
107
107
 
108
108
  ## Anchor stability
@@ -118,13 +118,15 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
118
118
 
119
119
  ## Auto-read
120
120
 
121
- Enabled by default. After a successful `write`, `replace`, or `undo_last_replace`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
121
+ Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
122
122
 
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.
123
+ - A no-op `replace` produces no diff — the file is unchanged, so existing anchors remain valid.
124
+ - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — call `read` for fresh anchors.
125
+ - With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text — no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
124
126
  - 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
127
  - 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).
126
128
  - 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.
129
+ - If the auto-read itself fails (e.g. the file was deleted between the write and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
128
130
 
129
131
  ## Undo
130
132
 
@@ -134,14 +136,14 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
134
136
  - 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
137
  - **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.
136
138
  - A successful `write` clears the history for that file.
137
- - Call `read` after an undo to get fresh anchors for follow-up edits.
139
+ - With auto-read enabled, the model sees the post-edit diff after an undo, just like a replace; with auto-read disabled it sees the plain summary. No anchors are appended after an undo — call `read` to get fresh anchors for follow-up edits.
138
140
  - **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.
139
141
 
140
142
  ## Commands and configuration
141
143
 
142
144
  | Command | Description |
143
145
  | --- | --- |
144
- | `/toggle-auto-read` | Toggle automatic hashline anchors after write and replace operations. Persists across sessions. |
146
+ | `/toggle-auto-read` | Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations. Persists across sessions. |
145
147
 
146
148
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled:
147
149
 
package/index.ts CHANGED
@@ -5,7 +5,7 @@ import { regReplace } from "./src/replace";
5
5
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
6
6
  import { regRead, fmtReadPreview } from "./src/read";
7
7
  import type { RMetrics } from "./src/replace-response";
8
- import { AUTO_READ_MAX } from "./src/constants";
8
+ import { extractWarnings } from "./src/replace-render";
9
9
  import { MAX_HASH_LINES } from "./src/hashline";
10
10
  import {
11
11
  readConfig,
@@ -13,16 +13,17 @@ import {
13
13
  } from "./src/config";
14
14
  import { loadHashStore, pruneMissing } from "./src/hash-store";
15
15
  import { readNormFile } from "./src/file-reader";
16
+ import { loadFileKindAndText } from "./src/file-kind";
16
17
  import { toCwd } from "./src/paths";
17
18
  import { resolveTarget } from "./src/fs-write";
19
+ import { valAccess } from "./src/validation";
18
20
 
19
21
  export default function (pi: ExtensionAPI): void {
20
- regRead(pi, { autoRead: true });
22
+ regRead(pi);
21
23
 
22
24
  regReplace(pi);
23
25
  regReplaceUndo(pi);
24
26
 
25
- const debugValue = process.env.PI_HASHLINE_DEBUG;
26
27
  let autoRead = true;
27
28
 
28
29
  pi.on("session_start", async (_event, ctx) => {
@@ -37,25 +38,24 @@ export default function (pi: ExtensionAPI): void {
37
38
  }
38
39
  const config = await readConfig();
39
40
  autoRead = config.autoRead;
40
- regRead(pi, { autoRead });
41
-
41
+ const debugValue = process.env.PI_HASHLINE_DEBUG;
42
42
  if (debugValue === "1" || debugValue === "true") {
43
43
  ctx.ui.notify(`Hashline Edit mode active`, "info");
44
44
  }
45
45
  });
46
46
 
47
47
  pi.registerCommand("toggle-auto-read", {
48
- description: "Toggle automatic hashline anchors after write and replace operations",
48
+ description: "Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations",
49
49
  handler: async (_args, ctx) => {
50
50
  autoRead = await toggleAutoRead();
51
- regRead(pi, { autoRead });
52
51
  const state = autoRead ? "enabled" : "disabled";
53
- ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
52
+ ctx.ui.notify(`Auto-read anchors (write) and post-edit diffs (replace/undo): ${state}`, "info");
54
53
  },
55
54
  });
56
55
 
57
56
  pi.on("tool_result", async (event, ctx) => {
58
57
  if (event.isError) return;
58
+
59
59
  if (event.toolName === "write") {
60
60
  const writtenPath = (event.input as Record<string, unknown>)?.path;
61
61
  if (typeof writtenPath === "string") {
@@ -65,58 +65,68 @@ export default function (pi: ExtensionAPI): void {
65
65
  console.error("Failed to clear undo after write:", error);
66
66
  }
67
67
  }
68
+ if (!autoRead) return;
69
+ if (typeof writtenPath !== "string") return;
70
+ try {
71
+ const resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
72
+ await valAccess(resolvedPath, writtenPath);
73
+ const file = await loadFileKindAndText(resolvedPath, { maxLines: MAX_HASH_LINES, displayPath: writtenPath });
74
+ if (file.kind !== "text") return;
75
+ const { normalized, fileHashes, absolutePath } = await readNormFile(
76
+ writtenPath, ctx.cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file },
77
+ );
78
+ const preview = await fmtReadPreview(
79
+ normalized,
80
+ {},
81
+ fileHashes,
82
+ absolutePath,
83
+ DEFAULT_MAX_BYTES,
84
+ );
85
+ return {
86
+ content: [
87
+ ...(event.content ?? []),
88
+ { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
89
+ ],
90
+ };
91
+ } catch (error) {
92
+ console.error("Auto-read after write failed:", error);
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ return {
95
+ content: [
96
+ ...(event.content ?? []),
97
+ { type: "text", text: `\n\n--- Auto-read failed: ${message} ---` },
98
+ ],
99
+ };
100
+ }
68
101
  }
69
- if (!autoRead) return;
102
+
70
103
  if (
71
- event.toolName !== "write" &&
72
104
  event.toolName !== "replace" &&
73
105
  event.toolName !== "undo_last_replace"
74
106
  ) return;
75
- const filePath = (event.input as Record<string, unknown>)?.path;
76
- if (typeof filePath !== "string") return;
107
+ if (!autoRead) return;
77
108
 
78
109
  const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
79
- if (event.toolName !== "write" && metrics?.classification === "noop") return;
110
+ if (metrics?.classification === "noop") return;
80
111
 
81
- try {
82
- const { normalized, fileHashes, absolutePath } = await readNormFile(
83
- filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
84
- );
85
-
86
- const changedLines =
87
- event.toolName === "replace" || event.toolName === "undo_last_replace"
88
- ? metrics?.changed_lines
89
- : undefined;
90
- let offset: number | undefined;
91
- let limit = AUTO_READ_MAX;
92
- if (changedLines) {
93
- offset = Math.max(1, changedLines.first - 2);
94
- limit = Math.min(changedLines.last + 2 - offset + 1, AUTO_READ_MAX);
95
- }
112
+ const diff = (event.details as { diff?: string } | undefined)?.diff;
113
+ if (!diff) return;
96
114
 
97
- const preview = await fmtReadPreview(
98
- normalized,
99
- { offset, limit },
100
- fileHashes,
101
- absolutePath,
102
- DEFAULT_MAX_BYTES,
103
- );
104
-
105
- return {
106
- content: [
107
- ...(event.content ?? []),
108
- { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
109
- ],
110
- };
111
- } catch (error) {
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
- };
120
- }
115
+ const rendered = (event.content ?? [])
116
+ .filter(
117
+ (entry): entry is { type: "text"; text: string } =>
118
+ entry.type === "text" && typeof entry.text === "string",
119
+ )
120
+ .map((entry) => entry.text)
121
+ .join("\n");
122
+ const warnings = extractWarnings(rendered);
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: warnings ? `${diff}\n\n${warnings}` : diff,
128
+ },
129
+ ],
130
+ };
121
131
  });
122
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.0.8",
3
+ "version": "1.1.1",
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,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
- {{AUTO_READ_NOTE}}
2
+ - `read`: call again after any edit to that file — changed lines get new anchors.
@@ -1,4 +1,6 @@
1
1
  - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
2
+ - `replace`: hash_range_inclusive marks the exact lines that are REMOVED, and content_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in content_lines is deleted from the file — including closing braces and other structural lines.
3
+ - `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
4
+ - `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
2
5
  - `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.
4
- - `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
6
+ - `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
@@ -46,8 +46,14 @@ export type LFile =
46
46
  | { kind: "binary"; description: string };
47
47
 
48
48
 
49
+ export interface LoadFileOptions {
50
+ maxLines?: number;
51
+ displayPath?: string;
52
+ }
53
+
49
54
  export async function loadFileKindAndText(
50
55
  filePath: string,
56
+ options?: LoadFileOptions,
51
57
  ): Promise<LFile> {
52
58
  const pathStat = await fsStat(filePath);
53
59
  if (pathStat.isDirectory()) {
@@ -104,6 +110,7 @@ export async function loadFileKindAndText(
104
110
 
105
111
  const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
106
112
  let hadUtf8DecodeErrors = false;
113
+ let newlineCount = 0;
107
114
  const parts: string[] = [];
108
115
 
109
116
  function decodeChunk(chunk: Uint8Array, stream: boolean): string {
@@ -111,6 +118,16 @@ export async function loadFileKindAndText(
111
118
  if (!hadUtf8DecodeErrors && decoded.includes("\uFFFD")) {
112
119
  hadUtf8DecodeErrors = true;
113
120
  }
121
+ if (options?.maxLines !== undefined) {
122
+ for (let i = 0; i < decoded.length; i++) {
123
+ if (decoded.charCodeAt(i) === 10) newlineCount++;
124
+ }
125
+ if (newlineCount > options.maxLines) {
126
+ throw new Error(
127
+ `[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
128
+ );
129
+ }
130
+ }
114
131
  return decoded;
115
132
  }
116
133
 
@@ -20,12 +20,17 @@ export interface NormFile {
20
20
 
21
21
  export type SnapInfo = {
22
22
  snapshotId: string;
23
+ ino: number;
23
24
  mtimeMs: number;
25
+ ctimeMs: number;
24
26
  size: number;
25
27
  };
26
28
 
27
- function fmtSnapId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
28
- return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
29
+ function fmtSnapId(
30
+ canonicalPath: string,
31
+ info: { ino: number; mtimeMs: number; ctimeMs: number; size: number },
32
+ ): string {
33
+ return `v2|${canonicalPath}|${info.ino}|${info.mtimeMs}|${info.ctimeMs}|${info.size}`;
29
34
  }
30
35
 
31
36
  export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
@@ -33,7 +38,9 @@ export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
33
38
  const stats = await stat(canonicalPath);
34
39
  return {
35
40
  snapshotId: fmtSnapId(canonicalPath, stats),
41
+ ino: stats.ino,
36
42
  mtimeMs: stats.mtimeMs,
43
+ ctimeMs: stats.ctimeMs,
37
44
  size: stats.size,
38
45
  };
39
46
  }
@@ -60,9 +67,8 @@ export async function readNormFile(
60
67
  await valAccess(resolvedPath, path, accessMode);
61
68
 
62
69
  abortIf(signal);
63
- const file = options?.preloadedFile ?? (await loadFileKindAndText(resolvedPath));
70
+ const file = options?.preloadedFile ?? (await loadFileKindAndText(resolvedPath, { maxLines: options?.maxLines, displayPath: path }));
64
71
  valKind(file, path);
65
-
66
72
  abortIf(signal);
67
73
  const { bom, text: rawContent } = stripBOM(file.text);
68
74
  const originalEnding = detectEnding(rawContent);
package/src/hash-store.ts CHANGED
@@ -46,6 +46,55 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
46
46
  return true;
47
47
  }
48
48
 
49
+ export function isCorruptionError(error: unknown): boolean {
50
+ if (error && typeof error === "object") {
51
+ const errcode = (error as { errcode?: unknown }).errcode;
52
+ if (typeof errcode === "number") {
53
+ return errcode === 11 || errcode === 24 || errcode === 26;
54
+ }
55
+ const code = (error as { code?: unknown }).code;
56
+ if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
57
+ }
58
+ return (
59
+ error instanceof Error &&
60
+ /corrupt|not a database|malformed|database disk image/i.test(error.message)
61
+ );
62
+ }
63
+
64
+ function isBusyError(error: unknown): boolean {
65
+ if (error && typeof error === "object") {
66
+ const errcode = (error as { errcode?: unknown }).errcode;
67
+ if (typeof errcode === "number") return errcode === 5 || errcode === 6;
68
+ }
69
+ return error instanceof Error && /busy|locked/i.test(error.message);
70
+ }
71
+
72
+ function sleepSync(ms: number): void {
73
+ const sab = new Int32Array(new SharedArrayBuffer(4));
74
+ Atomics.wait(sab, 0, 0, ms);
75
+ }
76
+
77
+ const BUSY_RETRIES = 3;
78
+ const BUSY_RETRY_DELAY_MS = 100;
79
+
80
+ function withBusyRetry<T>(fn: () => T): T {
81
+ let lastError: unknown;
82
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
83
+ try {
84
+ return fn();
85
+ } catch (error) {
86
+ lastError = error;
87
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
88
+ sleepSync(BUSY_RETRY_DELAY_MS);
89
+ }
90
+ }
91
+ throw lastError;
92
+ }
93
+
94
+ function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prepared } {
95
+ return withBusyRetry(() => openDb(storePath));
96
+ }
97
+
49
98
  let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
50
99
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
51
100
  let exitHandlerRegistered = false;
@@ -121,13 +170,12 @@ function buildStore(
121
170
  const stmts: Prepared = {
122
171
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
123
172
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
124
- deleteOne: (...params) => { delStmt.run(...params); },
125
- upsert: (...params) => { upsertStmt.run(...params); },
126
- undoUpsert: (...params) => { undoUpsertStmt.run(...params); },
173
+ deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
174
+ upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
175
+ undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
127
176
  undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
128
- undoDelete: (...params) => { undoDelStmt.run(...params); },
177
+ undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
129
178
  };
130
-
131
179
  return { db, stmts };
132
180
  }
133
181
 
@@ -135,8 +183,9 @@ function isHealthy(db: DatabaseSync): boolean {
135
183
  try {
136
184
  const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
137
185
  return row?.quick_check === "ok";
138
- } catch {
139
- return false;
186
+ } catch (error) {
187
+ if (isCorruptionError(error)) return false;
188
+ return true;
140
189
  }
141
190
  }
142
191
 
@@ -170,18 +219,19 @@ async function openStore(storePath: string): Promise<HashStore> {
170
219
  let existed = existsSync(storePath);
171
220
  let opened: { db: DatabaseSync; stmts: Prepared };
172
221
  try {
173
- opened = openDb(storePath);
222
+ opened = openDbWithBusyRetry(storePath);
174
223
  } catch (error) {
224
+ if (!isCorruptionError(error)) throw error;
175
225
  console.error("Hash store failed to open, rebuilding:", error);
176
226
  await quarantineStore(storePath);
177
227
  existed = false;
178
- opened = openDb(storePath);
228
+ opened = openDbWithBusyRetry(storePath);
179
229
  }
180
230
  if (!isHealthy(opened.db)) {
181
231
  shutdownDb(opened.db);
182
232
  await quarantineStore(storePath);
183
233
  existed = false;
184
- opened = openDb(storePath);
234
+ opened = openDbWithBusyRetry(storePath);
185
235
  }
186
236
  const { db, stmts } = opened;
187
237
 
@@ -228,14 +278,16 @@ export function shutdownHashStore(): void {
228
278
 
229
279
  function withStore(fn: () => void): void {
230
280
  if (cachedDb) {
231
- cachedDb.db.exec("BEGIN IMMEDIATE");
232
- try {
233
- fn();
234
- cachedDb.db.exec("COMMIT");
235
- } catch (e) {
236
- cachedDb.db.exec("ROLLBACK");
237
- throw e;
238
- }
281
+ withBusyRetry(() => {
282
+ cachedDb!.db.exec("BEGIN IMMEDIATE");
283
+ try {
284
+ fn();
285
+ cachedDb!.db.exec("COMMIT");
286
+ } catch (e) {
287
+ try { cachedDb!.db.exec("ROLLBACK"); } catch {}
288
+ throw e;
289
+ }
290
+ });
239
291
  } else {
240
292
  fn();
241
293
  }
@@ -43,10 +43,10 @@ function hashAt(idx: number): string {
43
43
  }
44
44
 
45
45
  export const HL_PREFIX_PLUS_RE = new RegExp(
46
- `^\\+\\s*${HASH_CLASS}│`,
46
+ `^\\+${HASH_CLASS}│`,
47
47
  );
48
48
  export const HL_PREFIX_MINUS_RE = new RegExp(
49
- `^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
49
+ `^-(?:${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
50
50
  );
51
51
 
52
52
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
@@ -157,19 +157,32 @@ export async function lineHashes(
157
157
  previous.removedHashes,
158
158
  );
159
159
  if (persist !== false) {
160
- upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
160
+ try {
161
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
162
+ } catch (error) {
163
+ console.error("Failed to persist hash snapshot:", error);
164
+ }
161
165
  }
162
166
  return newHashes;
163
167
  }
164
168
 
165
- const cached = getSnapshot(hashStore, path, content);
169
+ let cached: string[] | undefined;
170
+ try {
171
+ cached = getSnapshot(hashStore, path, content);
172
+ } catch (error) {
173
+ console.error("Failed to read hash store snapshot:", error);
174
+ }
166
175
  if (cached) {
167
176
  return cached;
168
177
  }
169
178
 
170
179
  const newHashes = _lineHashesPure(content);
171
180
  if (persist !== false) {
172
- upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
181
+ try {
182
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
183
+ } catch (error) {
184
+ console.error("Failed to persist hash snapshot:", error);
185
+ }
173
186
  }
174
187
  return newHashes;
175
188
  }
package/src/read.ts CHANGED
@@ -21,11 +21,8 @@ const R_DESC = loadP("../prompts/read.md");
21
21
 
22
22
  const R_SNIPPET = loadP("../prompts/read-snippet.md");
23
23
 
24
- function readGuide(autoRead: boolean): string[] {
25
- const note = "- `read`: call again after any edit to that file — changed lines get new anchors.";
26
- return loadGuide("../prompts/read-guidelines.md", {
27
- AUTO_READ_NOTE: autoRead ? "" : note,
28
- });
24
+ function readGuide(): string[] {
25
+ return loadGuide("../prompts/read-guidelines.md");
29
26
  }
30
27
 
31
28
  function normPosInt(
@@ -103,15 +100,15 @@ export async function fmtReadPreview(
103
100
  : fmtRegion([selectedHashes[index]!], [selected[index]!]),
104
101
  );
105
102
  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;
103
+ const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
104
+ const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
108
105
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
109
106
  const verb = oversized.length === 1 ? "exceeds" : "exceed";
110
107
  const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
111
108
  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
109
  let preview = skippedTruncation.content;
113
110
  let nextOffset: number | undefined;
114
- if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
111
+ if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
115
112
  nextOffset = lastShownLine + 1;
116
113
  preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
117
114
  } else {
@@ -148,13 +145,13 @@ export async function fmtReadPreview(
148
145
  };
149
146
  }
150
147
 
151
- export function regRead(pi: ExtensionAPI, opts?: { autoRead?: boolean }): void {
148
+ export function regRead(pi: ExtensionAPI): void {
152
149
  pi.registerTool({
153
150
  name: "read",
154
151
  label: "Read",
155
152
  description: R_DESC,
156
153
  promptSnippet: R_SNIPPET,
157
- promptGuidelines: readGuide(opts?.autoRead ?? true),
154
+ promptGuidelines: readGuide(),
158
155
  parameters: Type.Object({
159
156
  path: Type.String({
160
157
  description: "Path to the file to read (relative or absolute)",
@@ -181,7 +178,7 @@ export function regRead(pi: ExtensionAPI, opts?: { autoRead?: boolean }): void {
181
178
  await valAccess(absolutePath, rawPath);
182
179
 
183
180
  abortIf(signal);
184
- const file = await loadFileKindAndText(absolutePath);
181
+ const file = await loadFileKindAndText(absolutePath, { maxLines: MAX_HASH_LINES, displayPath: rawPath });
185
182
  if (file.kind === "image") {
186
183
  const builtinRead = createReadTool(ctx.cwd);
187
184
  const executeBuiltinRead = builtinRead.execute as unknown as (
@@ -35,7 +35,7 @@ type NEditEntry = {
35
35
  export interface NoopInput {
36
36
  path: string;
37
37
  noopEdit: NEditEntry | undefined;
38
- snapshotId: string;
38
+ snapshotId?: string;
39
39
  editMeta: RMeta;
40
40
  warnings: string[] | undefined;
41
41
  }
@@ -47,7 +47,7 @@ export interface SuccessInput {
47
47
  result: string;
48
48
  resultHashes: string[];
49
49
  warnings: string[] | undefined;
50
- snapshotId: string;
50
+ snapshotId?: string;
51
51
  editMeta: RMeta;
52
52
  }
53
53
 
@@ -126,7 +126,7 @@ export function buildChanged(input: SuccessInput): TResult {
126
126
  const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
127
127
 
128
128
  const resultLines = visLines(result);
129
- const diffResult = genDiff(originalNormalized, result, 2, resultHashes);
129
+ const diffResult = genDiff(originalNormalized, result, 1, resultHashes);
130
130
  const addedLines = editMeta.addedLines;
131
131
  const removedLines = editMeta.removedLines;
132
132
  const warningsBlock = warnBlock(warnings);
@@ -157,6 +157,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
157
157
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
158
158
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
159
159
  const restoredRange = changedRange(currentNormalized, undo.content);
160
+ const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes).diff;
160
161
 
161
162
  await writeAtomic(
162
163
  mutationTargetPath,
@@ -192,6 +193,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
192
193
  },
193
194
  ],
194
195
  details: {
196
+ diff: undoDiff,
195
197
  metrics: buildMetrics({
196
198
  classification: "applied",
197
199
  editsAttempted: 1,
package/src/replace.ts CHANGED
@@ -453,7 +453,12 @@ export function buildToolDef(): ToolDef {
453
453
 
454
454
  const editsAttempted = 1;
455
455
  if (originalNormalized === result) {
456
- const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
456
+ let noopSnapshotId: string | undefined;
457
+ try {
458
+ noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
459
+ } catch (error) {
460
+ console.error("Failed to compute snapshot for noop edit:", error);
461
+ }
457
462
  return buildNoop({
458
463
  path,
459
464
  noopEdit,
@@ -497,8 +502,12 @@ export function buildToolDef(): ToolDef {
497
502
  await undo.restore();
498
503
  throw error;
499
504
  }
500
- const updatedSnapshotId = (await fileSnap(absolutePath))
501
- .snapshotId;
505
+ let updatedSnapshotId: string | undefined;
506
+ try {
507
+ updatedSnapshotId = (await fileSnap(absolutePath)).snapshotId;
508
+ } catch (error) {
509
+ console.error("Failed to compute post-edit snapshot:", error);
510
+ }
502
511
 
503
512
  const editMeta: RMeta = {
504
513
  editsAttempted,
package/src/validation.ts CHANGED
@@ -19,6 +19,9 @@ export async function valAccess(
19
19
  const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
20
20
  throw new Error(`[E_ACCESS] File is ${accessLabel}: ${path}`);
21
21
  }
22
+ if (code === "ELOOP") {
23
+ throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
24
+ }
22
25
  throw new Error(`[E_ACCESS] Cannot access file: ${path}`);
23
26
  }
24
27
  }