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 +5 -0
- package/package.json +1 -1
- package/src/hashline/hash.ts +64 -32
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
package/src/hashline/hash.ts
CHANGED
|
@@ -180,15 +180,15 @@ function hashToIndex(hash: string): number {
|
|
|
180
180
|
return idx;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
function
|
|
184
|
-
candidates:
|
|
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]
|
|
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]
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
226
|
-
const
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
241
|
-
const
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|