pi-hashline-edit-pro 1.1.0 → 1.1.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
@@ -199,7 +199,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
199
199
 
200
200
  ## Development
201
201
 
202
- Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
202
+ Requires [Node.js](https://nodejs.org) ≥ 22.19 and npm.
203
203
 
204
204
  ```bash
205
205
  npm install
package/index.ts CHANGED
@@ -13,8 +13,10 @@ 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
22
  regRead(pi);
@@ -22,7 +24,6 @@ export default function (pi: ExtensionAPI): void {
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,6 +38,7 @@ export default function (pi: ExtensionAPI): void {
37
38
  }
38
39
  const config = await readConfig();
39
40
  autoRead = config.autoRead;
41
+ const debugValue = process.env.PI_HASHLINE_DEBUG;
40
42
  if (debugValue === "1" || debugValue === "true") {
41
43
  ctx.ui.notify(`Hashline Edit mode active`, "info");
42
44
  }
@@ -66,8 +68,12 @@ export default function (pi: ExtensionAPI): void {
66
68
  if (!autoRead) return;
67
69
  if (typeof writtenPath !== "string") return;
68
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;
69
75
  const { normalized, fileHashes, absolutePath } = await readNormFile(
70
- writtenPath, ctx.cwd, { maxLines: MAX_HASH_LINES },
76
+ writtenPath, ctx.cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file },
71
77
  );
72
78
  const preview = await fmtReadPreview(
73
79
  normalized,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.1.0",
3
+ "version": "1.1.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",
@@ -34,15 +34,15 @@
34
34
  "dependencies": {
35
35
  "diff": "^8.0.2",
36
36
  "file-type": "^21.3.0",
37
- "typebox": "^1.1.24",
37
+ "typebox": "^1.3.7",
38
38
  "xxhash-wasm": "^1.1.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": ">=0.74.0",
41
+ "@earendil-works/pi-coding-agent": ">=0.75.0",
42
42
  "@earendil-works/pi-tui": "*"
43
43
  },
44
44
  "engines": {
45
- "node": ">=22.13.0"
45
+ "node": ">=22.19.0"
46
46
  },
47
47
  "scripts": {
48
48
  "test": "vitest run",
@@ -52,7 +52,7 @@
52
52
  "typecheck": "tsc --noEmit"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-coding-agent": "^0.74.0",
55
+ "@earendil-works/pi-coding-agent": "^0.84.0",
56
56
  "@eslint/js": "^10.0.1",
57
57
  "@types/node": "^24.0.0",
58
58
  "@vitest/coverage-v8": "^4.1.10",
@@ -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
@@ -100,15 +100,15 @@ export async function fmtReadPreview(
100
100
  : fmtRegion([selectedHashes[index]!], [selected[index]!]),
101
101
  );
102
102
  const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes });
103
- const shownRows = rowSizes.filter((row) => row.bytes <= maxBytes);
104
- 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;
105
105
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
106
106
  const verb = oversized.length === 1 ? "exceeds" : "exceed";
107
107
  const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
108
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}]`;
109
109
  let preview = skippedTruncation.content;
110
110
  let nextOffset: number | undefined;
111
- if (shownRows.length > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
111
+ if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
112
112
  nextOffset = lastShownLine + 1;
113
113
  preview += `\n\n${warning}\n${formatPaginationHint(startLine, lastShownLine, totalLines, nextOffset, skippedTruncation.truncated ? skippedTruncation.maxBytes : undefined)}`;
114
114
  } else {
@@ -178,7 +178,7 @@ export function regRead(pi: ExtensionAPI): void {
178
178
  await valAccess(absolutePath, rawPath);
179
179
 
180
180
  abortIf(signal);
181
- const file = await loadFileKindAndText(absolutePath);
181
+ const file = await loadFileKindAndText(absolutePath, { maxLines: MAX_HASH_LINES, displayPath: rawPath });
182
182
  if (file.kind === "image") {
183
183
  const builtinRead = createReadTool(ctx.cwd);
184
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
 
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
  }