pi-hashline-edit-pro 0.19.0 → 0.19.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
@@ -96,6 +96,11 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
96
96
 
97
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. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
98
98
 
99
+ Two guarantees make the mapping safe for duplicated content:
100
+
101
+ - **An edited range never borrows a hash from a line outside it.** Lines outside the replaced range keep their hashes unconditionally, even when their content is byte-identical to lines inside the range. Previously, identical text in a replacement could "steal" the nearest sibling line's hash, silently relocating an anchor the model was still holding.
102
+ - **Re-inserted identical text keeps its hash.** When replacement content matches a line that was just removed, the removed line's hash is reused for it (same canonical content, same meaning). Previously this was a coin flip: the hash was retired and a fresh one assigned, so "replace X with X" rotated the anchor even though nothing changed.
103
+
99
104
  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
105
 
101
106
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.19.0",
3
+ "version": "0.19.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",
@@ -180,15 +180,15 @@ function hashToIndex(hash: string): number {
180
180
  return idx;
181
181
  }
182
182
 
183
- function findNearestCandidate(
184
- candidates: { index: number; hash: string }[],
183
+ function nearestNew(
184
+ candidates: number[],
185
185
  target: number,
186
186
  ): number {
187
187
  let lo = 0;
188
188
  let hi = candidates.length;
189
189
  while (lo < hi) {
190
190
  const mid = (lo + hi) >>> 1;
191
- if (candidates[mid]!.index < target) lo = mid + 1;
191
+ if (candidates[mid]! < target) lo = mid + 1;
192
192
  else hi = mid;
193
193
  }
194
194
  const left = lo - 1;
@@ -196,8 +196,7 @@ function findNearestCandidate(
196
196
  if (
197
197
  left >= 0 &&
198
198
  (right >= candidates.length ||
199
- target - candidates[left]!.index <=
200
- candidates[right]!.index - target)
199
+ target - candidates[left]! <= candidates[right]! - target)
201
200
  ) {
202
201
  return left;
203
202
  }
@@ -210,46 +209,78 @@ function mapStableHashes(
210
209
  newContent: string,
211
210
  removedHashes?: Set<string>,
212
211
  ): string[] {
212
+ const oldLines = splitLines(oldContent);
213
213
  const newLines = splitLines(newContent);
214
214
  const newHashes = new Array<string>(newLines.length);
215
215
  const used = new Uint32Array(BITSET_WORDS);
216
216
  const hint = { value: 0 };
217
+ const removed = removedHashes ?? new Set<string>();
217
218
 
218
- if (removedHashes) {
219
- for (const hash of removedHashes) {
220
- const idx = hashToIndex(hash);
221
- if (idx >= 0) setBit(used, idx);
222
- }
219
+ const oldHashIndex = new Map<string, number>();
220
+ for (let i = 0; i < oldHashes.length; i++) {
221
+ const hash = oldHashes[i]!;
222
+ oldHashIndex.set(hash, i);
223
+ const idx = hashToIndex(hash);
224
+ if (idx >= 0) setBit(used, idx);
223
225
  }
224
226
 
225
- const contentMap = new Map<string, { index: number; hash: string }[]>();
226
- const oldLines = splitLines(oldContent);
227
+ const removedIndexes = new Set<number>();
228
+ for (const hash of removed) {
229
+ const idx = oldHashIndex.get(hash);
230
+ if (idx !== undefined) removedIndexes.add(idx);
231
+ }
232
+
233
+ const survivors: { index: number; hash: string }[] = [];
234
+ const removedEntries: { index: number; hash: string }[] = [];
227
235
  for (let i = 0; i < oldLines.length; i++) {
228
- const line = oldLines[i]!;
229
- const hash = oldHashes[i]!;
230
- if (removedHashes?.has(hash)) continue;
231
- const entry = { index: i, hash };
232
- const list = contentMap.get(line);
233
- if (list) {
234
- list.push(entry);
235
- } else {
236
- contentMap.set(line, [entry]);
237
- }
236
+ const entry = { index: i, hash: oldHashes[i]! };
237
+ if (removedIndexes.has(i)) removedEntries.push(entry);
238
+ else survivors.push(entry);
238
239
  }
240
+
241
+ const newByContent = new Map<string, number[]>();
239
242
  for (let i = 0; i < newLines.length; i++) {
240
- const line = newLines[i]!;
241
- const candidates = contentMap.get(line);
243
+ const key = canon(newLines[i]!);
244
+ const list = newByContent.get(key);
245
+ if (list) list.push(i);
246
+ else newByContent.set(key, [i]);
247
+ }
248
+
249
+ const markUsed = (hash: string): void => {
250
+ const idx = hashToIndex(hash);
251
+ if (idx >= 0) {
252
+ setBit(used, idx);
253
+ if (idx + 1 > hint.value) hint.value = idx + 1;
254
+ }
255
+ };
256
+
257
+ for (const entry of survivors) {
258
+ const candidates = newByContent.get(canon(oldLines[entry.index]!));
242
259
  if (!candidates || candidates.length === 0) continue;
260
+ const pos = nearestNew(candidates, entry.index);
261
+ if (pos < 0) continue;
262
+ const newIdx = candidates.splice(pos, 1)[0]!;
263
+ newHashes[newIdx] = entry.hash;
264
+ markUsed(entry.hash);
265
+ }
243
266
 
244
- const bestIdx = findNearestCandidate(candidates, i);
245
- if (bestIdx < 0) continue;
246
- const match = candidates.splice(bestIdx, 1)[0]!;
247
- newHashes[i] = match.hash;
248
- const matchIdx = hashToIndex(match.hash);
249
- if (matchIdx >= 0) {
250
- setBit(used, matchIdx);
251
- if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
267
+ const removedByContent = new Map<string, { hashes: string[]; pos: number }>();
268
+ for (const entry of removedEntries) {
269
+ const key = canon(oldLines[entry.index]!);
270
+ let queue = removedByContent.get(key);
271
+ if (!queue) {
272
+ queue = { hashes: [], pos: 0 };
273
+ removedByContent.set(key, queue);
252
274
  }
275
+ queue.hashes.push(entry.hash);
276
+ }
277
+
278
+ for (let i = 0; i < newLines.length; i++) {
279
+ if (newHashes[i]) continue;
280
+ const queue = removedByContent.get(canon(newLines[i]!));
281
+ if (!queue || queue.pos >= queue.hashes.length) continue;
282
+ newHashes[i] = queue.hashes[queue.pos]!;
283
+ queue.pos += 1;
253
284
  }
254
285
 
255
286
  for (let i = 0; i < newLines.length; i++) {
@@ -258,6 +289,7 @@ function mapStableHashes(
258
289
  const baseIdx = xxh32(c) >>> 14;
259
290
  newHashes[i] = assignHash(used, baseIdx, hint);
260
291
  }
292
+
261
293
  return newHashes;
262
294
  }
263
295