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 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 replace
106
+ ### Auto-read after write, replace, and undo
107
107
 
108
- Auto-read is **disabled by default**. When enabled, after a successful `write` or `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:
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
- For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
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 (event.toolName !== "write" && event.toolName !== "replace") return;
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 preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
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.18.4",
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
- export async function loadHashStore(): Promise<HashStore> {
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
- return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
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
- const hashesJson = JSON.stringify(hashes);
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
- withStore(() => {
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> {
@@ -389,52 +389,33 @@ export function changedRange(
389
389
  };
390
390
  }
391
391
 
392
- if (result.startsWith(original) && original.endsWith("\n")) {
393
- return {
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
- let firstDiff = 0;
400
- const minLen = Math.min(original.length, result.length);
401
- while (firstDiff < minLen && original[firstDiff] === result[firstDiff]) {
402
- firstDiff++;
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
- let lastOrig = original.length - 1;
407
- let lastRes = result.length - 1;
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 >= firstDiff &&
410
- lastRes >= firstDiff &&
411
- original[lastOrig] === result[lastRes]
410
+ lastOrig >= first &&
411
+ lastRes >= first &&
412
+ originalLines[lastOrig] === resultLines[lastRes]
412
413
  ) {
413
414
  lastOrig--;
414
415
  lastRes--;
415
416
  }
416
-
417
- function idxToLine(charIdx: number, text: string): number {
418
- let line = 1;
419
- for (let i = 0; i < charIdx && i < text.length; i++) {
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
  }
@@ -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
- let left = lo - 1;
196
- let right = lo;
197
- while (left >= 0 || right < candidates.length) {
198
- let bestPos = -1;
199
- let bestDist = Infinity;
200
- if (left >= 0) {
201
- const candidate = candidates[left]!;
202
- if (!removedHashes?.has(candidate.hash)) {
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]!.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 entry = { index: i, hash: oldHashes[i]! };
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, removedHashes);
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;
@@ -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
  }),