pi-hashline-edit-pro 0.18.5 → 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 +18 -1
- package/index.ts +19 -1
- package/package.json +1 -1
- package/src/hash-store.ts +28 -14
- package/src/hashline/apply.ts +21 -40
- package/src/hashline/hash.ts +70 -54
- package/src/replace-undo.ts +4 -0
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.
|
|
@@ -112,7 +117,19 @@ Auto-read is **disabled by default**. When enabled, after a successful `write`,
|
|
|
112
117
|
|
|
113
118
|
Toggle at runtime with the `/toggle-auto-read` command. The setting persists across sessions in the config file (`~/.config/pi-hashline-edit-pro/config.json`). Set `PI_HASHLINE_AUTO_READ=1` to enable by default on first run.
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
After a `replace` or `undo_last_replace`, the block is limited to the changed span plus 2 lines of context above and below it. Because the persistent hash store keeps anchors for unchanged lines stable across edits, the model's previously read anchors for the rest of the file remain valid — only the edited region needs fresh anchors. `write` dumps from the top of the file, since the model has no prior anchors for that state.
|
|
121
|
+
|
|
122
|
+
For `write` on files over 2000 lines, the dump from the top is truncated with a pagination hint — use `read` with `offset` to see more. `replace` and `undo_last_replace` windows are small by construction (the changed span plus context), so they are not truncated except when the changed span itself exceeds 2000 lines.
|
|
123
|
+
|
|
124
|
+
### Undo
|
|
125
|
+
|
|
126
|
+
`undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content (BOM and line endings included) and the previous hash anchors. It is meant for immediate recovery from a bad edit:
|
|
127
|
+
|
|
128
|
+
- Undo history is per-file and single-level: each successful `replace` replaces the previous undo entry, so only the most recent edit can be reverted.
|
|
129
|
+
- Undo history is in-memory and is lost when the session ends or the extension reloads.
|
|
130
|
+
- A successful `write` clears the undo history for that file — the write becomes the new source of truth, and reverting past it would be ambiguous.
|
|
131
|
+
- After an undo, the hash-store snapshot is restored to match the reverted content, so anchors read from the previous state are valid again.
|
|
132
|
+
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
116
133
|
|
|
117
134
|
### Diff for the host
|
|
118
135
|
|
package/index.ts
CHANGED
|
@@ -101,7 +101,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
101
101
|
);
|
|
102
102
|
if (visLines(normalized).length === 0) return;
|
|
103
103
|
|
|
104
|
-
const
|
|
104
|
+
const changedLines =
|
|
105
|
+
event.toolName === "replace" || event.toolName === "undo_last_replace"
|
|
106
|
+
? (event.details as
|
|
107
|
+
| { metrics?: { changed_lines?: { first: number; last: number } } }
|
|
108
|
+
| undefined)?.metrics?.changed_lines
|
|
109
|
+
: undefined;
|
|
110
|
+
let offset: number | undefined;
|
|
111
|
+
let limit = AUTO_READ_MAX;
|
|
112
|
+
if (changedLines) {
|
|
113
|
+
offset = Math.max(1, changedLines.first - 2);
|
|
114
|
+
limit = Math.min(changedLines.last + 2 - offset + 1, AUTO_READ_MAX);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const preview = await fmtReadPreview(
|
|
118
|
+
normalized,
|
|
119
|
+
{ offset, limit },
|
|
120
|
+
fileHashes,
|
|
121
|
+
absolutePath,
|
|
122
|
+
);
|
|
105
123
|
|
|
106
124
|
return {
|
|
107
125
|
content: [
|
package/package.json
CHANGED
package/src/hash-store.ts
CHANGED
|
@@ -36,6 +36,7 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
|
|
39
|
+
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
39
40
|
let exitHandlerRegistered = false;
|
|
40
41
|
function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
41
42
|
const db = new DatabaseSync(storePath, {
|
|
@@ -101,12 +102,7 @@ function shutdownDb(db: DatabaseSync): void {
|
|
|
101
102
|
db.close();
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
const storePath = hashStorePath();
|
|
106
|
-
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
107
|
-
return { stmts: cachedDb.stmts, engine: "node:sqlite" };
|
|
108
|
-
}
|
|
109
|
-
|
|
105
|
+
async function openStore(storePath: string): Promise<HashStore> {
|
|
110
106
|
shutdownHashStore();
|
|
111
107
|
|
|
112
108
|
await initHasher();
|
|
@@ -149,6 +145,21 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
149
145
|
return { stmts, engine: "node:sqlite" };
|
|
150
146
|
}
|
|
151
147
|
|
|
148
|
+
export function loadHashStore(): Promise<HashStore> {
|
|
149
|
+
const storePath = hashStorePath();
|
|
150
|
+
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
151
|
+
return Promise.resolve({ stmts: cachedDb.stmts, engine: "node:sqlite" });
|
|
152
|
+
}
|
|
153
|
+
if (opening && opening.path === storePath) {
|
|
154
|
+
return opening.promise;
|
|
155
|
+
}
|
|
156
|
+
const promise = openStore(storePath).finally(() => {
|
|
157
|
+
if (opening?.path === storePath) opening = null;
|
|
158
|
+
});
|
|
159
|
+
opening = { path: storePath, promise };
|
|
160
|
+
return promise;
|
|
161
|
+
}
|
|
162
|
+
|
|
152
163
|
export function shutdownHashStore(): void {
|
|
153
164
|
if (cachedDb) {
|
|
154
165
|
shutdownDb(cachedDb.db);
|
|
@@ -233,7 +244,15 @@ export function getSnapshot(
|
|
|
233
244
|
const checksum = contentChecksum(content);
|
|
234
245
|
const lineCount = splitLines(content).length;
|
|
235
246
|
const row = store.stmts.get(path, checksum, lineCount);
|
|
236
|
-
|
|
247
|
+
if (!row) return undefined;
|
|
248
|
+
try {
|
|
249
|
+
const parsed = JSON.parse(row.hashes as string);
|
|
250
|
+
return Array.isArray(parsed) && parsed.every((h) => typeof h === "string")
|
|
251
|
+
? (parsed as string[])
|
|
252
|
+
: undefined;
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
237
256
|
}
|
|
238
257
|
|
|
239
258
|
export function upsertSnapshot(
|
|
@@ -243,16 +262,11 @@ export function upsertSnapshot(
|
|
|
243
262
|
lineCount: number,
|
|
244
263
|
hashes: string[],
|
|
245
264
|
): void {
|
|
246
|
-
|
|
247
|
-
withStore(() => {
|
|
248
|
-
store.stmts.upsert(path, checksum, lineCount, hashesJson, Date.now());
|
|
249
|
-
});
|
|
265
|
+
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
export function deleteSnapshot(store: HashStore, path: string): void {
|
|
253
|
-
|
|
254
|
-
store.stmts.deleteOne(path);
|
|
255
|
-
});
|
|
269
|
+
store.stmts.deleteOne(path);
|
|
256
270
|
}
|
|
257
271
|
|
|
258
272
|
export async function pruneMissing(store: HashStore): Promise<void> {
|
package/src/hashline/apply.ts
CHANGED
|
@@ -389,52 +389,33 @@ export function changedRange(
|
|
|
389
389
|
};
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
firstChangedLine: splitLines(original).length + 1,
|
|
395
|
-
lastChangedLine: splitLines(result).length,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
392
|
+
const originalLines = splitLines(original);
|
|
393
|
+
const resultLines = splitLines(result);
|
|
398
394
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
395
|
+
if (
|
|
396
|
+
originalLines.length === resultLines.length &&
|
|
397
|
+
originalLines.every((line, index) => line === resultLines[index])
|
|
398
|
+
) {
|
|
399
|
+
return null;
|
|
403
400
|
}
|
|
404
|
-
if (firstDiff === minLen && original.length === result.length) return null;
|
|
405
401
|
|
|
406
|
-
|
|
407
|
-
let
|
|
402
|
+
const minLen = Math.min(originalLines.length, resultLines.length);
|
|
403
|
+
let first = 0;
|
|
404
|
+
while (first < minLen && originalLines[first] === resultLines[first]) {
|
|
405
|
+
first++;
|
|
406
|
+
}
|
|
407
|
+
let lastOrig = originalLines.length - 1;
|
|
408
|
+
let lastRes = resultLines.length - 1;
|
|
408
409
|
while (
|
|
409
|
-
lastOrig >=
|
|
410
|
-
lastRes >=
|
|
411
|
-
|
|
410
|
+
lastOrig >= first &&
|
|
411
|
+
lastRes >= first &&
|
|
412
|
+
originalLines[lastOrig] === resultLines[lastRes]
|
|
412
413
|
) {
|
|
413
414
|
lastOrig--;
|
|
414
415
|
lastRes--;
|
|
415
416
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (text[i] === "\n") line++;
|
|
421
|
-
}
|
|
422
|
-
return line;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const firstChangedLine = idxToLine(firstDiff + 1, result);
|
|
426
|
-
let lastChangedLine: number;
|
|
427
|
-
if (lastRes < firstDiff) {
|
|
428
|
-
lastChangedLine = result.length === 0 ? 1 : splitLines(result).length;
|
|
429
|
-
} else if (
|
|
430
|
-
firstDiff === 0 &&
|
|
431
|
-
original.length > 0 &&
|
|
432
|
-
result.endsWith(original)
|
|
433
|
-
) {
|
|
434
|
-
lastChangedLine = firstChangedLine;
|
|
435
|
-
} else {
|
|
436
|
-
lastChangedLine = idxToLine(lastRes + 1, result);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return { firstChangedLine, lastChangedLine };
|
|
417
|
+
return {
|
|
418
|
+
firstChangedLine: first + 1,
|
|
419
|
+
lastChangedLine: Math.max(first, lastRes) + 1,
|
|
420
|
+
};
|
|
440
421
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -180,45 +180,27 @@ 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
|
-
removedHashes?: Set<string>,
|
|
187
186
|
): number {
|
|
188
187
|
let lo = 0;
|
|
189
188
|
let hi = candidates.length;
|
|
190
189
|
while (lo < hi) {
|
|
191
190
|
const mid = (lo + hi) >>> 1;
|
|
192
|
-
if (candidates[mid]
|
|
191
|
+
if (candidates[mid]! < target) lo = mid + 1;
|
|
193
192
|
else hi = mid;
|
|
194
193
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
bestPos = left;
|
|
204
|
-
bestDist = target - candidate.index;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
if (right < candidates.length) {
|
|
208
|
-
const candidate = candidates[right]!;
|
|
209
|
-
if (!removedHashes?.has(candidate.hash)) {
|
|
210
|
-
const dist = candidate.index - target;
|
|
211
|
-
if (dist < bestDist) {
|
|
212
|
-
bestPos = right;
|
|
213
|
-
bestDist = dist;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
if (bestPos >= 0) return bestPos;
|
|
218
|
-
left--;
|
|
219
|
-
right++;
|
|
194
|
+
const left = lo - 1;
|
|
195
|
+
const right = lo;
|
|
196
|
+
if (
|
|
197
|
+
left >= 0 &&
|
|
198
|
+
(right >= candidates.length ||
|
|
199
|
+
target - candidates[left]! <= candidates[right]! - target)
|
|
200
|
+
) {
|
|
201
|
+
return left;
|
|
220
202
|
}
|
|
221
|
-
return -1;
|
|
203
|
+
return right < candidates.length ? right : -1;
|
|
222
204
|
}
|
|
223
205
|
|
|
224
206
|
function mapStableHashes(
|
|
@@ -227,45 +209,78 @@ function mapStableHashes(
|
|
|
227
209
|
newContent: string,
|
|
228
210
|
removedHashes?: Set<string>,
|
|
229
211
|
): string[] {
|
|
212
|
+
const oldLines = splitLines(oldContent);
|
|
230
213
|
const newLines = splitLines(newContent);
|
|
231
214
|
const newHashes = new Array<string>(newLines.length);
|
|
232
215
|
const used = new Uint32Array(BITSET_WORDS);
|
|
233
216
|
const hint = { value: 0 };
|
|
217
|
+
const removed = removedHashes ?? new Set<string>();
|
|
218
|
+
|
|
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);
|
|
225
|
+
}
|
|
234
226
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
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);
|
|
240
231
|
}
|
|
241
232
|
|
|
242
|
-
const
|
|
243
|
-
const
|
|
233
|
+
const survivors: { index: number; hash: string }[] = [];
|
|
234
|
+
const removedEntries: { index: number; hash: string }[] = [];
|
|
244
235
|
for (let i = 0; i < oldLines.length; i++) {
|
|
245
|
-
const line = oldLines[i]!;
|
|
246
236
|
const entry = { index: i, hash: oldHashes[i]! };
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
list.push(entry);
|
|
250
|
-
} else {
|
|
251
|
-
contentMap.set(line, [entry]);
|
|
252
|
-
}
|
|
237
|
+
if (removedIndexes.has(i)) removedEntries.push(entry);
|
|
238
|
+
else survivors.push(entry);
|
|
253
239
|
}
|
|
254
240
|
|
|
241
|
+
const newByContent = new Map<string, number[]>();
|
|
255
242
|
for (let i = 0; i < newLines.length; i++) {
|
|
256
|
-
const
|
|
257
|
-
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]!));
|
|
258
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
|
+
}
|
|
259
266
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
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);
|
|
268
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;
|
|
269
284
|
}
|
|
270
285
|
|
|
271
286
|
for (let i = 0; i < newLines.length; i++) {
|
|
@@ -274,6 +289,7 @@ function mapStableHashes(
|
|
|
274
289
|
const baseIdx = xxh32(c) >>> 14;
|
|
275
290
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
276
291
|
}
|
|
292
|
+
|
|
277
293
|
return newHashes;
|
|
278
294
|
}
|
|
279
295
|
|
package/src/replace-undo.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
|
10
10
|
import { cntDiff, splitLines } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
13
|
+
import { changedRange } from "./hashline";
|
|
13
14
|
export interface UndoEntry {
|
|
14
15
|
content: string;
|
|
15
16
|
bom: string;
|
|
@@ -77,6 +78,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
77
78
|
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
|
78
79
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
79
80
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
81
|
+
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
80
82
|
|
|
81
83
|
await writeAtomic(
|
|
82
84
|
mutationTargetPath,
|
|
@@ -113,6 +115,8 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
113
115
|
editsAttempted: 1,
|
|
114
116
|
noopEditsCount: 0,
|
|
115
117
|
warningsCount: 0,
|
|
118
|
+
firstChangedLine: restoredRange?.firstChangedLine,
|
|
119
|
+
lastChangedLine: restoredRange?.lastChangedLine,
|
|
116
120
|
addedLines: linesRemovedByReplace,
|
|
117
121
|
removedLines: linesAddedByReplace,
|
|
118
122
|
}),
|