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 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.json`) 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.
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 contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
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.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). 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 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.
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, pruneHashStore } from "./src/hash-store";
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 pruneHashStore(store);
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.16.14",
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
- "vitest": "^4.1.8",
51
- "typescript": "^5.8.0"
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
- if (hasNull(sample)) {
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 { readFile, mkdir } from "fs/promises";
2
- import { stat } from "fs/promises";
3
- import { hashStorePath, hashStoreDir } from "./paths";
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 { writeAtomic } from "./fs-write";
6
- function isValidSnapshot(value: unknown): value is FileSnapshot {
7
- if (typeof value !== "object" || value === null) return false;
8
- const v = value as Record<string, unknown>;
9
- if (typeof v.content !== "string") return false;
10
- if (!Array.isArray(v.hashes)) return false;
11
- for (const h of v.hashes) {
12
- if (typeof h !== "string") return false;
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
- version: 1;
23
- snapshots: Record<string, FileSnapshot>;
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
- try {
28
- const content = await readFile(hashStorePath(), "utf-8");
29
- const parsed = JSON.parse(content) as Partial<HashStore>;
30
- const raw = parsed.snapshots;
31
- const snapshots: Record<string, FileSnapshot> = {};
32
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
33
- for (const [key, value] of Object.entries(raw)) {
34
- if (isValidSnapshot(value)) {
35
- snapshots[key] = value;
36
- }
37
- }
38
- }
39
- return { version: 1, snapshots };
40
- } catch (error: unknown) {
41
- if (errCode(error) !== "ENOENT") {
42
- console.error("Hash store corrupted, creating fresh store:", error);
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 async function saveHashStore(store: HashStore): Promise<void> {
55
- await mkdir(hashStoreDir(), { recursive: true });
56
- await writeAtomic(hashStorePath(), JSON.stringify(store, null, 2));
138
+ export function shutdownHashStore(): void {
139
+ if (cachedStore) {
140
+ cachedStore.store.db.close();
141
+ cachedStore = null;
142
+ }
57
143
  }
58
144
 
59
- export async function pruneHashStore(store: HashStore): Promise<void> {
60
- let changed = false;
61
- for (const filePath of Object.keys(store.snapshots)) {
62
- try {
63
- await stat(filePath);
64
- } catch {
65
- delete store.snapshots[filePath];
66
- changed = true;
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 };
@@ -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: import("./resolve").HEdit[],
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: import("./resolve").HEdit[] = edits.map(e => ({
300
+ const correctedEdits: HEdit[] = edits.map(e => ({
301
301
  ...e,
302
302
  content_lines: [...e.content_lines],
303
303
  }));
@@ -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
- hashStore.snapshots[path] = { content, hashes: newHashes };
116
- await saveHashStore(hashStore);
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 snapshot = hashStore.snapshots[path];
121
- if (snapshot && snapshot.content === content) {
122
- return snapshot.hashes;
104
+ const cached = getSnapshot(hashStore, path, content);
105
+ if (cached) {
106
+ return cached;
123
107
  }
124
108
 
125
109
  const newHashes = _lineHashesPure(content);
126
- hashStore.snapshots[path] = { content, hashes: newHashes };
127
- await saveHashStore(hashStore);
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(homedir(), ".config", "pi-hashline-edit-pro");
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
- if (filePath === "~") return homedir();
23
- if (filePath.startsWith("~/")) return homedir() + filePath.slice(1);
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.replaceAll(`{{${key}}}`, value);
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.replaceAll(`{{${key}}}`, value);
17
+ content = content.split(`{{${key}}}`).join(value);
18
18
  }
19
19
  }
20
20
  return content
@@ -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, saveHashStore } from "./hash-store";
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.snapshots[mutationTargetPath] = {
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, resolvedPath, {
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) {