pi-hashline-edit-pro 2.5.0 → 2.5.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
@@ -1,5 +1,7 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro)
4
+
3
5
  Hash-anchored `read` and `replace` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character hash, and you edit by hash. No line numbers, no fuzzy matching, no edits landing on the wrong line.
4
6
 
5
7
  Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character hashes and collision resolution.
@@ -63,12 +65,12 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
63
65
 
64
66
  Edge cases:
65
67
 
66
- - Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
67
- - Binary files and directories are rejected with a descriptive error.
68
+ - Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
69
+ - Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
68
70
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
69
71
  - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
72
  - 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
- - Files over 238,328 lines are rejected with `[E_FILE_TOO_LARGE]`.
73
+ - Files over 238,328 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
72
74
 
73
75
  ## The replace tool
74
76
 
@@ -114,7 +116,6 @@ Notes:
114
116
 
115
117
  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 you get fresh `HASH│content` anchors without a separate `read` call.
116
118
 
117
- - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
118
119
  - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
119
120
  - 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).
120
121
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
@@ -170,7 +171,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
170
171
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
171
172
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
172
173
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
173
- | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
174
+ | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
174
175
 
175
176
  ## Troubleshooting
176
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
package/src/file-kind.ts CHANGED
@@ -3,9 +3,10 @@ import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
 
5
5
  const IMG_TYPES = new Set<string>([
6
+ "image/bmp",
7
+ "image/gif",
6
8
  "image/jpeg",
7
9
  "image/png",
8
- "image/gif",
9
10
  "image/webp",
10
11
  ]);
11
12
 
@@ -39,11 +40,22 @@ function isTextType(mimeType: string): boolean {
39
40
  return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
40
41
  }
41
42
 
