pi-hashline-edit-pro 0.16.14 → 0.17.0
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 +5 -3
- package/index.ts +2 -2
- package/package.json +8 -3
- package/src/constants.ts +3 -0
- package/src/file-kind.ts +1 -16
- package/src/hash-store.ts +179 -60
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/hash.ts +20 -34
- package/src/hashline/hasher.ts +33 -0
- package/src/paths.ts +13 -3
- package/src/prompts.ts +2 -2
- package/src/replace-undo.ts +3 -6
- package/src/replace.ts +7 -5
package/README.md
CHANGED
|
@@ -94,9 +94,11 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
94
94
|
|
|
95
95
|
### Stable hashing across edits
|
|
96
96
|
|
|
97
|
-
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.
|
|
97
|
+
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
|
|
98
98
|
|
|
99
|
-
The store
|
|
99
|
+
The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe — stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
|
|
100
|
+
|
|
101
|
+
On first run after upgrading, a one-time migration imports the previous `hash-store.json` into the database and renames the old file to `hash-store.json.bak`; the old JSON store is otherwise discarded.
|
|
100
102
|
|
|
101
103
|
### Chained edits
|
|
102
104
|
|
|
@@ -145,7 +147,7 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
145
147
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
146
148
|
- **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
147
149
|
- **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
|
|
148
|
-
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.
|
|
150
|
+
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history.
|
|
149
151
|
## Hashing
|
|
150
152
|
|
|
151
153
|
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
|
package/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
toggleReplaceMode,
|
|
11
11
|
toggleAutoRead,
|
|
12
12
|
} from "./src/config";
|
|
13
|
-
import { loadHashStore,
|
|
13
|
+
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
14
14
|
import { readNormFile } from "./src/file-reader";
|
|
15
15
|
|
|
16
16
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -36,7 +36,7 @@ function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean)
|
|
|
36
36
|
await initHasher();
|
|
37
37
|
try {
|
|
38
38
|
const store = await loadHashStore();
|
|
39
|
-
await
|
|
39
|
+
await pruneMissing(store);
|
|
40
40
|
} catch (err) {
|
|
41
41
|
console.error("Failed to load or prune hash store:", err);
|
|
42
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
]
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"better-sqlite3": "^13.0.1",
|
|
34
35
|
"diff": "^8.0.2",
|
|
35
36
|
"file-type": "^21.3.0",
|
|
36
37
|
"xxhash-wasm": "^1.1.0"
|
|
@@ -46,8 +47,12 @@
|
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
50
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
49
51
|
"@types/node": "^22.0.0",
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
+
"typescript": "^5.8.0",
|
|
53
|
+
"vitest": "^4.1.8"
|
|
54
|
+
},
|
|
55
|
+
"allowScripts": {
|
|
56
|
+
"better-sqlite3@13.0.1": true
|
|
52
57
|
}
|
|
53
58
|
}
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,9 @@ export const MAX_BYTES = 100 * 1024 * 1024;
|
|
|
5
5
|
export const MAX_HASH_LINES = 1_000_000;
|
|
6
6
|
export const MAX_HASH_RETRIES = 262_144;
|
|
7
7
|
|
|
8
|
+
export const HASH_STORE_VERSION = 2;
|
|
9
|
+
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
10
|
+
|
|
8
11
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
9
12
|
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
10
13
|
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/file-kind.ts
CHANGED
|
@@ -31,9 +31,6 @@ export type LFile =
|
|
|
31
31
|
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
32
32
|
| { kind: "binary"; description: string };
|
|
33
33
|
|
|
34
|
-
function hasNull(buffer: Uint8Array): boolean {
|
|
35
|
-
return buffer.includes(0);
|
|
36
|
-
}
|
|
37
34
|
|
|
38
35
|
export async function loadFileKindAndText(
|
|
39
36
|
filePath: string,
|
|
@@ -82,12 +79,7 @@ export async function loadFileKindAndText(
|
|
|
82
79
|
description: detectedMimeType,
|
|
83
80
|
};
|
|
84
81
|
}
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
kind: "binary",
|
|
88
|
-
description: "null bytes detected",
|
|
89
|
-
};
|
|
90
|
-
}
|
|
82
|
+
|
|
91
83
|
|
|
92
84
|
const decoder = new TextDecoder("utf-8");
|
|
93
85
|
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
@@ -121,17 +113,10 @@ export async function loadFileKindAndText(
|
|
|
121
113
|
}
|
|
122
114
|
|
|
123
115
|
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
124
|
-
if (hasNull(chunk)) {
|
|
125
|
-
return {
|
|
126
|
-
kind: "binary",
|
|
127
|
-
description: "null bytes detected",
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
116
|
noteUtf8Err(chunk);
|
|
131
117
|
parts.push(decoder.decode(chunk, { stream: true }));
|
|
132
118
|
position += chunkBytesRead;
|
|
133
119
|
}
|
|
134
|
-
|
|
135
120
|
noteUtf8Err();
|
|
136
121
|
parts.push(decoder.decode());
|
|
137
122
|
|
package/src/hash-store.ts
CHANGED
|
@@ -1,72 +1,191 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { Database as DatabaseType, Statement } from "better-sqlite3";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { readFile, rename, mkdir, stat } from "fs/promises";
|
|
5
|
+
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
4
6
|
import { errCode } from "./utils";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
|
-
export interface FileSnapshot {
|
|
17
|
-
content: string;
|
|
18
|
-
hashes: string[];
|
|
7
|
+
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
8
|
+
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
9
|
+
|
|
10
|
+
interface Prepared {
|
|
11
|
+
get: Statement<[string, string, number], { hashes: string }>;
|
|
12
|
+
allPaths: Statement<[], { path: string }>;
|
|
13
|
+
deleteOne: Statement<[string]>;
|
|
14
|
+
upsert: Statement<[string, string, number, string, number]>;
|
|
19
15
|
}
|
|
20
16
|
|
|
21
17
|
export interface HashStore {
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
readonly db: DatabaseType;
|
|
19
|
+
readonly stmts: Prepared;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface LegacySnapshot {
|
|
23
|
+
content: string;
|
|
24
|
+
hashes: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
28
|
+
if (typeof value !== "object" || value === null) return false;
|
|
29
|
+
const v = value as Record<string, unknown>;
|
|
30
|
+
if (typeof v.content !== "string") return false;
|
|
31
|
+
if (!Array.isArray(v.hashes)) return false;
|
|
32
|
+
for (const h of v.hashes) {
|
|
33
|
+
if (typeof h !== "string") return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cachedStore: { path: string; store: HashStore } | null = null;
|
|
39
|
+
|
|
40
|
+
function openDatabase(storePath: string): HashStore {
|
|
41
|
+
const db = new Database(storePath);
|
|
42
|
+
db.pragma("journal_mode = WAL");
|
|
43
|
+
db.pragma("synchronous = NORMAL");
|
|
44
|
+
db.pragma(`busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
47
|
+
path TEXT PRIMARY KEY,
|
|
48
|
+
checksum TEXT NOT NULL,
|
|
49
|
+
line_count INTEGER NOT NULL,
|
|
50
|
+
hashes TEXT NOT NULL,
|
|
51
|
+
updated_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
`);
|
|
54
|
+
const stmts: Prepared = {
|
|
55
|
+
get: db.prepare<[string, string, number], { hashes: string }>(
|
|
56
|
+
"SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?",
|
|
57
|
+
),
|
|
58
|
+
allPaths: db.prepare<[], { path: string }>("SELECT path FROM snapshots"),
|
|
59
|
+
deleteOne: db.prepare<[string]>("DELETE FROM snapshots WHERE path = ?"),
|
|
60
|
+
upsert: db.prepare<[string, string, number, string, number]>(
|
|
61
|
+
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
62
|
+
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
return { db, stmts };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function migrateLegacy(store: HashStore): Promise<void> {
|
|
69
|
+
const legacyPath = legacyHashStorePath();
|
|
70
|
+
let content: string;
|
|
71
|
+
try {
|
|
72
|
+
content = await readFile(legacyPath, "utf-8");
|
|
73
|
+
} catch (error: unknown) {
|
|
74
|
+
if (errCode(error) === "ENOENT") return;
|
|
75
|
+
console.error("Failed to read legacy hash store for migration:", error);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let parsed: { snapshots?: Record<string, unknown> };
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(content) as typeof parsed;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error("Failed to parse legacy hash store, skipping migration:", error);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const raw = parsed.snapshots;
|
|
88
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
89
|
+
|
|
90
|
+
const insert = store.db.prepare(
|
|
91
|
+
"INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
92
|
+
);
|
|
93
|
+
const rows: [string, string, number, string, number][] = [];
|
|
94
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
95
|
+
if (!isValidSnapshot(value)) continue;
|
|
96
|
+
rows.push([
|
|
97
|
+
key,
|
|
98
|
+
contentChecksum(value.content),
|
|
99
|
+
value.content.split("\n").length,
|
|
100
|
+
JSON.stringify(value.hashes),
|
|
101
|
+
Date.now(),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (rows.length > 0) {
|
|
106
|
+
store.db.transaction(() => {
|
|
107
|
+
for (const row of rows) insert.run(...row);
|
|
108
|
+
}).immediate();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await rename(legacyPath, `${legacyPath}.bak`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error("Failed to rename legacy hash store after migration:", error);
|
|
115
|
+
}
|
|
24
116
|
}
|
|
25
117
|
|
|
26
118
|
export async function loadHashStore(): Promise<HashStore> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await mkdir(hashStoreDir(), { recursive: true });
|
|
45
|
-
const defaultStore: HashStore = {
|
|
46
|
-
version: 1,
|
|
47
|
-
snapshots: {},
|
|
48
|
-
};
|
|
49
|
-
await writeAtomic(hashStorePath(), JSON.stringify(defaultStore));
|
|
50
|
-
return defaultStore;
|
|
51
|
-
}
|
|
119
|
+
const storePath = hashStorePath();
|
|
120
|
+
if (cachedStore && cachedStore.path === storePath) {
|
|
121
|
+
return cachedStore.store;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
shutdownHashStore();
|
|
125
|
+
|
|
126
|
+
await initHasher();
|
|
127
|
+
await mkdir(hashStoreDir(), { recursive: true });
|
|
128
|
+
|
|
129
|
+
const existed = existsSync(storePath);
|
|
130
|
+
const store = openDatabase(storePath);
|
|
131
|
+
if (!existed) {
|
|
132
|
+
await migrateLegacy(store);
|
|
133
|
+
}
|
|
134
|
+
cachedStore = { path: storePath, store };
|
|
135
|
+
return store;
|
|
52
136
|
}
|
|
53
137
|
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
138
|
+
export function shutdownHashStore(): void {
|
|
139
|
+
if (cachedStore) {
|
|
140
|
+
cachedStore.store.db.close();
|
|
141
|
+
cachedStore = null;
|
|
142
|
+
}
|
|
57
143
|
}
|
|
58
144
|
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
if (changed) {
|
|
70
|
-
await saveHashStore(store);
|
|
71
|
-
}
|
|
145
|
+
export function getSnapshot(
|
|
146
|
+
store: HashStore,
|
|
147
|
+
path: string,
|
|
148
|
+
content: string,
|
|
149
|
+
): string[] | undefined {
|
|
150
|
+
const checksum = contentChecksum(content);
|
|
151
|
+
const lineCount = content.split("\n").length;
|
|
152
|
+
const row = store.stmts.get.get(path, checksum, lineCount);
|
|
153
|
+
return row ? (JSON.parse(row.hashes) as string[]) : undefined;
|
|
72
154
|
}
|
|
155
|
+
|
|
156
|
+
export function upsertSnapshot(
|
|
157
|
+
store: HashStore,
|
|
158
|
+
path: string,
|
|
159
|
+
checksum: string,
|
|
160
|
+
lineCount: number,
|
|
161
|
+
hashes: string[],
|
|
162
|
+
): void {
|
|
163
|
+
const hashesJson = JSON.stringify(hashes);
|
|
164
|
+
store.db.transaction(() => {
|
|
165
|
+
store.stmts.upsert.run(path, checksum, lineCount, hashesJson, Date.now());
|
|
166
|
+
}).immediate();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function deleteSnapshot(store: HashStore, path: string): void {
|
|
170
|
+
store.db.transaction(() => {
|
|
171
|
+
store.stmts.deleteOne.run(path);
|
|
172
|
+
}).immediate();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function pruneMissing(store: HashStore): Promise<void> {
|
|
176
|
+
const rows = store.stmts.allPaths.all();
|
|
177
|
+
const missing: string[] = [];
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
try {
|
|
180
|
+
await stat(row.path);
|
|
181
|
+
} catch {
|
|
182
|
+
missing.push(row.path);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (missing.length === 0) return;
|
|
186
|
+
store.db.transaction(() => {
|
|
187
|
+
for (const path of missing) store.stmts.deleteOne.run(path);
|
|
188
|
+
}).immediate();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { HASH_STORE_VERSION };
|
package/src/hashline/apply.ts
CHANGED
|
@@ -51,7 +51,7 @@ type RESpan = {
|
|
|
51
51
|
function assertNotEmpty(originalContent: string, result: string): void {
|
|
52
52
|
if (originalContent.length > 0 && result.length === 0) {
|
|
53
53
|
throw new Error(
|
|
54
|
-
"[E_WOULD_EMPTY] Cannot empty a non-empty file via edit."
|
|
54
|
+
"[E_WOULD_EMPTY] Cannot empty a non-empty file via edit. Use `write` if you need to clear the file."
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -252,7 +252,7 @@ export function fmtBoundaryWarning(params: {
|
|
|
252
252
|
|
|
253
253
|
export function applyEdits(
|
|
254
254
|
content: string,
|
|
255
|
-
edits:
|
|
255
|
+
edits: HEdit[],
|
|
256
256
|
signal?: AbortSignal,
|
|
257
257
|
precomputedHashes?: string[],
|
|
258
258
|
filePath?: string,
|
|
@@ -297,7 +297,7 @@ export function applyEdits(
|
|
|
297
297
|
let autoFixes: AutoFix[] | undefined;
|
|
298
298
|
if (boundaryWarnings.length > 0) {
|
|
299
299
|
autoFixes = [];
|
|
300
|
-
const correctedEdits:
|
|
300
|
+
const correctedEdits: HEdit[] = edits.map(e => ({
|
|
301
301
|
...e,
|
|
302
302
|
content_lines: [...e.content_lines],
|
|
303
303
|
}));
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import xxhash from "xxhash-wasm";
|
|
2
|
-
import * as Diff from "diff";
|
|
3
|
-
import { loadHashStore, saveHashStore, type HashStore } from "../hash-store";
|
|
4
1
|
import { MAX_HASH_RETRIES } from "../constants";
|
|
2
|
+
import {
|
|
3
|
+
loadHashStore,
|
|
4
|
+
type HashStore,
|
|
5
|
+
getSnapshot,
|
|
6
|
+
upsertSnapshot,
|
|
7
|
+
} from "../hash-store";
|
|
8
|
+
import { xxh32, contentChecksum, initHasher } from "./hasher";
|
|
9
|
+
|
|
10
|
+
export { initHasher };
|
|
5
11
|
|
|
6
12
|
export const HASH_LEN = 3;
|
|
7
13
|
export const ANCHOR_LEN = HASH_LEN;
|
|
@@ -41,30 +47,6 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
|
41
47
|
|
|
42
48
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
43
49
|
|
|
44
|
-
type Hasher = { h32(input: string, seed?: number): number };
|
|
45
|
-
let hasherP: Promise<Hasher> | null = null;
|
|
46
|
-
let hasher: Hasher | null = null;
|
|
47
|
-
|
|
48
|
-
function getH(): Hasher {
|
|
49
|
-
if (hasher) return hasher;
|
|
50
|
-
throw new Error("xxhash-wasm not initialized yet. This should not happen.");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
hasherP = xxhash().then((h) => {
|
|
54
|
-
hasher = h;
|
|
55
|
-
return h;
|
|
56
|
-
}).catch((err) => {
|
|
57
|
-
console.error("xxhash-wasm initialization failed:", err);
|
|
58
|
-
throw err;
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
export function initHasher(): Promise<Hasher> {
|
|
62
|
-
return hasherP!
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function xxh32(input: string, seed = 0): number {
|
|
66
|
-
return getH().h32(input, seed) >>> 0;
|
|
67
|
-
}
|
|
68
50
|
|
|
69
51
|
function canon(line: string): string {
|
|
70
52
|
return line.replace(/\r/g, "").trimEnd();
|
|
@@ -99,6 +81,7 @@ export async function lineHashes(
|
|
|
99
81
|
path?: string,
|
|
100
82
|
previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
|
|
101
83
|
store?: HashStore,
|
|
84
|
+
persist?: boolean,
|
|
102
85
|
): Promise<string[]> {
|
|
103
86
|
if (!path) {
|
|
104
87
|
return _lineHashesPure(content);
|
|
@@ -112,19 +95,21 @@ export async function lineHashes(
|
|
|
112
95
|
content,
|
|
113
96
|
previous.removedHashes,
|
|
114
97
|
);
|
|
115
|
-
|
|
116
|
-
|
|
98
|
+
if (persist !== false) {
|
|
99
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
|
|
100
|
+
}
|
|
117
101
|
return newHashes;
|
|
118
102
|
}
|
|
119
103
|
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
return
|
|
104
|
+
const cached = getSnapshot(hashStore, path, content);
|
|
105
|
+
if (cached) {
|
|
106
|
+
return cached;
|
|
123
107
|
}
|
|
124
108
|
|
|
125
109
|
const newHashes = _lineHashesPure(content);
|
|
126
|
-
|
|
127
|
-
|
|
110
|
+
if (persist !== false) {
|
|
111
|
+
upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
|
|
112
|
+
}
|
|
128
113
|
return newHashes;
|
|
129
114
|
}
|
|
130
115
|
|
|
@@ -166,6 +151,7 @@ function mapStableHashes(
|
|
|
166
151
|
}
|
|
167
152
|
}
|
|
168
153
|
|
|
154
|
+
if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
|
|
169
155
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
170
156
|
newHashes[i] = match.hash;
|
|
171
157
|
used.add(match.hash);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import xxhash from "xxhash-wasm";
|
|
2
|
+
|
|
3
|
+
export type Hasher = {
|
|
4
|
+
h32(input: string, seed?: number): number;
|
|
5
|
+
h64ToString(input: string, seed?: bigint): string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
let hasher: Hasher | null = null;
|
|
9
|
+
|
|
10
|
+
export function getH(): Hasher {
|
|
11
|
+
if (hasher) return hasher;
|
|
12
|
+
throw new Error("xxhash-wasm not initialized yet. This should not happen.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const hasherP: Promise<Hasher> = xxhash().then((h) => {
|
|
16
|
+
hasher = h as unknown as Hasher;
|
|
17
|
+
return hasher;
|
|
18
|
+
}).catch((err: unknown) => {
|
|
19
|
+
console.error("xxhash-wasm initialization failed:", err);
|
|
20
|
+
throw err;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function initHasher(): Promise<Hasher> {
|
|
24
|
+
return hasherP;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function xxh32(input: string, seed = 0): number {
|
|
28
|
+
return getH().h32(input, seed) >>> 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function contentChecksum(content: string): string {
|
|
32
|
+
return getH().h64ToString(content);
|
|
33
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -2,8 +2,13 @@ import { homedir } from "os";
|
|
|
2
2
|
import { isAbsolute, resolve as resolvePath, join, dirname } from "path";
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
function homeBase(): string {
|
|
6
|
+
const envHome = process.env.HOME;
|
|
7
|
+
return envHome && envHome.length > 0 ? envHome : homedir();
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export function configDir(): string {
|
|
6
|
-
return join(
|
|
11
|
+
return join(homeBase(), ".config", "pi-hashline-edit-pro");
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function configPath(): string {
|
|
@@ -11,6 +16,10 @@ export function configPath(): string {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export function hashStorePath(): string {
|
|
19
|
+
return join(configDir(), "hash-store.sqlite");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function legacyHashStorePath(): string {
|
|
14
23
|
return join(configDir(), "hash-store.json");
|
|
15
24
|
}
|
|
16
25
|
|
|
@@ -19,8 +28,9 @@ export function hashStoreDir(): string {
|
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
function expand(filePath: string): string {
|
|
22
|
-
|
|
23
|
-
if (filePath
|
|
31
|
+
const home = homeBase();
|
|
32
|
+
if (filePath === "~") return home;
|
|
33
|
+
if (filePath.startsWith("~/")) return home + filePath.slice(1);
|
|
24
34
|
return filePath;
|
|
25
35
|
}
|
|
26
36
|
|
package/src/prompts.ts
CHANGED
|
@@ -4,7 +4,7 @@ export function loadP(relativePath: string, replacements?: Record<string, string
|
|
|
4
4
|
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
5
5
|
if (replacements) {
|
|
6
6
|
for (const [key, value] of Object.entries(replacements)) {
|
|
7
|
-
content = content.
|
|
7
|
+
content = content.split(`{{${key}}}`).join(value);
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
return content;
|
|
@@ -14,7 +14,7 @@ export function loadGuide(relativePath: string, replacements?: Record<string, st
|
|
|
14
14
|
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
|
15
15
|
if (replacements) {
|
|
16
16
|
for (const [key, value] of Object.entries(replacements)) {
|
|
17
|
-
content = content.
|
|
17
|
+
content = content.split(`{{${key}}}`).join(value);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
return content
|
package/src/replace-undo.ts
CHANGED
|
@@ -2,7 +2,8 @@ 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,
|
|
5
|
+
import { loadHashStore, upsertSnapshot } from "./hash-store";
|
|
6
|
+
import { contentChecksum } from "./hashline/hasher";
|
|
6
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
7
8
|
import { toCwd } from "./paths";
|
|
8
9
|
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
@@ -82,11 +83,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
82
83
|
);
|
|
83
84
|
|
|
84
85
|
const store = await loadHashStore();
|
|
85
|
-
store
|
|
86
|
-
content: undo.content,
|
|
87
|
-
hashes: undo.hashes,
|
|
88
|
-
};
|
|
89
|
-
await saveHashStore(store);
|
|
86
|
+
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), undo.content.split("\n").length, undo.hashes);
|
|
90
87
|
|
|
91
88
|
clearUndo(mutationTargetPath);
|
|
92
89
|
|
package/src/replace.ts
CHANGED
|
@@ -154,6 +154,7 @@ export async function execPipeline(
|
|
|
154
154
|
accessMode: number,
|
|
155
155
|
signal?: AbortSignal,
|
|
156
156
|
store?: HashStore,
|
|
157
|
+
noPersist?: boolean,
|
|
157
158
|
): Promise<PipelineResult> {
|
|
158
159
|
|
|
159
160
|
const path = params.path;
|
|
@@ -167,12 +168,10 @@ export async function execPipeline(
|
|
|
167
168
|
|
|
168
169
|
const hashStore = store ?? await loadHashStore();
|
|
169
170
|
|
|
170
|
-
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
171
|
+
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
171
172
|
path, cwd, signal, accessMode, undefined, MAX_HASH_LINES, hashStore,
|
|
172
173
|
);
|
|
173
174
|
|
|
174
|
-
const absolutePath = toCwd(path, cwd);
|
|
175
|
-
const resolvedPath = await resolveTarget(absolutePath);
|
|
176
175
|
const resolved = resEdits(toolEdits);
|
|
177
176
|
const anchorResult = applyEdits(
|
|
178
177
|
originalNormalized,
|
|
@@ -197,11 +196,11 @@ export async function execPipeline(
|
|
|
197
196
|
}
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
const resultHashes = await lineHashes(result,
|
|
199
|
+
const resultHashes = await lineHashes(result, absolutePath, {
|
|
201
200
|
content: originalNormalized,
|
|
202
201
|
hashes: originalHashes,
|
|
203
202
|
removedHashes,
|
|
204
|
-
}, hashStore);
|
|
203
|
+
}, hashStore, noPersist !== true);
|
|
205
204
|
|
|
206
205
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
207
206
|
|
|
@@ -250,6 +249,9 @@ export async function compPreview(
|
|
|
250
249
|
normalized,
|
|
251
250
|
cwd,
|
|
252
251
|
constants.R_OK,
|
|
252
|
+
undefined,
|
|
253
|
+
undefined,
|
|
254
|
+
true,
|
|
253
255
|
);
|
|
254
256
|
|
|
255
257
|
if (originalNormalized === result) {
|