pi-hashline-edit-pro 0.17.4 → 0.17.6

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
@@ -154,6 +154,8 @@ Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (
154
154
 
155
155
  The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
156
156
 
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
+
157
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.
158
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).
159
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.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.17.4",
3
+ "version": "0.17.6",
4
+ "type": "module",
4
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
6
  "main": "index.ts",
6
7
  "repository": {
@@ -45,15 +46,20 @@
45
46
  "scripts": {
46
47
  "test": "vitest run",
47
48
  "test:watch": "vitest",
49
+ "lint": "eslint 'src/**/*.ts' 'index.ts'",
48
50
  "typecheck": "tsc --noEmit",
49
- "postinstall": "node scripts/ensure-better-sqlite3.js"
51
+ "postinstall": "node scripts/ensure-better-sqlite3.js",
52
+ "preuninstall": "node scripts/cleanup-better-sqlite3.js"
50
53
  },
51
54
  "devDependencies": {
52
55
  "@earendil-works/pi-coding-agent": "^0.74.0",
56
+ "@eslint/js": "^10.0.1",
53
57
  "@types/better-sqlite3": "^7.6.13",
54
58
  "@types/node": "^22.0.0",
55
59
  "@types/sql.js": "^1.4.11",
60
+ "eslint": "^10.7.0",
56
61
  "typescript": "^5.8.0",
62
+ "typescript-eslint": "^8.65.0",
57
63
  "vitest": "^4.1.8"
58
64
  },
59
65
  "allowScripts": {
@@ -0,0 +1,17 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const parentNodeModules = path.resolve(__dirname, "..", "node_modules");
5
+ if (!fs.existsSync(parentNodeModules)) process.exit(0);
6
+
7
+ const entries = fs.readdirSync(parentNodeModules, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ if (entry.name.startsWith(".better-sqlite3-")) {
10
+ const full = path.join(parentNodeModules, entry.name);
11
+ try {
12
+ fs.rmSync(full, { recursive: true, force: true });
13
+ console.error("Cleaned up stale better-sqlite3 build artifact:", entry.name);
14
+ } catch {
15
+ }
16
+ }
17
+ }
@@ -3,15 +3,38 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
 
5
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
+ }
6
28
 
7
29
  try {
8
- require(path.join(root, "node_modules", "better-sqlite3"));
30
+ require(bsqlDir);
9
31
  process.exit(0);
10
32
  } catch (e) {
11
33
  const msg = e && typeof e.message === "string" ? e.message : String(e);
12
34
  if (/GLIBC|Cannot find module|dlo|not found/i.test(msg)) {
13
35
  console.error("better-sqlite3 prebuilt incompatible, rebuilding from source...");
14
- const prebuildDir = path.join(root, "node_modules", "better-sqlite3", "prebuilds");
36
+ removeStaleArtifacts();
37
+ const prebuildDir = path.join(bsqlDir, "prebuilds");
15
38
  if (fs.existsSync(prebuildDir)) {
16
39
  const platform = process.platform + "-" + process.arch;
17
40
  const prebuilt = path.join(prebuildDir, platform + ".node");
@@ -20,11 +43,12 @@ try {
20
43
  console.error("Removed incompatible prebuilt:", platform + ".node");
21
44
  }
22
45
  }
46
+
23
47
  try {
24
- execSync("npm rebuild better-sqlite3", {
25
- cwd: root,
48
+ execSync("npx --yes node-gyp rebuild", {
49
+ cwd: bsqlDir,
26
50
  stdio: "inherit",
27
- timeout: 120000,
51
+ timeout: 300000,
28
52
  });
29
53
  console.error("better-sqlite3 rebuilt successfully from source.");
30
54
  } catch (rebuildErr) {
package/src/hash-store.ts CHANGED
@@ -36,7 +36,7 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
36
36
  return true;
37
37
  }
38
38
 
39
- // ── Backend handle abstraction ──────────────────────────────────────
39
+
40
40
 
41
41
  interface BackendHandle {
42
42
  store: HashStore;
@@ -47,7 +47,7 @@ interface BackendHandle {
47
47
 
48
48
  let cachedHandle: { path: string; handle: BackendHandle } | null = null;
49
49
 
50
- // ── better-sqlite3 backend ──────────────────────────────────────────
50
+
51
51
 
52
52
  let BetterDatabase: any = undefined;
53
53
 
@@ -104,7 +104,7 @@ function openBetterDb(storePath: string): BackendHandle {
104
104
  };
105
105
  }
106
106
 
107
- // ── sql.js backend ──────────────────────────────────────────────────
107
+
108
108
 
109
109
  let SqlJsDatabase: any = null;
110
110
  let sqlJsPromise: Promise<void> | null = null;
@@ -182,7 +182,7 @@ function openSqlJsDb(storePath: string): BackendHandle {
182
182
  };
183
183
  }
184
184
 
185
- // ── Init ────────────────────────────────────────────────────────────
185
+
186
186
 
187
187
  let backendPromise: Promise<void> | null = null;
188
188
 
@@ -195,7 +195,7 @@ async function initBackend(): Promise<void> {
195
195
  return backendPromise;
196
196
  }
197
197
 
198
- // ── Public API ──────────────────────────────────────────────────────
198
+
199
199
 
200
200
  export async function loadHashStore(): Promise<HashStore> {
201
201
  const storePath = hashStorePath();
@@ -234,7 +234,7 @@ export function shutdownHashStore(): void {
234
234
  }
235
235
  }
236
236
 
237
- // ── Internal helpers ────────────────────────────────────────────────
237
+
238
238
 
239
239
  function withStore(store: HashStore, fn: () => void): void {
240
240
  const h = cachedHandle?.handle;
@@ -296,7 +296,7 @@ async function migrateLegacy(handle: BackendHandle): Promise<void> {
296
296
  }
297
297
  }
298
298
 
299
- // ── Snapshot operations ─────────────────────────────────────────────
299
+
300
300
 
301
301
  export function getSnapshot(
302
302
  store: HashStore,
@@ -9,14 +9,12 @@ import {
9
9
  type RHEdit,
10
10
  type NEdit,
11
11
  type HEdit,
12
- type BDupWarn,
13
12
  type AutoFix,
14
13
  } from "./resolve";
15
14
 
16
15
  type LIdx = {
17
16
  fileLines: string[];
18
17
  lineStarts: number[];
19
- hasTerminalNewline: boolean;
20
18
  };
21
19
 
22
20
  export function buildIdx(content: string): LIdx {
@@ -35,7 +33,6 @@ export function buildIdx(content: string): LIdx {
35
33
  return {
36
34
  fileLines,
37
35
  lineStarts,
38
- hasTerminalNewline: content.endsWith("\n"),
39
36
  };
40
37
  };
41
38
 
@@ -73,7 +70,7 @@ function resToSpan(
73
70
  lineIndex: LIdx,
74
71
  noopEdits: NEdit[],
75
72
  ): RESpan | null {
76
- const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
73
+ const { fileLines, lineStarts } = lineIndex;
77
74
 
78
75
  const startLine = edit.hash_range_inclusive[0].line;
79
76
  const endLine = edit.hash_range_inclusive[1].line;
@@ -25,7 +25,7 @@ export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
25
25
  function h2s(h: number): string {
26
26
  const totalBits = HASH_LEN * ALPH_BITS;
27
27
  const shift = 32 - totalBits;
28
- let n = h >>> shift;
28
+ const n = h >>> shift;
29
29
  let out = "";
30
30
  for (let j = 0; j < HASH_LEN; j++) {
31
31
  out +=
@@ -142,12 +142,13 @@ function mapStableHashes(
142
142
  if (!candidates || candidates.length === 0) continue;
143
143
 
144
144
  let bestIdx = 0;
145
- if (removedHashes && removedHashes.size > 0) {
146
- for (let j = 0; j < candidates.length; j++) {
147
- if (!removedHashes.has(candidates[j]!.hash)) {
148
- bestIdx = j;
149
- break;
150
- }
145
+ let bestDist = Infinity;
146
+ for (let j = 0; j < candidates.length; j++) {
147
+ if (removedHashes?.has(candidates[j]!.hash)) continue;
148
+ const dist = Math.abs(candidates[j]!.index - i);
149
+ if (dist < bestDist) {
150
+ bestDist = dist;
151
+ bestIdx = j;
151
152
  }
152
153
  }
153
154
 
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
- HASH_CLASS,
5
4
  HL_PREFIX_PLUS_RE,
6
5
  DIFF_MINUS_RE,
7
6
  } from "./hash";
@@ -45,16 +45,12 @@ export function genDiff(
45
45
  newContent: string,
46
46
  contextLines = 2,
47
47
  newContentHashes?: string[],
48
- oldHashes?: string[],
48
+ _oldHashes?: string[],
49
49
  ): { diff: string; firstChangedLine: number | undefined } {
50
- const oldLines = oldContent.split("\n");
51
- const newLines = newContent.split("\n");
52
- const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
53
50
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
54
51
 
55
52
  const parts = Diff.diffLines(oldContent, newContent);
56
53
  const output: string[] = [];
57
- let oldLineNum = 1;
58
54
  let newLineNum = 1;
59
55
  let lastWasChange = false;
60
56
  let firstChangedLine: number | undefined;
@@ -74,7 +70,6 @@ export function genDiff(
74
70
  newLineNum++;
75
71
  } else {
76
72
  output.push(fmtDiffLine("-", displayLines[k]!, undefined));
77
- oldLineNum++;
78
73
  }
79
74
  }
80
75
  lastWasChange = true;
@@ -86,7 +81,6 @@ export function genDiff(
86
81
  if (lastWasChange || nextPartIsChange) {
87
82
  let linesToShow = displayLines;
88
83
  let skipStart = 0;
89
- let skipEnd = 0;
90
84
  let skipMiddle = 0;
91
85
 
92
86
  if (!lastWasChange) {
@@ -97,29 +91,24 @@ export function genDiff(
97
91
  linesToShow = [...displayLines.slice(0, contextLines), "__ELLIPSIS__", ...tail];
98
92
  skipMiddle = displayLines.length - contextLines * 2;
99
93
  } else if (linesToShow.length > contextLines) {
100
- skipEnd = linesToShow.length - contextLines;
101
94
  linesToShow = linesToShow.slice(0, contextLines);
102
95
  }
103
96
 
104
97
  if (skipStart > 0) {
105
98
  output.push(" ...");
106
- oldLineNum += skipStart;
107
99
  newLineNum += skipStart;
108
100
  }
109
101
  for (const line of linesToShow) {
110
102
  if (line === "__ELLIPSIS__") {
111
103
  output.push(" ...");
112
- oldLineNum += skipMiddle;
113
104
  newLineNum += skipMiddle;
114
105
  continue;
115
106
  }
116
107
  const hash = effectiveNewHashes[newLineNum - 1];
117
108
  output.push(fmtDiffLine(" ", line, hash));
118
- oldLineNum++;
119
109
  newLineNum++;
120
110
  }
121
111
  } else {
122
- oldLineNum += displayLines.length;
123
112
  newLineNum += displayLines.length;
124
113
  }
125
114
  lastWasChange = false;
@@ -1,7 +1,7 @@
1
1
  import { isRec, has } from "./utils";
2
2
  import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
3
 
4
- function tryParseContentLines(record: Record<string, unknown>, key: string, label: string): void {
4
+ function tryParseContentLines(record: Record<string, unknown>, key: string): void {
5
5
  const val = record[key];
6
6
  if (typeof val !== "string") return;
7
7
  try {
@@ -11,7 +11,7 @@ function tryParseContentLines(record: Record<string, unknown>, key: string, labe
11
11
  return;
12
12
  }
13
13
  } catch {
14
- // fall through to error
14
+
15
15
  }
16
16
  throw new Error(CONTENT_LINES_NOT_STRING_MSG);
17
17
  }
@@ -43,7 +43,7 @@ function normalizeField(
43
43
  record[to] = [parsed];
44
44
  }
45
45
  } catch {
46
- // not valid JSON, leave as-is for downstream validation
46
+
47
47
  }
48
48
  }
49
49
  if (from !== to) delete record[from];
@@ -59,7 +59,7 @@ export function normReq(input: unknown): unknown {
59
59
  normalizeFilePath(record);
60
60
 
61
61
  if (has(record, "content_lines") && typeof record.content_lines === "string") {
62
- tryParseContentLines(record, "content_lines", "Top-level");
62
+ tryParseContentLines(record, "content_lines");
63
63
  }
64
64
 
65
65
  normalizeField(record, "changes", "changes");
@@ -69,7 +69,7 @@ export function normReq(input: unknown): unknown {
69
69
  for (let i = 0; i < record.changes.length; i++) {
70
70
  const item = record.changes[i];
71
71
  if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
72
- tryParseContentLines(item, "content_lines", `changes[${i}]`);
72
+ tryParseContentLines(item, "content_lines");
73
73
  }
74
74
  }
75
75
  }
@@ -1,6 +1,6 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff } from "./replace-diff";
3
- import { visLines, cntDiff } from "./utils";
3
+ import { visLines } from "./utils";
4
4
 
5
5
  type TResult = {
6
6
  content: Array<{ type: "text"; text: string }>;