43
+ function looksLikeText(sample: Uint8Array): boolean {
44
+ if (sample.includes(0)) return false;
45
+ try {
46
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
42
53
  export type LFile =
43
54
  | { kind: "directory" }
44
55
  | { kind: "image"; mimeType: string }
45
56
  | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
46
- | { kind: "binary"; description: string };
57
+ | { kind: "binary"; description: string }
58
+ | { kind: "too_large"; description: string };
47
59
 
48
60
 
49
61
  export interface LoadFileOptions {
@@ -67,8 +79,8 @@ export async function loadFileKindAndText(
67
79
  }
68
80
  if (pathStat.size > MAX_BYTES) {
69
81
  return {
70
- kind: "binary",
71
- description: `file exceeds ${MAX_BYTES} byte limit`
82
+ kind: "too_large",
83
+ description: `exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit`,
72
84
  };
73
85
  }
74
86
 
@@ -96,7 +108,8 @@ export async function loadFileKindAndText(
96
108
  const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
97
109
  if (
98
110
  detectedMimeType !== undefined &&
99
- !isTextType(detectedMimeType)
111
+ !isTextType(detectedMimeType) &&
112
+ !looksLikeText(sample)
100
113
  ) {
101
114
  if (IMG_TYPES.has(detectedMimeType)) {
102
115
  return { kind: "image", mimeType: detectedMimeType };
package/src/hash-store.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
- import { errCode, splitLines } from "./utils";
5
+ import { errCode, isRec, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
7
  import { HASH_RE } from "./hashline/alphabet";
8
8
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
@@ -45,9 +45,25 @@ export function isValidHashList(value: unknown): value is string[] {
45
45
  for (const hash of value) {
46
46
  if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
47
47
  }
48
+ if (new Set(value).size !== value.length) return false;
48
49
  return true;
49
50
  }
50
51
 
52
+ export function parseHashList(raw: string, onInvalid: () => void): string[] | undefined {
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch {
57
+ onInvalid();
58
+ return undefined;
59
+ }
60
+ if (!isValidHashList(parsed)) {
61
+ onInvalid();
62
+ return undefined;
63
+ }
64
+ return parsed;
65
+ }
66
+
51
67
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
52
68
  if (typeof value !== "object" || value === null) return false;
53
69
  const v = value as Record<string, unknown>;
@@ -107,6 +123,13 @@ function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prep
107
123
  let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
108
124
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
109
125
  let exitHandlerRegistered = false;
126
+ interface SnapshotCacheEntry {
127
+ checksum: string;
128
+ lineCount: number;
129
+ hashes: string[];
130
+ }
131
+ const snapshotCache = new Map<string, SnapshotCacheEntry>();
132
+ export const SNAPSHOT_CACHE_LIMIT = 256;
110
133
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
111
134
  const db = new DatabaseSync(storePath, {
112
135
  timeout: HASH_STORE_BUSY_TIMEOUT,
@@ -301,6 +324,7 @@ export function shutdownHashStore(): void {
301
324
  shutdownDb(cachedDb.db);
302
325
  cachedDb = null;
303
326
  }
327
+ snapshotCache.clear();
304
328
  }
305
329
 
306
330
  function withStore(fn: () => void): void {
@@ -344,11 +368,17 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
344
368
 
345
369
  const rows: [string, string, number, string, number][] = [];
346
370
  for (const [key, value] of Object.entries(raw)) {
347
- if (!isValidSnapshot(value)) continue;
348
- if (new Set(value.hashes).size !== value.hashes.length) {
349
- console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
371
+ if (
372
+ isRec(value) &&
373
+ Array.isArray(value.hashes) &&
374
+ new Set(value.hashes).size !== value.hashes.length
375
+ ) {
376
+ console.warn(
377
+ `Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`,
378
+ );
350
379
  continue;
351
380
  }
381
+ if (!isValidSnapshot(value)) continue;
352
382
  rows.push([
353
383
  key,
354
384
  contentChecksum(value.content),
@@ -378,6 +408,15 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
378
408
  }
379
409
  }
380
410
 
411
+ function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
412
+ snapshotCache.delete(path);
413
+ snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
414
+ if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
415
+ const oldest = snapshotCache.keys().next().value;
416
+ if (oldest !== undefined) snapshotCache.delete(oldest);
417
+ }
418
+ }
419
+
381
420
  export function getSnapshot(
382
421
  store: HashStore,
383
422
  path: string,
@@ -386,17 +425,21 @@ export function getSnapshot(
386
425
  ): string[] | undefined {
387
426
  const checksum = contentChecksum(content);
388
427
  const lineCount = splitLines(content).length;
428
+ const cached = snapshotCache.get(path);
429
+ if (cached && cached.checksum === checksum && cached.lineCount === lineCount) {
430
+ snapshotCache.delete(path);
431
+ snapshotCache.set(path, cached);
432
+ return cached.hashes.slice();
433
+ }
389
434
  const row = store.stmts.get(path, checksum, lineCount);
390
435
  if (!row) return undefined;
391
- try {
392
- const parsed = JSON.parse(row.hashes as string);
393
- if (isValidHashList(parsed)) return parsed;
394
- if (deleteCorrupt) store.stmts.deleteOne(path);
395
- return undefined;
396
- } catch {
436
+ const parsed = parseHashList(row.hashes as string, () => {
397
437
  if (deleteCorrupt) store.stmts.deleteOne(path);
398
- return undefined;
399
- }
438
+ snapshotCache.delete(path);
439
+ });
440
+ if (!parsed) return undefined;
441
+ cacheSnapshot(path, checksum, lineCount, parsed);
442
+ return parsed;
400
443
  }
401
444
 
402
445
  export function upsertSnapshot(
@@ -407,6 +450,7 @@ export function upsertSnapshot(
407
450
  hashes: string[],
408
451
  ): void {
409
452
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
453
+ cacheSnapshot(path, checksum, lineCount, hashes);
410
454
  }
411
455
 
412
456
  export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
@@ -424,23 +468,15 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
424
468
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
425
469
  const row = store.stmts.undoGet(path);
426
470
  if (!row) return undefined;
427
- try {
428
- const parsed = JSON.parse(row.hashes as string);
429
- if (!isValidHashList(parsed)) {
430
- store.stmts.undoDelete(path);
431
- return undefined;
432
- }
433
- return {
434
- content: row.content as string,
435
- bom: row.bom as string,
436
- ending: row.ending as string,
437
- hashes: parsed as string[],
438
- resultContent: row.result_content as string,
439
- };
440
- } catch {
441
- store.stmts.undoDelete(path);
442
- return undefined;
443
- }
471
+ const parsed = parseHashList(row.hashes as string, () => store.stmts.undoDelete(path));
472
+ if (!parsed) return undefined;
473
+ return {
474
+ content: row.content as string,
475
+ bom: row.bom as string,
476
+ ending: row.ending as string,
477
+ hashes: parsed,
478
+ resultContent: row.result_content as string,
479
+ };
444
480
  }
445
481
 
446
482
  export function deleteUndo(store: HashStore, path: string): void {
@@ -477,6 +513,7 @@ export async function pruneMissing(store: HashStore): Promise<void> {
477
513
  withStore(() => {
478
514
  for (const path of missing) {
479
515
  store.stmts.deleteOne(path);
516
+ snapshotCache.delete(path);
480
517
  store.stmts.undoDelete(path);
481
518
  store.stmts.servedDelete(path);
482
519
  }
@@ -96,6 +96,7 @@ export function genDiff(
96
96
  let linesToShow: (string | symbol)[] = displayLines;
97
97
  let skipStart = 0;
98
98
  let skipMiddle = 0;
99
+ let skipTail = 0;
99
100
 
100
101
  if (!lastWasChange) {
101
102
  skipStart = Math.max(0, displayLines.length - contextLines);
@@ -104,8 +105,9 @@ export function genDiff(
104
105
  const tail = displayLines.slice(-contextLines);
105
106
  linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
106
107
  skipMiddle = displayLines.length - contextLines * 2;
107
- } else if (linesToShow.length > contextLines) {
108
+ } else if (!nextPartIsChange && linesToShow.length > contextLines) {
108
109
  linesToShow = linesToShow.slice(0, contextLines);
110
+ skipTail = displayLines.length - contextLines;
109
111
  }
110
112
 
111
113
  if (skipStart > 0) {
@@ -125,6 +127,9 @@ export function genDiff(
125
127
  newLineNum++;
126
128
  oldLineNum++;
127
129
  }
130
+ if (skipTail > 0) {
131
+ output.push(" ...");
132
+ }
128
133
  } else {
129
134
  newLineNum += displayLines.length;
130
135
  oldLineNum += displayLines.length;
package/src/served.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { HashStore } from "./hash-store";
2
- import { isValidHashList } from "./hash-store";
2
+ import { parseHashList } from "./hash-store";
3
3
  import { HASH_CLASS } from "./hashline/alphabet";
4
4
 
5
5
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
@@ -16,17 +16,9 @@ export function servedHashesFromDiff(diff: string): string[] {
16
16
  export function getServed(store: HashStore, path: string): Set<string> | undefined {
17
17
  const row = store.stmts.servedGet(path);
18
18
  if (!row) return undefined;
19
- try {
20
- const parsed = JSON.parse(row.hashes as string);
21
- if (!isValidHashList(parsed)) {
22
- store.stmts.servedDelete(path);
23
- return undefined;
24
- }
25
- return new Set(parsed);
26
- } catch {
27
- store.stmts.servedDelete(path);
28
- return undefined;
29
- }
19
+ const parsed = parseHashList(row.hashes as string, () => store.stmts.servedDelete(path));
20
+ if (!parsed) return undefined;
21
+ return new Set(parsed);
30
22
  }
31
23
 
32
24
  export function recordServed(store: HashStore, path: string, hashes: string[]): void {
package/src/utils.ts CHANGED
@@ -16,9 +16,7 @@ export function splitLines(text: string): string[] {
16
16
  }
17
17
 
18
18
  export function visLines(text: string): string[] {
19
- if (text.length === 0) return [];
20
- const lines = text.split("\n");
21
- return text.endsWith("\n") ? lines.slice(0, -1) : lines;
19
+ return text.length === 0 ? [] : splitLines(text);
22
20
  }
23
21
 
24
22
 
package/src/validation.ts CHANGED
@@ -36,5 +36,10 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
36
36
  if (file.kind === "image") {
37
37
  throw new Error(`[E_NOT_TEXT] Path is an image file: ${path}. Hashline edit only supports text files.`);
38
38
  }
39
+ if (file.kind === "too_large") {
40
+ throw new Error(
41
+ `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
42
+ );
43
+ }
39
44
  }
40
45