pi-hashline-edit-pro 0.19.0 → 0.19.2
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/index.ts +5 -5
- package/package.json +3 -1
- package/prompts/replace-guidelines.md +2 -1
- package/src/hash-store.ts +14 -3
- package/src/hashline/apply.ts +0 -38
- package/src/hashline/hash.ts +64 -35
- package/src/hashline/index.ts +0 -2
- package/src/hashline/parse.ts +2 -1
- package/src/hashline/resolve.ts +3 -3
- package/src/replace-response.ts +2 -2
- package/src/utils.ts +5 -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.
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { initHasher } from "./src/hashline";
|
|
|
3
3
|
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
4
4
|
import { regReplaceUndo, clearUndo } from "./src/replace-undo";
|
|
5
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
6
|
-
import {
|
|
6
|
+
import type { RMetrics } from "./src/replace-response";
|
|
7
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
8
8
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
9
9
|
import {
|
|
@@ -95,17 +95,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
95
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
96
96
|
if (typeof filePath !== "string") return;
|
|
97
97
|
|
|
98
|
+
const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
|
|
99
|
+
if (event.toolName !== "write" && metrics?.classification === "noop") return;
|
|
100
|
+
|
|
98
101
|
try {
|
|
99
102
|
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
100
103
|
filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
|
|
101
104
|
);
|
|
102
|
-
if (visLines(normalized).length === 0) return;
|
|
103
105
|
|
|
104
106
|
const changedLines =
|
|
105
107
|
event.toolName === "replace" || event.toolName === "undo_last_replace"
|
|
106
|
-
?
|
|
107
|
-
| { metrics?: { changed_lines?: { first: number; last: number } } }
|
|
108
|
-
| undefined)?.metrics?.changed_lines
|
|
108
|
+
? metrics?.changed_lines
|
|
109
109
|
: undefined;
|
|
110
110
|
let offset: number | undefined;
|
|
111
111
|
let limit = AUTO_READ_MAX;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:watch": "vitest",
|
|
49
|
+
"test:coverage": "vitest run --coverage",
|
|
49
50
|
"lint": "eslint 'src/**/*.ts' 'index.ts' 'test/**/*.ts'",
|
|
50
51
|
"typecheck": "tsc --noEmit"
|
|
51
52
|
},
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
54
55
|
"@eslint/js": "^10.0.1",
|
|
55
56
|
"@types/node": "^24.0.0",
|
|
57
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
56
58
|
"eslint": "^10.7.0",
|
|
57
59
|
"typescript": "^5.8.0",
|
|
58
60
|
"typescript-eslint": "^8.65.0",
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
2
|
-
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
2
|
+
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
3
|
+
- `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
|
package/src/hash-store.ts
CHANGED
|
@@ -53,7 +53,20 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
|
53
53
|
"updated_at INTEGER NOT NULL" +
|
|
54
54
|
")"
|
|
55
55
|
);
|
|
56
|
-
|
|
56
|
+
db.exec(
|
|
57
|
+
"CREATE TABLE IF NOT EXISTS meta (" +
|
|
58
|
+
"key TEXT PRIMARY KEY, " +
|
|
59
|
+
"value TEXT NOT NULL" +
|
|
60
|
+
")"
|
|
61
|
+
);
|
|
62
|
+
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
63
|
+
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
64
|
+
db.exec("DELETE FROM snapshots");
|
|
65
|
+
}
|
|
66
|
+
db.prepare(
|
|
67
|
+
"INSERT INTO meta (key, value) VALUES ('version', ?) " +
|
|
68
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
69
|
+
).run(String(HASH_STORE_VERSION));
|
|
57
70
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
58
71
|
const allStmt = db.prepare("SELECT path FROM snapshots");
|
|
59
72
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
@@ -284,5 +297,3 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
284
297
|
for (const path of missing) store.stmts.deleteOne(path);
|
|
285
298
|
});
|
|
286
299
|
}
|
|
287
|
-
|
|
288
|
-
export { HASH_STORE_VERSION };
|
package/src/hashline/apply.ts
CHANGED
|
@@ -219,44 +219,6 @@ function assemble(
|
|
|
219
219
|
return result;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
export function fmtBoundaryWarning(params: {
|
|
223
|
-
kind: "trailing" | "leading";
|
|
224
|
-
survivingContent: string;
|
|
225
|
-
matchIndex: number;
|
|
226
|
-
resultLines: string[];
|
|
227
|
-
resultHashes: string[];
|
|
228
|
-
}): string {
|
|
229
|
-
const header =
|
|
230
|
-
params.kind === "trailing"
|
|
231
|
-
? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
|
|
232
|
-
: "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
|
|
233
|
-
|
|
234
|
-
let pairStart = -1;
|
|
235
|
-
let bestDist = Infinity;
|
|
236
|
-
for (let i = 0; i < params.resultLines.length - 1; i++) {
|
|
237
|
-
if (
|
|
238
|
-
params.resultLines[i] === params.survivingContent &&
|
|
239
|
-
params.resultLines[i + 1] === params.survivingContent
|
|
240
|
-
) {
|
|
241
|
-
const dist = Math.abs(i - params.matchIndex);
|
|
242
|
-
if (dist < bestDist) {
|
|
243
|
-
bestDist = dist;
|
|
244
|
-
pairStart = i;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
if (pairStart < 0) pairStart = params.matchIndex;
|
|
249
|
-
|
|
250
|
-
const winStart = Math.max(0, pairStart - 2);
|
|
251
|
-
const winEnd = Math.min(params.resultLines.length - 1, pairStart + 3);
|
|
252
|
-
|
|
253
|
-
const rows: string[] = [];
|
|
254
|
-
for (let i = winStart; i <= winEnd; i++) {
|
|
255
|
-
rows.push(`${params.resultHashes[i]}${HASH_SEP}${params.resultLines[i]}`);
|
|
256
|
-
}
|
|
257
|
-
return `${header}\n\n${rows.join("\n")}`;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
222
|
export function applyEdits(
|
|
261
223
|
content: string,
|
|
262
224
|
edits: HEdit[],
|
package/src/hashline/hash.ts
CHANGED
|
@@ -37,9 +37,6 @@ const HASH_TABLE: string[] = Array.from(
|
|
|
37
37
|
(_, i) => idxToHash(i),
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
-
export const HL_PREFIX_RE = new RegExp(
|
|
41
|
-
`^\\s*(?:>>>|>>)?\\s*${HASH_CLASS}│`,
|
|
42
|
-
);
|
|
43
40
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
44
41
|
`^\\+\\s*${HASH_CLASS}│`,
|
|
45
42
|
);
|
|
@@ -180,15 +177,15 @@ function hashToIndex(hash: string): number {
|
|
|
180
177
|
return idx;
|
|
181
178
|
}
|
|
182
179
|
|
|
183
|
-
function
|
|
184
|
-
candidates:
|
|
180
|
+
function nearestNew(
|
|
181
|
+
candidates: number[],
|
|
185
182
|
target: number,
|
|
186
183
|
): number {
|
|
187
184
|
let lo = 0;
|
|
188
185
|
let hi = candidates.length;
|
|
189
186
|
while (lo < hi) {
|
|
190
187
|
const mid = (lo + hi) >>> 1;
|
|
191
|
-
if (candidates[mid]
|
|
188
|
+
if (candidates[mid]! < target) lo = mid + 1;
|
|
192
189
|
else hi = mid;
|
|
193
190
|
}
|
|
194
191
|
const left = lo - 1;
|
|
@@ -196,8 +193,7 @@ function findNearestCandidate(
|
|
|
196
193
|
if (
|
|
197
194
|
left >= 0 &&
|
|
198
195
|
(right >= candidates.length ||
|
|
199
|
-
target - candidates[left]
|
|
200
|
-
candidates[right]!.index - target)
|
|
196
|
+
target - candidates[left]! <= candidates[right]! - target)
|
|
201
197
|
) {
|
|
202
198
|
return left;
|
|
203
199
|
}
|
|
@@ -210,46 +206,78 @@ function mapStableHashes(
|
|
|
210
206
|
newContent: string,
|
|
211
207
|
removedHashes?: Set<string>,
|
|
212
208
|
): string[] {
|
|
209
|
+
const oldLines = splitLines(oldContent);
|
|
213
210
|
const newLines = splitLines(newContent);
|
|
214
211
|
const newHashes = new Array<string>(newLines.length);
|
|
215
212
|
const used = new Uint32Array(BITSET_WORDS);
|
|
216
213
|
const hint = { value: 0 };
|
|
214
|
+
const removed = removedHashes ?? new Set<string>();
|
|
217
215
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
const oldHashIndex = new Map<string, number>();
|
|
217
|
+
for (let i = 0; i < oldHashes.length; i++) {
|
|
218
|
+
const hash = oldHashes[i]!;
|
|
219
|
+
oldHashIndex.set(hash, i);
|
|
220
|
+
const idx = hashToIndex(hash);
|
|
221
|
+
if (idx >= 0) setBit(used, idx);
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
const
|
|
226
|
-
const
|
|
224
|
+
const removedIndexes = new Set<number>();
|
|
225
|
+
for (const hash of removed) {
|
|
226
|
+
const idx = oldHashIndex.get(hash);
|
|
227
|
+
if (idx !== undefined) removedIndexes.add(idx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const survivors: { index: number; hash: string }[] = [];
|
|
231
|
+
const removedEntries: { index: number; hash: string }[] = [];
|
|
227
232
|
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
|
-
}
|
|
233
|
+
const entry = { index: i, hash: oldHashes[i]! };
|
|
234
|
+
if (removedIndexes.has(i)) removedEntries.push(entry);
|
|
235
|
+
else survivors.push(entry);
|
|
238
236
|
}
|
|
237
|
+
|
|
238
|
+
const newByContent = new Map<string, number[]>();
|
|
239
239
|
for (let i = 0; i < newLines.length; i++) {
|
|
240
|
-
const
|
|
241
|
-
const
|
|
240
|
+
const key = canon(newLines[i]!);
|
|
241
|
+
const list = newByContent.get(key);
|
|
242
|
+
if (list) list.push(i);
|
|
243
|
+
else newByContent.set(key, [i]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const markUsed = (hash: string): void => {
|
|
247
|
+
const idx = hashToIndex(hash);
|
|
248
|
+
if (idx >= 0) {
|
|
249
|
+
setBit(used, idx);
|
|
250
|
+
if (idx + 1 > hint.value) hint.value = idx + 1;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
for (const entry of survivors) {
|
|
255
|
+
const candidates = newByContent.get(canon(oldLines[entry.index]!));
|
|
242
256
|
if (!candidates || candidates.length === 0) continue;
|
|
257
|
+
const pos = nearestNew(candidates, entry.index);
|
|
258
|
+
if (pos < 0) continue;
|
|
259
|
+
const newIdx = candidates.splice(pos, 1)[0]!;
|
|
260
|
+
newHashes[newIdx] = entry.hash;
|
|
261
|
+
markUsed(entry.hash);
|
|
262
|
+
}
|
|
243
263
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
|
|
264
|
+
const removedByContent = new Map<string, { hashes: string[]; pos: number }>();
|
|
265
|
+
for (const entry of removedEntries) {
|
|
266
|
+
const key = canon(oldLines[entry.index]!);
|
|
267
|
+
let queue = removedByContent.get(key);
|
|
268
|
+
if (!queue) {
|
|
269
|
+
queue = { hashes: [], pos: 0 };
|
|
270
|
+
removedByContent.set(key, queue);
|
|
252
271
|
}
|
|
272
|
+
queue.hashes.push(entry.hash);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
276
|
+
if (newHashes[i]) continue;
|
|
277
|
+
const queue = removedByContent.get(canon(newLines[i]!));
|
|
278
|
+
if (!queue || queue.pos >= queue.hashes.length) continue;
|
|
279
|
+
newHashes[i] = queue.hashes[queue.pos]!;
|
|
280
|
+
queue.pos += 1;
|
|
253
281
|
}
|
|
254
282
|
|
|
255
283
|
for (let i = 0; i < newLines.length; i++) {
|
|
@@ -258,6 +286,7 @@ function mapStableHashes(
|
|
|
258
286
|
const baseIdx = xxh32(c) >>> 14;
|
|
259
287
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
260
288
|
}
|
|
289
|
+
|
|
261
290
|
return newHashes;
|
|
262
291
|
}
|
|
263
292
|
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DIFF_MINUS_RE,
|
|
7
7
|
} from "./hash";
|
|
8
8
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
9
|
+
import { clipLine } from "../utils";
|
|
9
10
|
|
|
10
11
|
export type Anchor = { hash: string };
|
|
11
12
|
|
|
@@ -51,7 +52,7 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
51
52
|
DIFF_MINUS_RE.test(line)
|
|
52
53
|
) {
|
|
53
54
|
throw new Error(
|
|
54
|
-
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
|
|
55
|
+
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(clipLine(line))}. Use literal file content only — plain + or - lines are written literally.`
|
|
55
56
|
);
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty } from "../utils";
|
|
1
|
+
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
|
|
2
2
|
import { HL_BARE_PREFIX_RE } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
@@ -109,7 +109,7 @@ export function fmtMismatch(
|
|
|
109
109
|
: "";
|
|
110
110
|
const lines = sample
|
|
111
111
|
.map((line) => {
|
|
112
|
-
const content = fileLines[line - 1] ?? "";
|
|
112
|
+
const content = clipLine(fileLines[line - 1] ?? "");
|
|
113
113
|
return ` ${line}: ${fileHashes[line - 1]}│${content}`;
|
|
114
114
|
})
|
|
115
115
|
.join("\n");
|
|
@@ -223,7 +223,7 @@ export function assertNoBarePrefix(
|
|
|
223
223
|
const matched = suspects.filter((s) => fileHashSet.has(s.hash));
|
|
224
224
|
const matchedCount = matched.length;
|
|
225
225
|
|
|
226
|
-
const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
|
|
226
|
+
const exampleLine = `${suspects[0]!.hash}│${clipLine(suspects[0]!.line)}`;
|
|
227
227
|
|
|
228
228
|
const linesHint =
|
|
229
229
|
matchedCount === 0
|
package/src/replace-response.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReplaceDetails } from "./replace";
|
|
2
2
|
import { genDiff } from "./replace-diff";
|
|
3
|
-
import { visLines } from "./utils";
|
|
3
|
+
import { visLines, clipLine } from "./utils";
|
|
4
4
|
|
|
5
5
|
type TResult = {
|
|
6
6
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -102,7 +102,7 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
102
102
|
? noopEdits
|
|
103
103
|
.map(
|
|
104
104
|
(edit) =>
|
|
105
|
-
`Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${edit.currentContent}`,
|
|
105
|
+
`Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${clipLine(edit.currentContent)}`,
|
|
106
106
|
)
|
|
107
107
|
.join("\n")
|
|
108
108
|
: "The edits produced identical content.";
|
package/src/utils.ts
CHANGED
|
@@ -82,3 +82,8 @@ export function firstNonEmpty(lines: string[]): string | undefined {
|
|
|
82
82
|
const idx = firstNonEmptyIndex(lines);
|
|
83
83
|
return idx >= 0 ? lines[idx] : undefined;
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
export function clipLine(line: string, maxLen = 200): string {
|
|
87
|
+
const flat = line.replace(/\n/g, "\\n");
|
|
88
|
+
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|
|
89
|
+
}
|