pi-hashline-edit-pro 0.17.14 → 0.18.1

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
@@ -13,7 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
13
13
  This fork makes two changes that compound:
14
14
 
15
15
  1. **3-character hash length** over a 64-char URL-safe base64 alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 262,144 buckets.
16
- 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
16
+ 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
17
17
 
18
18
  ## Installation
19
19
 
@@ -156,7 +156,7 @@ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squin
156
156
 
157
157
  Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
158
158
 
159
- **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
159
+ **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
160
160
  The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
161
161
  `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
162
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.17.14",
3
+ "version": "0.18.1",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
6
6
  "main": "index.ts",
@@ -24,8 +24,7 @@
24
24
  "src",
25
25
  "prompts",
26
26
  "README.md",
27
- "LICENSE",
28
- "scripts"
27
+ "LICENSE"
29
28
  ],
30
29
  "pi": {
31
30
  "extensions": [
@@ -33,10 +32,8 @@
33
32
  ]
34
33
  },
35
34
  "dependencies": {
36
- "better-sqlite3": "^13.0.1",
37
35
  "diff": "^8.0.2",
38
36
  "file-type": "^21.3.0",
39
- "sql.js": "1.11",
40
37
  "xxhash-wasm": "^1.1.0"
41
38
  },
42
39
  "peerDependencies": {
@@ -47,23 +44,18 @@
47
44
  "test": "vitest run",
48
45
  "test:watch": "vitest",
49
46
  "lint": "eslint 'src/**/*.ts' 'index.ts'",
50
- "typecheck": "tsc --noEmit",
51
- "postinstall": "node scripts/ensure-better-sqlite3.cjs",
52
- "preuninstall": "node scripts/cleanup-better-sqlite3.cjs"
47
+ "typecheck": "tsc --noEmit"
53
48
  },
54
49
  "devDependencies": {
55
50
  "@earendil-works/pi-coding-agent": "^0.74.0",
56
51
  "@eslint/js": "^10.0.1",
57
- "@types/better-sqlite3": "^7.6.13",
58
- "@types/node": "^22.0.0",
59
- "@types/sql.js": "^1.4.11",
52
+ "@types/node": "^24.0.0",
60
53
  "eslint": "^10.7.0",
61
54
  "typescript": "^5.8.0",
62
55
  "typescript-eslint": "^8.65.0",
63
56
  "vitest": "^4.1.8"
64
57
  },
65
58
  "allowScripts": {
66
- "better-sqlite3@13.0.1": true,
67
59
  "@google/genai@1.52.0": true,
68
60
  "koffi@2.16.2": true,
69
61
  "protobufjs@7.6.4": true
@@ -1,2 +0,0 @@
1
- - `undo_last_replace`: call with the file path to revert the last replace on that file.
2
- - `undo_last_replace`: only the most recent replace per file is tracked.
package/src/constants.ts CHANGED
@@ -6,7 +6,7 @@ export const MAX_HASH_LINES = 1_000_000;
6
6
  export const MAX_HASH_RETRIES = 262_144;
7
7
 
8
8
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
9
- export const HASH_STORE_VERSION = 2;
9
+ export const HASH_STORE_VERSION = 3;
10
10
  export const CONTENT_LINES_NOT_STRING_MSG =
11
11
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
12
12
  + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/hash-store.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { readFileSync, writeFileSync, existsSync } from "fs";
1
+ import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
+ import { DatabaseSync } from "node:sqlite";
3
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
4
- import { errCode } from "./utils";
5
+ import { errCode, splitLines } from "./utils";
5
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
6
7
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
7
- import initSqlJs from "sql.js";
8
-
9
8
  type SqlParams = (string | number)[];
10
9
 
11
10
  interface Prepared {
@@ -17,7 +16,7 @@ interface Prepared {
17
16
 
18
17
  export interface HashStore {
19
18
  readonly stmts: Prepared;
20
- readonly engine: "better-sqlite3" | "sql.js";
19
+ readonly engine: "node:sqlite";
21
20
  }
22
21
 
23
22
  interface LegacySnapshot {
@@ -36,38 +35,15 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
36
35
  return true;
37
36
  }
38
37
 
39
-
40
-
41
- interface BackendHandle {
42
- store: HashStore;
43
- close: () => void;
44
- transact: (fn: () => void) => void;
45
- prepare: (sql: string) => { run: (...params: unknown[]) => void; free: () => void };
46
- }
47
-
48
- let cachedHandle: { path: string; handle: BackendHandle } | null = null;
49
-
50
-
51
-
52
- let BetterDatabase: any = undefined;
53
-
54
- async function tryLoadBetter(): Promise<boolean> {
55
- if (BetterDatabase !== undefined) return BetterDatabase !== null;
56
- try {
57
- const mod = await import("better-sqlite3");
58
- BetterDatabase = mod.default || mod;
59
- return true;
60
- } catch {
61
- BetterDatabase = null;
62
- return false;
63
- }
64
- }
65
-
66
- function openBetterDb(storePath: string): BackendHandle {
67
- const db = new BetterDatabase(storePath);
68
- db.pragma("journal_mode = WAL");
69
- db.pragma("synchronous = NORMAL");
70
- db.pragma(`busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
38
+ let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
39
+ let exitHandlerRegistered = false;
40
+ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
41
+ const db = new DatabaseSync(storePath, {
42
+ timeout: HASH_STORE_BUSY_TIMEOUT,
43
+ defensive: false,
44
+ } as any);
45
+ db.exec("PRAGMA journal_mode = WAL");
46
+ db.exec("PRAGMA synchronous = NORMAL");
71
47
  db.exec(
72
48
  "CREATE TABLE IF NOT EXISTS snapshots (" +
73
49
  "path TEXT PRIMARY KEY, " +
@@ -93,169 +69,70 @@ function openBetterDb(storePath: string): BackendHandle {
93
69
  upsert: (...params) => { upsertStmt.run(...params); },
94
70
  };
95
71
 
96
- return {
97
- store: { stmts, engine: "better-sqlite3" },
98
- close: () => { db.close(); },
99
- transact: (fn) => { db.transaction(fn).immediate(); },
100
- prepare: (sql) => {
101
- const stmt = db.prepare(sql);
102
- return { run: (...params) => stmt.run(...params), free: () => {} };
103
- },
104
- };
105
- }
106
-
107
-
108
-
109
- let SqlJsDatabase: any = null;
110
- let sqlJsPromise: Promise<void> | null = null;
111
-
112
- async function ensureSqlJs(): Promise<void> {
113
- if (sqlJsPromise) return sqlJsPromise;
114
- sqlJsPromise = initSqlJs().then((SQL) => { SqlJsDatabase = SQL.Database; });
115
- return sqlJsPromise;
116
- }
117
-
118
- function openSqlJsDb(storePath: string): BackendHandle {
119
- const data = existsSync(storePath) ? new Uint8Array(readFileSync(storePath)) : undefined;
120
- const db = new SqlJsDatabase(data);
121
-
122
- db.run(
123
- "CREATE TABLE IF NOT EXISTS snapshots (" +
124
- "path TEXT PRIMARY KEY, " +
125
- "checksum TEXT NOT NULL, " +
126
- "line_count INTEGER NOT NULL, " +
127
- "hashes TEXT NOT NULL, " +
128
- "updated_at INTEGER NOT NULL" +
129
- ")"
130
- );
131
-
132
- function save() {
133
- writeFileSync(storePath, Buffer.from(db.export()));
134
- }
135
-
136
- save();
137
-
138
- const stmts: Prepared = {
139
- get: (...params) => {
140
- const stmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
141
- stmt.bind(params);
142
- let result: Record<string, unknown> | undefined;
143
- if (stmt.step()) result = stmt.getAsObject() as Record<string, unknown>;
144
- stmt.free();
145
- return result;
146
- },
147
- allPaths: (...params) => {
148
- const stmt = db.prepare("SELECT path FROM snapshots");
149
- if (params.length > 0) stmt.bind(params);
150
- const results: Record<string, unknown>[] = [];
151
- while (stmt.step()) results.push(stmt.getAsObject() as Record<string, unknown>);
152
- stmt.free();
153
- return results;
154
- },
155
- deleteOne: (...params) => {
156
- if (params.length > 0) db.run("DELETE FROM snapshots WHERE path = ?", params);
157
- else db.run("DELETE FROM snapshots WHERE path = ?");
158
- },
159
- upsert: (...params) => {
160
- db.run(
161
- "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
162
- "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
163
- params
164
- );
165
- },
166
- };
167
-
168
- return {
169
- store: { stmts, engine: "sql.js" },
170
- close: () => { db.close(); },
171
- transact: (fn) => {
172
- db.run("BEGIN IMMEDIATE");
173
- try { fn(); db.run("COMMIT"); save(); } catch (e) { db.run("ROLLBACK"); throw e; }
174
- },
175
- prepare: (sql) => {
176
- const stmt = db.prepare(sql);
177
- return {
178
- run: (...params) => { stmt.bind(params); stmt.step(); stmt.reset(); },
179
- free: () => { stmt.free(); },
180
- };
181
- },
182
- };
72
+ return { db, stmts };
183
73
  }
