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 +6 -5
- package/package.json +1 -1
- package/src/file-kind.ts +18 -5
- package/src/hash-store.ts +66 -29
- package/src/replace-diff.ts +6 -1
- package/src/served.ts +4 -12
- package/src/utils.ts +1 -3
- package/src/validation.ts +5 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-hashline-edit-pro
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-hashline-edit-pro) [](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.
|
|
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: "
|
|
71
|
-
description: `
|
|
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 (
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|