pi-hashline-edit-pro 0.18.4 → 0.19.0
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 +15 -3
- package/index.ts +24 -3
- package/package.json +2 -2
- package/src/config.ts +9 -1
- package/src/hash-store.ts +28 -14
- package/src/hashline/apply.ts +21 -40
- package/src/hashline/hash.ts +14 -30
- package/src/replace-undo.ts +4 -0
package/README.md
CHANGED
|
@@ -103,16 +103,28 @@ On first run after upgrading, a one-time migration imports the previous `hash-st
|
|
|
103
103
|
### Chained edits
|
|
104
104
|
|
|
105
105
|
After a successful replace, the response confirms with `Successfully replaced in {path}. Added X line(s), removed Y line(s).` (warnings are still shown if present). When auto-read is enabled, fresh anchors are appended automatically. Otherwise call `read` to get fresh anchors for follow-up edits.
|
|
106
|
-
### Auto-read after write and
|
|
106
|
+
### Auto-read after write, replace, and undo
|
|
107
107
|
|
|
108
|
-
Auto-read is **disabled by default**. When enabled, after a successful `write` or `
|
|
108
|
+
Auto-read is **disabled by default**. When enabled, after a successful `write`, `replace`, or `undo_last_replace` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the file without requiring a separate `read` call. The workflow becomes:
|
|
109
109
|
|
|
110
110
|
1. `write` a file, result includes hashline anchors
|
|
111
111
|
2. `replace` using those anchors directly
|
|
112
112
|
|
|
113
113
|
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
114
|
|
|
115
|
-
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
### Undo
|
|
120
|
+
|
|
121
|
+
`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:
|
|
122
|
+
|
|
123
|
+
- 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.
|
|
124
|
+
- Undo history is in-memory and is lost when the session ends or the extension reloads.
|
|
125
|
+
- 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.
|
|
126
|
+
- After an undo, the hash-store snapshot is restored to match the reverted content, so anchors read from the previous state are valid again.
|
|
127
|
+
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
116
128
|
|
|
117
129
|
### Diff for the host
|
|
118
130
|
|
package/index.ts
CHANGED
|
@@ -87,8 +87,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
if (!autoRead) return;
|
|
90
|
-
if (
|
|
91
|
-
|
|
90
|
+
if (
|
|
91
|
+
event.toolName !== "write" &&
|
|
92
|
+
event.toolName !== "replace" &&
|
|
93
|
+
event.toolName !== "undo_last_replace"
|
|
94
|
+
) return;
|
|
92
95
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
93
96
|
if (typeof filePath !== "string") return;
|
|
94
97
|
|
|
@@ -98,7 +101,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
98
101
|
);
|
|
99
102
|
if (visLines(normalized).length === 0) return;
|
|
100
103
|
|
|
101
|
-
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
|
+
);
|
|
102
123
|
|
|
103
124
|
return {
|
|
104
125
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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,7 +46,7 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:watch": "vitest",
|
|
49
|
-
"lint": "eslint 'src/**/*.ts' 'index.ts'",
|
|
49
|
+
"lint": "eslint 'src/**/*.ts' 'index.ts' 'test/**/*.ts'",
|
|
50
50
|
"typecheck": "tsc --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,14 @@ function parseConfig(content: string): Config {
|
|
|
21
21
|
autoRead: parsed.autoRead === true,
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
function envDefaultConfig(): Partial<Config> {
|
|
26
|
+
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
27
|
+
return autoReadValue === "1" || autoReadValue === "true"
|
|
28
|
+
? { autoRead: true }
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
export async function readConfig(): Promise<Config> {
|
|
25
33
|
try {
|
|
26
34
|
const content = await readFile(configPath(), "utf-8");
|
|
@@ -29,7 +37,7 @@ export async function readConfig(): Promise<Config> {
|
|
|
29
37
|
if (errCode(error) !== "ENOENT") {
|
|
30
38
|
console.error("Config file corrupted, using defaults:", error);
|
|
31
39
|
}
|
|
32
|
-
return { ...DEFAULT_CONFIG };
|
|
40
|
+
return { ...DEFAULT_CONFIG, ...envDefaultConfig() };
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
export async function writeConfig(config: Config): Promise<void> {
|
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
|
@@ -183,7 +183,6 @@ function hashToIndex(hash: string): number {
|
|
|
183
183
|
function findNearestCandidate(
|
|
184
184
|
candidates: { index: number; hash: string }[],
|
|
185
185
|
target: number,
|
|
186
|
-
removedHashes?: Set<string>,
|
|
187
186
|
): number {
|
|
188
187
|
let lo = 0;
|
|
189
188
|
let hi = candidates.length;
|
|
@@ -192,33 +191,17 @@ function findNearestCandidate(
|
|
|
192
191
|
if (candidates[mid]!.index < target) lo = mid + 1;
|
|
193
192
|
else hi = mid;
|
|
194
193
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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]!.index <=
|
|
200
|
+
candidates[right]!.index - target)
|
|
201
|
+
) {
|
|
202
|
+
return left;
|
|
220
203
|
}
|
|
221
|
-
return -1;
|
|
204
|
+
return right < candidates.length ? right : -1;
|
|
222
205
|
}
|
|
223
206
|
|
|
224
207
|
function mapStableHashes(
|
|
@@ -243,7 +226,9 @@ function mapStableHashes(
|
|
|
243
226
|
const oldLines = splitLines(oldContent);
|
|
244
227
|
for (let i = 0; i < oldLines.length; i++) {
|
|
245
228
|
const line = oldLines[i]!;
|
|
246
|
-
const
|
|
229
|
+
const hash = oldHashes[i]!;
|
|
230
|
+
if (removedHashes?.has(hash)) continue;
|
|
231
|
+
const entry = { index: i, hash };
|
|
247
232
|
const list = contentMap.get(line);
|
|
248
233
|
if (list) {
|
|
249
234
|
list.push(entry);
|
|
@@ -251,13 +236,12 @@ function mapStableHashes(
|
|
|
251
236
|
contentMap.set(line, [entry]);
|
|
252
237
|
}
|
|
253
238
|
}
|
|
254
|
-
|
|
255
239
|
for (let i = 0; i < newLines.length; i++) {
|
|
256
240
|
const line = newLines[i]!;
|
|
257
241
|
const candidates = contentMap.get(line);
|
|
258
242
|
if (!candidates || candidates.length === 0) continue;
|
|
259
243
|
|
|
260
|
-
const bestIdx = findNearestCandidate(candidates, i
|
|
244
|
+
const bestIdx = findNearestCandidate(candidates, i);
|
|
261
245
|
if (bestIdx < 0) continue;
|
|
262
246
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
263
247
|
newHashes[i] = match.hash;
|
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
|
}),
|