184
74
 
185
-
186
-
187
- let backendPromise: Promise<void> | null = null;
188
-
189
- async function initBackend(): Promise<void> {
190
- if (backendPromise) return backendPromise;
191
- backendPromise = (async () => {
192
- const hasBetter = await tryLoadBetter();
193
- if (!hasBetter) await ensureSqlJs();
194
- })();
195
- return backendPromise;
196
- }
197
-
198
-
199
-
200
75
  export async function loadHashStore(): Promise<HashStore> {
201
76
  const storePath = hashStorePath();
202
- if (cachedHandle && cachedHandle.path === storePath) {
203
- return cachedHandle.handle.store;
77
+ if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
78
+ return { stmts: cachedDb.stmts, engine: "node:sqlite" };
204
79
  }
205
80
 
206
81
  shutdownHashStore();
207
82
 
208
83
  await initHasher();
209
84
  await mkdir(hashStoreDir(), { recursive: true });
210
- await initBackend();
211
85
 
212
86
  const existed = existsSync(storePath);
87
+ const { db, stmts } = openDb(storePath);
213
88
 
214
- let handle: BackendHandle;
215
- if (BetterDatabase) {
216
- try {
217
- handle = openBetterDb(storePath);
218
- } catch {
219
- const debug = process.env.PI_HASHLINE_DEBUG === "1" || process.env.PI_HASHLINE_DEBUG === "true";
220
- if (debug) {
221
- console.error('better-sqlite3 native binding failed, falling back to sql.js');
222
- }
223
- BetterDatabase = null;
224
- await ensureSqlJs();
225
- handle = openSqlJsDb(storePath);
226
- }
227
- } else {
228
- await ensureSqlJs();
229
- handle = openSqlJsDb(storePath);
89
+ if (!existed) {
90
+ await migrateLegacy(db);
230
91
  }
231
92
 
232
- if (!existed) {
233
- await migrateLegacy(handle);
93
+ cachedDb = { path: storePath, db, stmts };
94
+
95
+ if (!exitHandlerRegistered) {
96
+ exitHandlerRegistered = true;
97
+ process.once("exit", () => shutdownHashStore());
98
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
99
+ process.once(sig, () => {
100
+ shutdownHashStore();
101
+ process.kill(process.pid, sig);
102
+ });
103
+ }
234
104
  }
235
105
 
236
- cachedHandle = { path: storePath, handle };
237
- return handle.store;
106
+ return { stmts, engine: "node:sqlite" };
238
107
  }
239
108
 
240
109
  export function shutdownHashStore(): void {
241
- if (cachedHandle) {
242
- cachedHandle.handle.close();
243
- cachedHandle = null;
110
+ if (cachedDb) {
111
+ try {
112
+ cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
+ } catch {
114
+ }
115
+ cachedDb.db.close();
116
+ cachedDb = null;
244
117
  }
245
118
  }
246
119
 
247
-
248
-
249
- function withStore(store: HashStore, fn: () => void): void {
250
- const h = cachedHandle?.handle;
251
- if (h && h.store === store) {
252
- h.transact(fn);
120
+ function withStore(fn: () => void): void {
121
+ if (cachedDb) {
122
+ cachedDb.db.exec("BEGIN IMMEDIATE");
123
+ try {
124
+ fn();
125
+ cachedDb.db.exec("COMMIT");
126
+ } catch (e) {
127
+ cachedDb.db.exec("ROLLBACK");
128
+ throw e;
129
+ }
253
130
  } else {
254
131
  fn();
255
132
  }
256
133
  }
257
134
 
258
- async function migrateLegacy(handle: BackendHandle): Promise<void> {
135
+ async function migrateLegacy(db: DatabaseSync): Promise<void> {
259
136
  const legacyPath = legacyHashStorePath();
260
137
  let content: string;
261
138
  try {
@@ -283,20 +160,23 @@ async function migrateLegacy(handle: BackendHandle): Promise<void> {
283
160
  rows.push([
284
161
  key,
285
162
  contentChecksum(value.content),
286
- value.content.split("\n").length,
163
+ splitLines(value.content).length,
287
164
  JSON.stringify(value.hashes),
288
165
  Date.now(),
289
166
  ]);
290
167
  }
291
-
292
168
  if (rows.length > 0) {
293
- handle.transact(() => {
294
- const stmt = handle.prepare(
169
+ db.exec("BEGIN IMMEDIATE");
170
+ try {
171
+ const stmt = db.prepare(
295
172
  "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
296
173
  );
297
174
  for (const row of rows) stmt.run(...row);
298
- stmt.free();
299
- });
175
+ db.exec("COMMIT");
176
+ } catch (e) {
177
+ db.exec("ROLLBACK");
178
+ throw e;
179
+ }
300
180
  }
301
181
 
302
182
  try {
@@ -306,15 +186,13 @@ async function migrateLegacy(handle: BackendHandle): Promise<void> {
306
186
  }
307
187
  }
308
188
 
309
-
310
-
311
189
  export function getSnapshot(
312
190
  store: HashStore,
313
191
  path: string,
314
192
  content: string,
315
193
  ): string[] | undefined {
316
194
  const checksum = contentChecksum(content);
317
- const lineCount = content.split("\n").length;
195
+ const lineCount = splitLines(content).length;
318
196
  const row = store.stmts.get(path, checksum, lineCount);
319
197
  return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
320
198
  }
@@ -327,13 +205,13 @@ export function upsertSnapshot(
327
205
  hashes: string[],
328
206
  ): void {
329
207
  const hashesJson = JSON.stringify(hashes);
330
- withStore(store, () => {
208
+ withStore(() => {
331
209
  store.stmts.upsert(path, checksum, lineCount, hashesJson, Date.now());
332
210
  });
333
211
  }
334
212
 
335
213
  export function deleteSnapshot(store: HashStore, path: string): void {
336
- withStore(store, () => {
214
+ withStore(() => {
337
215
  store.stmts.deleteOne(path);
338
216
  });
339
217
  }
@@ -349,7 +227,7 @@ export async function pruneMissing(store: HashStore): Promise<void> {
349
227
  }
350
228
  }
351
229
  if (missing.length === 0) return;
352
- withStore(store, () => {
230
+ withStore(() => {
353
231
  for (const path of missing) store.stmts.deleteOne(path);
354
232
  });
355
233
  }
@@ -1,4 +1,4 @@
1
- import { abortIf, visLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
1
+ import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
2
2
  import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdits,
@@ -18,22 +18,22 @@ type LIdx = {
18
18
  };
19
19
 
20
20
  export function buildIdx(content: string): LIdx {
21
- const fileLines = content.split("\n");
22
- const lineStarts: number[] = [];
23
- let offset = 0;
24
-
25
- for (let index = 0; index < fileLines.length; index++) {
26
- lineStarts.push(offset);
27
- offset += fileLines[index]!.length;
28
- if (index < fileLines.length - 1) {
29
- offset += 1;
30
- }
31
- }
21
+ const fileLines = splitLines(content);
22
+ const lineStarts: number[] = [];
23
+ let offset = 0;
24
+
25
+ for (let index = 0; index < fileLines.length; index++) {
26
+ lineStarts.push(offset);
27
+ offset += fileLines[index]!.length;
28
+ if (index < fileLines.length - 1) {
29
+ offset += 1;
30
+ }
31
+ }
32
32
 
33
- return {
34
- fileLines,
35
- lineStarts,
36
- };
33
+ return {
34
+ fileLines,
35
+ lineStarts,
36
+ };
37
37
  };
38
38
 
39
39
  type RESpan = {
@@ -124,16 +124,26 @@ function resToSpan(
124
124
  };
125
125
  }
126
126
 
127
+ if (content.endsWith("\n")) {
128
+ return {
129
+ kind: "replace",
130
+ index,
131
+ label,
132
+ start: lineStarts[startLine - 1]!,
133
+ end: content.length,
134
+ replacement: "",
135
+ };
136
+ }
137
+
127
138
  return {
128
139
  kind: "replace",
129
140
  index,
130
141
  label,
131
142
  start: Math.max(0, lineStarts[startLine - 1]! - 1),
132
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
143
+ end: content.length,
133
144
  replacement: "",
134
145
  };
135
146
  }
136
-
137
147
  function assertNoConflict(spans: RESpan[]): void {
138
148
  for (let leftIndex = 0; leftIndex < spans.length; leftIndex++) {
139
149
  const left = spans[leftIndex]!;
@@ -375,14 +385,14 @@ export function changedRange(
375
385
  if (original.length === 0) {
376
386
  return {
377
387
  firstChangedLine: 1,
378
- lastChangedLine: visLines(result).length,
388
+ lastChangedLine: splitLines(result).length,
379
389
  };
380
390
  }
381
391
 
382
392
  if (result.startsWith(original) && original.endsWith("\n")) {
383
393
  return {
384
- firstChangedLine: visLines(original).length + 1,
385
- lastChangedLine: visLines(result).length,
394
+ firstChangedLine: splitLines(original).length + 1,
395
+ lastChangedLine: splitLines(result).length,
386
396
  };
387
397
  }
388
398
 
@@ -415,7 +425,7 @@ export function changedRange(
415
425
  const firstChangedLine = idxToLine(firstDiff + 1, result);
416
426
  let lastChangedLine: number;
417
427
  if (lastRes < firstDiff) {
418
- lastChangedLine = result.length === 0 ? 1 : visLines(result).length;
428
+ lastChangedLine = result.length === 0 ? 1 : splitLines(result).length;
419
429
  } else if (
420
430
  firstDiff === 0 &&
421
431
  original.length > 0 &&
@@ -1,4 +1,4 @@
1
- import { MAX_HASH_RETRIES } from "../constants";
1
+ import { splitLines } from "../utils";
2
2
  import {
3
3
  loadHashStore,
4
4
  type HashStore,
@@ -6,7 +6,6 @@ import {
6
6
  upsertSnapshot,
7
7
  } from "../hash-store";
8
8
  import { xxh32, contentChecksum, initHasher } from "./hasher";
9
-
10
9
  export { initHasher };
11
10
 
12
11
  export const HASH_LEN = 3;
@@ -22,21 +21,19 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
22
21
  const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
23
22
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
24
23
 
25
- function h2s(h: number): string {
26
- const totalBits = HASH_LEN * ALPH_BITS;
27
- const shift = 32 - totalBits;
28
- const n = h >>> shift;
29
- let out = "";
30
- for (let j = 0; j < HASH_LEN; j++) {
31
- out +=
32
- ALPH[
33
- (n >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) &
34
- ALPH_MASK
35
- ]!;
36
- }
37
- return out;
24
+ function idxToHash(idx: number): string {
25
+ let out = "";
26
+ for (let j = 0; j < HASH_LEN; j++) {
27
+ out += ALPH[(idx >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) & ALPH_MASK]!;
28
+ }
29
+ return out;
38
30
  }
39
31
 
32
+ const HASH_TABLE: string[] = Array.from(
33
+ { length: 262_144 },
34
+ (_, i) => idxToHash(i),
35
+ );
36
+
40
37
  export const HL_PREFIX_RE = new RegExp(
41
38
  `^\\s*(?:>>>|>>)?\\s*${HASH_CLASS}│`,
42
39
  );
@@ -47,33 +44,85 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
47
44
 
48
45
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
49
46
 
50
-
51
47
  function canon(line: string): string {
52
48
  return line.replace(/\r/g, "").trimEnd();
53
49
  }
54
50
 
55
- function nextUniqueHash(content: string, used: Set<string>): string {
56
- let retry = 0;
57
- let hash = h2s(xxh32(content));
58
- while (used.has(hash)) {
59
- retry++;
60
- if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
61
- hash = h2s(xxh32(`${content}:R${retry}`));
62
- }
63
- used.add(hash);
64
- return hash;
51
+ const BITSET_WORDS = 8192;
52
+
53
+ function getBit(bits: Uint32Array, idx: number): boolean {
54
+ return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
55
+ }
56
+
57
+ function setBit(bits: Uint32Array, idx: number): void {
58
+ bits[idx >>> 5] |= 1 << (idx & 31);
59
+ }
60
+
61
+ function nextZeroBit(bits: Uint32Array, start: number): number {
62
+ const totalWords = bits.length;
63
+ const totalBits = totalWords * 32;
64
+
65
+ if (start >= totalBits) start = 0;
66
+
67
+ const wordIdx = start >>> 5;
68
+ const bitOffset = start & 31;
69
+
70
+ let word = bits[wordIdx];
71
+ for (let b = bitOffset; b < 32; b++) {
72
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
73
+ }
74
+
75
+ for (let w = wordIdx + 1; w < totalWords; w++) {
76
+ word = bits[w];
77
+ if (~word !== 0) {
78
+ for (let b = 0; b < 32; b++) {
79
+ if ((word >>> b & 1) === 0) return w * 32 + b;
80
+ }
81
+ }
82
+ }
83
+
84
+ for (let w = 0; w < wordIdx; w++) {
85
+ word = bits[w];
86
+ if (~word !== 0) {
87
+ for (let b = 0; b < 32; b++) {
88
+ if ((word >>> b & 1) === 0) return w * 32 + b;
89
+ }
90
+ }
91
+ }
92
+
93
+ word = bits[wordIdx];
94
+ for (let b = 0; b < bitOffset; b++) {
95
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
96
+ }
97
+
98
+ throw new Error("Hash space exhausted");
99
+ }
100
+
101
+ function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
102
+ if (!getBit(used, baseIdx)) {
103
+ setBit(used, baseIdx);
104
+ hint.value = baseIdx + 1;
105
+ return HASH_TABLE[baseIdx];
106
+ }
107
+ const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
108
+ const nextIdx = nextZeroBit(used, start);
109
+ setBit(used, nextIdx);
110
+ hint.value = nextIdx + 1;
111
+ return HASH_TABLE[nextIdx];
65
112
  }
66
113
 
67
114
  export function _lineHashesPure(content: string): string[] {
68
- const lines = content.split("\n");
69
- const hashes = new Array<string>(lines.length);
70
- const assigned = new Set<string>();
71
- for (let i = 0; i < lines.length; i++) {
72
- const c = canon(lines[i]!);
73
- const hash = nextUniqueHash(c, assigned);
74
- hashes[i] = hash;
75
- }
76
- return hashes;
115
+ const lines = splitLines(content);
116
+ const hashes = new Array<string>(lines.length);
117
+ const used = new Uint32Array(BITSET_WORDS);
118
+ const hint = { value: 0 };
119
+
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const c = canon(lines[i]!);
122
+ const baseIdx = xxh32(c) >>> 14;
123
+ hashes[i] = assignHash(used, baseIdx, hint);
124
+ }
125
+ return hashes;
77
126
  }
78
127
 
79
128
  export async function lineHashes(
@@ -96,7 +145,7 @@ export async function lineHashes(
96
145
  previous.removedHashes,
97
146
  );
98
147
  if (persist !== false) {
99
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
148
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
100
149
  }
101
150
  return newHashes;
102
151
  }
@@ -108,23 +157,41 @@ export async function lineHashes(
108
157
 
109
158
  const newHashes = _lineHashesPure(content);
110
159
  if (persist !== false) {
111
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
160
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
112
161
  }
113
162
  return newHashes;
114
163
  }
115
164
 
165
+ function hashToIndex(hash: string): number {
166
+ let idx = 0;
167
+ for (let j = 0; j < HASH_LEN; j++) {
168
+ const charIdx = ALPH.indexOf(hash[j]!);
169
+ if (charIdx < 0) return -1;
170
+ idx = (idx << ALPH_BITS) | charIdx;
171
+ }
172
+ return idx;
173
+ }
174
+
116
175
  function mapStableHashes(
117
176
  oldContent: string,
118
177
  oldHashes: string[],
119
178
  newContent: string,
120
179
  removedHashes?: Set<string>,
121
180
  ): string[] {
122
- const newLines = newContent.split("\n");
181
+ const newLines = splitLines(newContent);
123
182
  const newHashes = new Array<string>(newLines.length);
124
- const used = new Set<string>();
183
+ const used = new Uint32Array(BITSET_WORDS);
184
+ const hint = { value: 0 };
185
+
186
+ if (removedHashes) {
187
+ for (const hash of removedHashes) {
188
+ const idx = hashToIndex(hash);
189
+ if (idx >= 0) setBit(used, idx);
190
+ }
191
+ }
125
192
 
126
193
  const contentMap = new Map<string, { index: number; hash: string }[]>();
127
- const oldLines = oldContent.split("\n");
194
+ const oldLines = splitLines(oldContent);
128
195
  for (let i = 0; i < oldLines.length; i++) {
129
196
  const line = oldLines[i]!;
130
197
  const entry = { index: i, hash: oldHashes[i]! };
@@ -155,14 +222,18 @@ function mapStableHashes(
155
222
  if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
156
223
  const match = candidates.splice(bestIdx, 1)[0]!;
157
224
  newHashes[i] = match.hash;
158
- used.add(match.hash);
225
+ const matchIdx = hashToIndex(match.hash);
226
+ if (matchIdx >= 0) {
227
+ setBit(used, matchIdx);
228
+ if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
229
+ }
159
230
  }
160
231
 
161
232
  for (let i = 0; i < newLines.length; i++) {
162
233
  if (newHashes[i]) continue;
163
234
  const c = canon(newLines[i]!);
164
- const hash = nextUniqueHash(c, used);
165
- newHashes[i] = hash;
235
+ const baseIdx = xxh32(c) >>> 14;
236
+ newHashes[i] = assignHash(used, baseIdx, hint);
166
237
  }
167
238
  return newHashes;
168
239
  }
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
9
9
  import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
10
- import { cntDiff } from "./utils";
10
+ import { cntDiff, splitLines } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
13
13
  export interface UndoEntry {
@@ -84,7 +84,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
84
84
  );
85
85
 
86
86
  const store = await loadHashStore();
87
- upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), undo.content.split("\n").length, undo.hashes);
87
+ upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
88
88
 
89
89
  clearUndo(mutationTargetPath);
90
90
 
package/src/replace.ts CHANGED
@@ -148,6 +148,7 @@ export function assertReq(
148
148
  throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
149
149
  }
150
150
  }
151
+
151
152
  export interface ExecPipelineOptions {
152
153
  accessMode?: number;
153
154
  signal?: AbortSignal;
@@ -155,6 +156,46 @@ export interface ExecPipelineOptions {
155
156
  noPersist?: boolean;
156
157
  }
157
158
 
159
+ function collectRemovedHashes(
160
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
161
+ originalHashes: string[],
162
+ ): Set<string> {
163
+ const removedHashes = new Set<string>();
164
+ for (const edit of resolved) {
165
+ const startHash = edit.hash_range_inclusive[0].hash;
166
+ const endHash = edit.hash_range_inclusive[1].hash;
167
+ const startLine = originalHashes.indexOf(startHash);
168
+ const endLine = originalHashes.indexOf(endHash);
169
+ if (startLine >= 0 && endLine >= 0) {
170
+ for (let i = startLine; i <= endLine; i++) {
171
+ removedHashes.add(originalHashes[i]!);
172
+ }
173
+ }
174
+ }
175
+ return removedHashes;
176
+ }
177
+
178
+ function countLineChanges(
179
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }]; content_lines: string[] }[],
180
+ originalHashes: string[],
181
+ noopEdits: { editIndex: number }[] | undefined,
182
+ ): { totalAddedLines: number; totalRemovedLines: number } {
183
+ let totalAddedLines = 0;
184
+ let totalRemovedLines = 0;
185
+ const noopIndices = new Set(noopEdits?.map((n) => n.editIndex) ?? []);
186
+ for (let i = 0; i < resolved.length; i++) {
187
+ if (noopIndices.has(i)) continue;
188
+ const edit = resolved[i]!;
189
+ const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
190
+ const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
191
+ if (startLine >= 0 && endLine >= 0) {
192
+ totalRemovedLines += endLine - startLine + 1;
193
+ }
194
+ totalAddedLines += edit.content_lines.length;
195
+ }
196
+ return { totalAddedLines, totalRemovedLines };
197
+ }
198
+
158
199
  export async function execPipeline(
159
200
  params: ReqParams,
160
201
  cwd: string,
@@ -187,18 +228,7 @@ export async function execPipeline(
187
228
 
188
229
  const result = anchorResult.content;
189
230
 
190
- const removedHashes = new Set<string>();
191
- for (const edit of resolved) {
192
- const startHash = edit.hash_range_inclusive[0].hash;
193
- const endHash = edit.hash_range_inclusive[1].hash;
194
- const startLine = originalHashes.indexOf(startHash);
195
- const endLine = originalHashes.indexOf(endHash);
196
- if (startLine >= 0 && endLine >= 0) {
197
- for (let i = startLine; i <= endLine; i++) {
198
- removedHashes.add(originalHashes[i]!);
199
- }
200
- }
201
- }
231
+ const removedHashes = collectRemovedHashes(resolved, originalHashes);
202
232
 
203
233
  const noPersist = options?.noPersist;
204
234
  const resultHashes = await lineHashes(result, absolutePath, {
@@ -209,19 +239,9 @@ export async function execPipeline(
209
239
 
210
240
  const warnings = [...(anchorResult.warnings ?? [])];
211
241
 
212
- let totalAddedLines = 0;
213
- let totalRemovedLines = 0;
214
- const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
215
- for (let i = 0; i < resolved.length; i++) {
216
- if (noopIndices.has(i)) continue;
217
- const edit = resolved[i]!;
218
- const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
219
- const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
220
- if (startLine >= 0 && endLine >= 0) {
221
- totalRemovedLines += endLine - startLine + 1;
222
- }
223
- totalAddedLines += edit.content_lines.length;
224
- }
242
+ const { totalAddedLines, totalRemovedLines } = countLineChanges(
243
+ resolved, originalHashes, anchorResult.noopEdits,
244
+ );
225
245
 
226
246
  return {
227
247
  path,
@@ -289,6 +309,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
289
309
  m.setText(content);
290
310
  return m;
291
311
  }
312
+
292
313
  const MODE_CFG = {
293
314
  flat: {
294
315
  desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
@@ -435,7 +456,6 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
435
456
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
436
457
  const canonical = normReq(params);
437
458
 
438
-
439
459
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
440
460
  const path = normalizedParams.path;
441
461
  const absolutePath = toCwd(path, ctx.cwd);
package/src/utils.ts CHANGED
@@ -6,6 +6,12 @@ export function has(record: Record<string, unknown>, key: string): boolean {
6
6
  return Object.hasOwn(record, key);
7
7
  }
8
8
 
9
+ export function splitLines(text: string): string[] {
10
+ if (text.length === 0) return [""];
11
+ const lines = text.split("\n");
12
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
13
+ }
14
+
9
15
  export function visLines(text: string): string[] {
10
16
  if (text.length === 0) return [];
11
17
  const lines = text.split("\n");
@@ -1,21 +0,0 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
-
4
- function cleanNodeModules(nm) {
5
- if (!fs.existsSync(nm)) return;
6
- const entries = fs.readdirSync(nm, { withFileTypes: true });
7
- for (const entry of entries) {
8
- if (entry.name.startsWith(".better-sqlite3-")) {
9
- const full = path.join(nm, entry.name);
10
- try {
11
- fs.rmSync(full, { recursive: true, force: true });
12
- console.error("Cleaned up stale better-sqlite3 build artifact:", entry.name);
13
- } catch {
14
- }
15
- }
16
- }
17
- }
18
-
19
- const pkgDir = path.resolve(__dirname, "..");
20
- cleanNodeModules(path.resolve(pkgDir, "node_modules"));
21
- cleanNodeModules(path.resolve(pkgDir, ".."));
@@ -1,60 +0,0 @@
1
- const { execSync } = require("child_process");
2
- const fs = require("fs");
3
- const path = require("path");
4
-
5
- const root = path.resolve(__dirname, "..");
6
- const bsqlDir = path.join(root, "node_modules", "better-sqlite3");
7
-
8
- function removeStaleArtifacts() {
9
- for (const dir of [root, bsqlDir]) {
10
- if (!fs.existsSync(dir)) continue;
11
- const entries = fs.readdirSync(dir, { withFileTypes: true });
12
- for (const entry of entries) {
13
- if (entry.name.startsWith(".better-sqlite3-")) {
14
- const full = path.join(dir, entry.name);
15
- try {
16
- fs.rmSync(full, { recursive: true, force: true });
17
- console.error("Removed stale artifact:", entry.name);
18
- } catch {
19
- }
20
- }
21
- }
22
- }
23
- const buildDir = path.join(bsqlDir, "build");
24
- if (fs.existsSync(buildDir)) {
25
- fs.rmSync(buildDir, { recursive: true, force: true });
26
- }
27
- }
28
-
29
- try {
30
- require(bsqlDir);
31
- process.exit(0);
32
- } catch (e) {
33
- const msg = e && typeof e.message === "string" ? e.message : String(e);
34
- if (/GLIBC|Cannot find module|dlo|not found/i.test(msg)) {
35
- console.error("better-sqlite3 prebuilt incompatible, rebuilding from source...");
36
- removeStaleArtifacts();
37
- const prebuildDir = path.join(bsqlDir, "prebuilds");
38
- if (fs.existsSync(prebuildDir)) {
39
- const platform = process.platform + "-" + process.arch;
40
- const prebuilt = path.join(prebuildDir, platform + ".node");
41
- if (fs.existsSync(prebuilt)) {
42
- fs.unlinkSync(prebuilt);
43
- console.error("Removed incompatible prebuilt:", platform + ".node");
44
- }
45
- }
46
-
47
- try {
48
- execSync("npx --yes node-gyp rebuild", {
49
- cwd: bsqlDir,
50
- stdio: "inherit",
51
- timeout: 300000,
52
- });
53
- console.error("better-sqlite3 rebuilt successfully from source.");
54
- } catch (rebuildErr) {
55
- console.error("better-sqlite3 rebuild failed:", rebuildErr.message);
56
- console.error("Will fall back to sql.js at runtime.");
57
- process.exit(0);
58
- }
59
- }
60
- }