pi-hashline-edit-pro 2.4.1 → 2.5.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 CHANGED
@@ -63,8 +63,8 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
63
63
 
64
64
  Edge cases:
65
65
 
66
- - Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
67
- - Binary files and directories are rejected with a descriptive error.
66
+ - Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
67
+ - Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
68
68
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
69
69
  - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
70
  - BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
@@ -96,6 +96,7 @@ Notes:
96
96
  - The request is checked before any file I/O, so a bad request never touches the file.
97
97
  - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_text` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
98
98
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
99
+ - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
99
100
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
100
101
  - Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
101
102
 
@@ -113,7 +114,6 @@ Notes:
113
114
 
114
115
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
115
116
 
116
- - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
117
117
  - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
118
118
  - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
119
119
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
@@ -142,6 +142,8 @@ Unique anchors by construction. If a line's base hash collides with an already-a
142
142
 
143
143
  Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
144
144
 
145
+ The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows). `replace` verifies every line of the resolved range against that record before writing; a line whose hash was never served means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
146
+
145
147
  Two guarantees make this safe even with duplicated content:
146
148
 
147
149
  - 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.
@@ -166,11 +168,13 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
166
168
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
167
169
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
168
170
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
171
+ | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
169
172
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
170
173
 
171
174
  ## Troubleshooting
172
175
 
173
176
  - Stale anchors. `[E_STALE_ANCHOR]` or `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read. Call `read` for fresh anchors and retry.
177
+ - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
174
178
  - Reset the hash store. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
175
179
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
176
180
  - Config directory moved. On non-Windows platforms, if `XDG_CONFIG_HOME` is set, the config directory (and the hash store inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
package/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  toggleAutoRead,
14
14
  } from "./src/config";
15
15
  import { loadHashStore, pruneMissing } from "./src/hash-store";
16
+ import { recordServed, clearServed } from "./src/served";
16
17
  import { readNormFile } from "./src/file-reader";
17
18
  import { loadFileKindAndText } from "./src/file-kind";
18
19
  import { toCwd } from "./src/paths";
@@ -61,7 +62,10 @@ export default function (pi: ExtensionAPI): void {
61
62
  const writtenPath = (event.input as Record<string, unknown>)?.path;
62
63
  if (typeof writtenPath === "string") {
63
64
  try {
64
- await clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
65
+ const target = await resolveTarget(toCwd(writtenPath, ctx.cwd));
66
+ await clearUndo(target);
67
+ const store = await loadHashStore();
68
+ clearServed(store, target);
65
69
  } catch (error) {
66
70
  console.error("Failed to clear undo after write:", error);
67
71
  }
@@ -84,6 +88,12 @@ export default function (pi: ExtensionAPI): void {
84
88
  DEFAULT_MAX_BYTES,
85
89
  AUTO_READ_MAX,
86
90
  );
91
+ try {
92
+ const store = await loadHashStore();
93
+ recordServed(store, absolutePath, preview.servedHashes);
94
+ } catch (error) {
95
+ console.error("Failed to record served state from auto-read:", error);
96
+ }
87
97
  return {
88
98
  content: [
89
99
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.4.1",
3
+ "version": "2.5.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -5,4 +5,5 @@
5
5
  - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
6
  - `replace`: every `\n` in replacement_text separates lines, so a trailing `\n` adds a final empty line. Mirror the removed lines exactly: a range that ends on a blank line must end replacement_text with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end replacement_text with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line.
7
7
  - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
8
+ - `replace`: `[E_RANGE_STALE]` means a line inside the replaced range changed on disk after it was last shown (or was never shown). Nothing was modified; the error lists the current range as `HASH│content` rows, so retry with those anchors and no `read`.
8
9
  - `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
package/src/constants.ts CHANGED
@@ -2,6 +2,7 @@ export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
4
  export const MAX_READ_LINE_BYTES = 200 * 1024;
5
+ export const MAX_RANGE_STALE_LINES = 100;
5
6
 
6
7
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
7
8
  export const HASH_STORE_VERSION = 5;
package/src/file-kind.ts CHANGED
@@ -3,9 +3,10 @@ import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
 
5
5
  const IMG_TYPES = new Set<string>([
6
+ "image/bmp",
7
+ "image/gif",
6
8
  "image/jpeg",
7
9
  "image/png",
8
- "image/gif",
9
10
  "image/webp",
10
11
  ]);
11
12
 
@@ -39,6 +40,16 @@ function isTextType(mimeType: string): boolean {
39
40
  return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
40
41
  }
41
42
 
43
+ function looksLikeText(sample: Uint8Array): boolean {
44
+ if (sample.includes(0)) return false;
45
+ try {
46
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
42
53
  export type LFile =
43
54
  | { kind: "directory" }
44
55
  | { kind: "image"; mimeType: string }
@@ -96,7 +107,8 @@ export async function loadFileKindAndText(
96
107
  const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
97
108
  if (
98
109
  detectedMimeType !== undefined &&
99
- !isTextType(detectedMimeType)
110
+ !isTextType(detectedMimeType) &&
111
+ !looksLikeText(sample)
100
112
  ) {
101
113
  if (IMG_TYPES.has(detectedMimeType)) {
102
114
  return { kind: "image", mimeType: detectedMimeType };
package/src/hash-store.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
- import { errCode, splitLines } from "./utils";
5
+ import { errCode, isRec, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
7
  import { HASH_RE } from "./hashline/alphabet";
8
8
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
@@ -17,6 +17,9 @@ interface Prepared {
17
17
  undoUpsert: (...params: SqlParams) => void;
18
18
  undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
19
19
  undoDelete: (...params: SqlParams) => void;
20
+ servedGet: (...params: SqlParams) => Record<string, unknown> | undefined;
21
+ servedUpsert: (...params: SqlParams) => void;
22
+ servedDelete: (...params: SqlParams) => void;
20
23
  }
21
24
 
22
25
  export interface HashStore {
@@ -37,11 +40,12 @@ interface LegacySnapshot {
37
40
  hashes: string[];
38
41
  }
39
42
 
40
- function isValidHashList(value: unknown): value is string[] {
43
+ export function isValidHashList(value: unknown): value is string[] {
41
44
  if (!Array.isArray(value)) return false;
42
45
  for (const hash of value) {
43
46
  if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
44
47
  }
48
+ if (new Set(value).size !== value.length) return false;
45
49
  return true;
46
50
  }
47
51
 
@@ -104,6 +108,13 @@ function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prep
104
108
  let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
105
109
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
106
110
  let exitHandlerRegistered = false;
111
+ interface SnapshotCacheEntry {
112
+ checksum: string;
113
+ lineCount: number;
114
+ hashes: string[];
115
+ }
116
+ const snapshotCache = new Map<string, SnapshotCacheEntry>();
117
+ export const SNAPSHOT_CACHE_LIMIT = 256;
107
118
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
108
119
  const db = new DatabaseSync(storePath, {
109
120
  timeout: HASH_STORE_BUSY_TIMEOUT,
@@ -149,6 +160,13 @@ function buildStore(
149
160
  "updated_at INTEGER NOT NULL" +
150
161
  ")"
151
162
  );
163
+ db.exec(
164
+ "CREATE TABLE IF NOT EXISTS served (" +
165
+ "path TEXT PRIMARY KEY, " +
166
+ "hashes TEXT NOT NULL, " +
167
+ "updated_at INTEGER NOT NULL" +
168
+ ")"
169
+ );
152
170
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
153
171
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
154
172
  db.exec("DELETE FROM snapshots");
@@ -159,7 +177,7 @@ function buildStore(
159
177
  "ON CONFLICT(key) DO UPDATE SET value = excluded.value"
160
178
  ).run(String(HASH_STORE_VERSION));
161
179
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
162
- const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
180
+ const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
163
181
  const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
164
182
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
165
183
  const upsertStmt = db.prepare(
@@ -174,6 +192,12 @@ function buildStore(
174
192
  "SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
175
193
  );
176
194
  const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
195
+ const servedGetStmt = db.prepare("SELECT hashes FROM served WHERE path = ?");
196
+ const servedUpsertStmt = db.prepare(
197
+ "INSERT INTO served (path, hashes, updated_at) VALUES (?, ?, ?) " +
198
+ "ON CONFLICT(path) DO UPDATE SET hashes = excluded.hashes, updated_at = excluded.updated_at"
199
+ );
200
+ const servedDelStmt = db.prepare("DELETE FROM served WHERE path = ?");
177
201
  const stmts: Prepared = {
178
202
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
179
203
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
@@ -183,6 +207,9 @@ function buildStore(
183
207
  undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
184
208
  undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
185
209
  undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
210
+ servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
211
+ servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
212
+ servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
186
213
  };
187
214
  return { db, stmts };
188
215
  }
@@ -282,6 +309,7 @@ export function shutdownHashStore(): void {
282
309
  shutdownDb(cachedDb.db);
283
310
  cachedDb = null;
284
311
  }
312
+ snapshotCache.clear();
285
313
  }
286
314
 
287
315
  function withStore(fn: () => void): void {
@@ -325,11 +353,17 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
325
353
 
326
354
  const rows: [string, string, number, string, number][] = [];
327
355
  for (const [key, value] of Object.entries(raw)) {
328
- if (!isValidSnapshot(value)) continue;
329
- if (new Set(value.hashes).size !== value.hashes.length) {
330
- console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
356
+ if (
357
+ isRec(value) &&
358
+ Array.isArray(value.hashes) &&
359
+ new Set(value.hashes).size !== value.hashes.length
360
+ ) {
361
+ console.warn(
362
+ `Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`,
363
+ );
331
364
  continue;
332
365
  }
366
+ if (!isValidSnapshot(value)) continue;
333
367
  rows.push([
334
368
  key,
335
369
  contentChecksum(value.content),
@@ -359,6 +393,15 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
359
393
  }
360
394
  }
361
395
 
396
+ function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
397
+ snapshotCache.delete(path);
398
+ snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
399
+ if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
400
+ const oldest = snapshotCache.keys().next().value;
401
+ if (oldest !== undefined) snapshotCache.delete(oldest);
402
+ }
403
+ }
404
+
362
405
  export function getSnapshot(
363
406
  store: HashStore,
364
407
  path: string,
@@ -367,17 +410,29 @@ export function getSnapshot(
367
410
  ): string[] | undefined {
368
411
  const checksum = contentChecksum(content);
369
412
  const lineCount = splitLines(content).length;
413
+ const cached = snapshotCache.get(path);
414
+ if (cached && cached.checksum === checksum && cached.lineCount === lineCount) {
415
+ snapshotCache.delete(path);
416
+ snapshotCache.set(path, cached);
417
+ return cached.hashes.slice();
418
+ }
370
419
  const row = store.stmts.get(path, checksum, lineCount);
371
420
  if (!row) return undefined;
421
+ let parsed: unknown;
372
422
  try {
373
- const parsed = JSON.parse(row.hashes as string);
374
- if (isValidHashList(parsed)) return parsed;
375
- if (deleteCorrupt) store.stmts.deleteOne(path);
376
- return undefined;
423
+ parsed = JSON.parse(row.hashes as string);
377
424
  } catch {
378
425
  if (deleteCorrupt) store.stmts.deleteOne(path);
426
+ snapshotCache.delete(path);
379
427
  return undefined;
380
428
  }
429
+ if (isValidHashList(parsed)) {
430
+ cacheSnapshot(path, checksum, lineCount, parsed);
431
+ return parsed;
432
+ }
433
+ if (deleteCorrupt) store.stmts.deleteOne(path);
434
+ snapshotCache.delete(path);
435
+ return undefined;
381
436
  }
382
437
 
383
438
  export function upsertSnapshot(
@@ -388,6 +443,7 @@ export function upsertSnapshot(
388
443
  hashes: string[],
389
444
  ): void {
390
445
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
446
+ cacheSnapshot(path, checksum, lineCount, hashes);
391
447
  }
392
448
 
393
449
  export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
@@ -458,7 +514,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
458
514
  withStore(() => {
459
515
  for (const path of missing) {
460
516
  store.stmts.deleteOne(path);
517
+ snapshotCache.delete(path);
461
518
  store.stmts.undoDelete(path);
519
+ store.stmts.servedDelete(path);
462
520
  }
463
521
  });
464
522
  }
@@ -6,7 +6,9 @@ import {
6
6
  stripDiffPrefixes,
7
7
  swapReversedRanges,
8
8
  warnUnicodeEsc,
9
- fmtMismatch,
9
+ fmtMismatchWithHashes,
10
+ AnchorMismatchError,
11
+ assertRangeServed,
10
12
  type RHEdit,
11
13
  type NEdit,
12
14
  type HEdit,
@@ -144,6 +146,7 @@ export function applyEdit(
144
146
  signal?: AbortSignal,
145
147
  precomputedHashes?: string[],
146
148
  filePath?: string,
149
+ servedHashes?: ReadonlySet<string>,
147
150
  ): {
148
151
  content: string;
149
152
  firstChangedLine: number | undefined;
@@ -172,9 +175,13 @@ export function applyEdit(
172
175
  signal,
173
176
  );
174
177
  if (mismatches.length || !initialResolved) {
175
- throw new Error(
176
- fmtMismatch(mismatches, lineIndex.fileLines, fileHashes, filePath),
178
+ const feedback = fmtMismatchWithHashes(
179
+ mismatches,
180
+ lineIndex.fileLines,
181
+ fileHashes,
182
+ filePath,
177
183
  );
184
+ throw new AnchorMismatchError(feedback.text, feedback.hashes);
178
185
  }
179
186
 
180
187
  warnUnicodeEsc(prefixFixed, warnings);
@@ -211,13 +218,22 @@ export function applyEdit(
211
218
  signal,
212
219
  );
213
220
  if (correctedResult.mismatches.length || !correctedResult.resolved) {
214
- throw new Error(
215
- fmtMismatch(correctedResult.mismatches, lineIndex.fileLines, fileHashes, filePath),
221
+ const feedback = fmtMismatchWithHashes(
222
+ correctedResult.mismatches,
223
+ lineIndex.fileLines,
224
+ fileHashes,
225
+ filePath,
216
226
  );
227
+ throw new AnchorMismatchError(feedback.text, feedback.hashes);
217
228
  }
218
229
  resolved = correctedResult.resolved;
219
230
  }
220
231
 
232
+ if (servedHashes) {
233
+ abortIf(signal);
234
+ assertRangeServed(resolved, lineIndex.fileLines, fileHashes, servedHashes, filePath);
235
+ }
236
+
221
237
  const spanResult = resToSpan(resolved, content, lineIndex);
222
238
  if (spanResult.kind === "noop") {
223
239
  return {
@@ -36,6 +36,9 @@ export {
36
36
  swapReversedRanges,
37
37
  fmtMismatch,
38
38
  findNewEdge,
39
+ assertRangeServed,
40
+ RangeStaleError,
41
+ AnchorMismatchError,
39
42
  } from "./resolve";
40
43
 
41
44
  export {
@@ -1,7 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
2
+ import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
- import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
4
+ import { NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
@@ -74,15 +74,16 @@ function assertAligned(
74
74
  }
75
75
  }
76
76
 
77
- export function fmtMismatch(
77
+ export function fmtMismatchWithHashes(
78
78
  mismatches: HMismatch[],
79
79
  fileLines: string[],
80
80
  fileHashes: string[],
81
81
  filePath?: string,
82
- ): string {
82
+ ): { text: string; hashes: string[] } {
83
83
  assertAligned(fileLines, fileHashes, "fmtMismatch");
84
84
 
85
85
  const out: string[] = [];
86
+ const hashes: string[] = [];
86
87
  const notFound = mismatches.filter((m) => m.kind === "not_found");
87
88
  const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
88
89
 
@@ -98,6 +99,7 @@ export function fmtMismatch(
98
99
  const to = Math.min(fileLines.length, ctx.line + 1);
99
100
  const rows: string[] = [];
100
101
  for (let ln = from; ln <= to; ln++) {
102
+ hashes.push(fileHashes[ln - 1]!);
101
103
  rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
102
104
  }
103
105
  out.push("");
@@ -115,6 +117,7 @@ export function fmtMismatch(
115
117
  (m.candidates?.length ?? 0) > sample.length
116
118
  ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
117
119
  : "";
120
+ for (const line of sample) hashes.push(fileHashes[line - 1]!);
118
121
  const lines = sample
119
122
  .map((line) => {
120
123
  const content = clipLine(fileLines[line - 1] ?? "");
@@ -127,7 +130,16 @@ export function fmtMismatch(
127
130
  }
128
131
  }
129
132
 
130
- return out.join("\n");
133
+ return { text: out.join("\n"), hashes };
134
+ }
135
+
136
+ export function fmtMismatch(
137
+ mismatches: HMismatch[],
138
+ fileLines: string[],
139
+ fileHashes: string[],
140
+ filePath?: string,
141
+ ): string {
142
+ return fmtMismatchWithHashes(mismatches, fileLines, fileHashes, filePath).text;
131
143
  }
132
144
 
133
145
  const ITEM_KS = new Set(["replacement_text", "remove_from", "remove_to"]);
@@ -476,4 +488,64 @@ export function valEdit(
476
488
  };
477
489
  }
478
490
 
491
+ export class RangeStaleError extends Error {
492
+ readonly firstMismatchLine: number;
493
+ readonly rangeHashes: string[];
494
+ constructor(message: string, firstMismatchLine: number, rangeHashes: string[]) {
495
+ super(message);
496
+ this.name = "RangeStaleError";
497
+ this.firstMismatchLine = firstMismatchLine;
498
+ this.rangeHashes = rangeHashes;
499
+ }
500
+ }
501
+
502
+ export class AnchorMismatchError extends Error {
503
+ readonly feedbackHashes: string[];
504
+ constructor(message: string, feedbackHashes: string[]) {
505
+ super(message);
506
+ this.name = "AnchorMismatchError";
507
+ this.feedbackHashes = feedbackHashes;
508
+ }
509
+ }
510
+
511
+ export function assertRangeServed(
512
+ resolved: RHEdit,
513
+ fileLines: string[],
514
+ fileHashes: string[],
515
+ served: ReadonlySet<string>,
516
+ filePath?: string,
517
+ ): void {
518
+ assertAligned(fileLines, fileHashes, "assertRangeServed");
519
+ const startLine = resolved.hash_bounds[0].line;
520
+ const endLine = resolved.hash_bounds[1].line;
521
+ const mismatchLines: number[] = [];
522
+ for (let line = startLine; line <= endLine; line++) {
523
+ if (!served.has(fileHashes[line - 1]!)) mismatchLines.push(line);
524
+ }
525
+ if (mismatchLines.length === 0) return;
526
+
527
+ const rangeLength = endLine - startLine + 1;
528
+ const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
529
+ const rows: string[] = [];
530
+ const shownHashes: string[] = [];
531
+ for (let line = startLine; line < startLine + shownLength; line++) {
532
+ const hash = fileHashes[line - 1]!;
533
+ shownHashes.push(hash);
534
+ rows.push(`${hash}${HASH_SEP}${fileLines[line - 1]}`);
535
+ }
536
+ const location = filePath ? ` in ${filePath}` : "";
537
+ const first = mismatchLines[0]!;
538
+ const mismatchText =
539
+ mismatchLines.length === 1
540
+ ? `Line ${first} of the replaced range (lines ${startLine}-${endLine})${location} does not match`
541
+ : `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
542
+ const capHint =
543
+ rangeLength > shownLength
544
+ ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
545
+ : "";
546
+ const message =
547
+ `[E_RANGE_STALE] ${mismatchText} what was previously shown: the file changed on disk after the anchors were read, or the line(s) were never shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
548
+ throw new RangeStaleError(message, first, shownHashes);
549
+ }
550
+
479
551
  export { warnUnicodeEsc };
package/src/read.ts CHANGED
@@ -12,9 +12,10 @@ import { loadFileKindAndText } from "./file-kind";
12
12
  import { readNormFile } from "./file-reader";
13
13
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
14
14
  import { toCwd } from "./paths";
15
- import { abortIf, isRec, normalizeFilePath } from "./utils";
15
+ import { abortIf, isRec, normalizeFilePath, visLines } from "./utils";
16
16
  import { fileSnap } from "./file-reader";
17
- import { visLines } from "./utils";
17
+ import { loadHashStore } from "./hash-store";
18
+ import { recordServed } from "./served";
18
19
  import { loadP, loadGuide } from "./prompts";
19
20
  import { valAccess } from "./validation";
20
21
 
@@ -59,7 +60,7 @@ export async function fmtReadPreview(
59
60
  path?: string,
60
61
  maxLineBytes = MAX_READ_LINE_BYTES,
61
62
  maxTruncLines = DEFAULT_MAX_LINES,
62
- ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
63
+ ): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number; servedHashes: string[] }> {
63
64
  const allLines = visLines(text);
64
65
  const totalLines = allLines.length;
65
66
  const startLine = normPosInt(options.offset, "offset") ?? 1;
@@ -69,15 +70,18 @@ export async function fmtReadPreview(
69
70
  const emptyLineHash = allHashes[0] ?? "";
70
71
  return {
71
72
  text: `${emptyLineHash}${HASH_SEP}\n[File is empty. Use replace to insert content.]`,
73
+ servedHashes: emptyLineHash ? [emptyLineHash] : [],
72
74
  };
73
75
  }
74
76
  return {
75
77
  text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
78
+ servedHashes: [],
76
79
  };
77
80
  }
78
81
  if (startLine > totalLines) {
79
82
  return {
80
83
  text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
84
+ servedHashes: [],
81
85
  };
82
86
  }
83
87
 
@@ -104,6 +108,11 @@ export async function fmtReadPreview(
104
108
  const skippedTruncation = truncateHead(rows.join("\n"), { maxBytes, maxLines: maxTruncLines });
105
109
  const shownRowCount = skippedTruncation.content === "" ? 0 : skippedTruncation.content.split("\n").length;
106
110
  const lastShownLine = shownRowCount > 0 ? startLine + shownRowCount - 1 : startLine - 1;
111
+ const oversizedIndexes = new Set(rowSizes.map((row, index) => row.bytes > maxBytes ? index : -1).filter((index) => index >= 0));
112
+ const servedHashes: string[] = [];
113
+ for (let index = 0; index < Math.min(shownRowCount, rows.length); index++) {
114
+ if (!oversizedIndexes.has(index)) servedHashes.push(selectedHashes[index]!);
115
+ }
107
116
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
108
117
  const verb = oversized.length === 1 ? "exceeds" : "exceed";
109
118
  const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
@@ -120,6 +129,7 @@ export async function fmtReadPreview(
120
129
  text: preview,
121
130
  truncation: skippedTruncation.truncated ? skippedTruncation : undefined,
122
131
  ...(nextOffset !== undefined ? { nextOffset } : {}),
132
+ servedHashes,
123
133
  };
124
134
  }
125
135
 
@@ -127,6 +137,8 @@ export async function fmtReadPreview(
127
137
 
128
138
  let preview = truncation.content;
129
139
  let nextOffset: number | undefined;
140
+ const shownCount = truncation.content === "" ? 0 : truncation.content.split("\n").length;
141
+ const servedHashes = selectedHashes.slice(0, shownCount);
130
142
  if (truncation.truncated) {
131
143
  const endLineDisplay = startLine + truncation.outputLines - 1;
132
144
  nextOffset = endLineDisplay + 1;
@@ -144,6 +156,7 @@ export async function fmtReadPreview(
144
156
  text: preview,
145
157
  truncation: truncation.truncated ? truncation : undefined,
146
158
  ...(nextOffset !== undefined ? { nextOffset } : {}),
159
+ servedHashes,
147
160
  };
148
161
  }
149
162
 
@@ -198,7 +211,7 @@ export function regRead(pi: ExtensionAPI): void {
198
211
  ) => ReturnType<typeof builtinRead.execute>;
199
212
  return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
200
213
  }
201
- const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
214
+ const { normalized, fileHashes, hadUtf8DecodeErrors, absolutePath: resolvedPath } = await readNormFile(
202
215
  rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
203
216
  );
204
217
  const preview = await fmtReadPreview(
@@ -208,8 +221,14 @@ export function regRead(pi: ExtensionAPI): void {
208
221
  limit: params.limit,
209
222
  },
210
223
  fileHashes,
211
- absolutePath,
224
+ resolvedPath,
212
225
  );
226
+ try {
227
+ const store = await loadHashStore();
228
+ recordServed(store, resolvedPath, preview.servedHashes);
229
+ } catch (error) {
230
+ console.error("Failed to record served state from read:", error);
231
+ }
213
232
  let snapshotId: string | undefined;
214
233
  try {
215
234
  snapshotId = (await fileSnap(absolutePath)).snapshotId;
@@ -96,6 +96,7 @@ export function genDiff(
96
96
  let linesToShow: (string | symbol)[] = displayLines;
97
97
  let skipStart = 0;
98
98
  let skipMiddle = 0;
99
+ let skipTail = 0;
99
100
 
100
101
  if (!lastWasChange) {
101
102
  skipStart = Math.max(0, displayLines.length - contextLines);
@@ -104,8 +105,9 @@ export function genDiff(
104
105
  const tail = displayLines.slice(-contextLines);
105
106
  linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
106
107
  skipMiddle = displayLines.length - contextLines * 2;
107
- } else if (linesToShow.length > contextLines) {
108
+ } else if (!nextPartIsChange && linesToShow.length > contextLines) {
108
109
  linesToShow = linesToShow.slice(0, contextLines);
110
+ skipTail = displayLines.length - contextLines;
109
111
  }
110
112
 
111
113
  if (skipStart > 0) {
@@ -125,6 +127,9 @@ export function genDiff(
125
127
  newLineNum++;
126
128
  oldLineNum++;
127
129
  }
130
+ if (skipTail > 0) {
131
+ output.push(" ...");
132
+ }
128
133
  } else {
129
134
  newLineNum += displayLines.length;
130
135
  oldLineNum += displayLines.length;
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
  import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
+ import { recordServedDiff } from "./served";
6
7
  import { contentChecksum } from "./hashline/hasher";
7
8
  import { resolveTarget, writeAtomic } from "./fs-write";
8
9
  import { toCwd } from "./paths";
@@ -174,6 +175,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
174
175
  try {
175
176
  const store = await loadHashStore();
176
177
  upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
178
+ recordServedDiff(store, mutationTargetPath, undoDiff);
177
179
  } catch (error) {
178
180
  console.error("Failed to restore hash store snapshot after undo:", error);
179
181
  }
package/src/replace.ts CHANGED
@@ -20,6 +20,8 @@ import { applyEdit,
20
20
  resEdit,
21
21
  parseHashRef,
22
22
  MAX_HASH_LINES,
23
+ RangeStaleError,
24
+ AnchorMismatchError,
23
25
  type HEdit,
24
26
  type NEdit,
25
27
  } from "./hashline";
@@ -45,6 +47,7 @@ import {
45
47
  import { loadP, loadGuide } from "./prompts";
46
48
  import { saveUndo } from "./replace-undo";
47
49
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
50
+ import { getServed, recordServed, recordServedDiff } from "./served";
48
51
 
49
52
  const replacementTextSchema = Type.String({
50
53
  description:
@@ -232,13 +235,35 @@ export async function execPipeline(
232
235
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
233
236
  );
234
237
 
235
- const anchorResult = applyEdit(
236
- originalNormalized,
237
- edit,
238
- options?.signal,
239
- originalHashes,
240
- path,
241
- );
238
+ const served = await getServed(hashStore, absolutePath);
239
+ let anchorResult: ReturnType<typeof applyEdit>;
240
+ try {
241
+ anchorResult = applyEdit(
242
+ originalNormalized,
243
+ edit,
244
+ options?.signal,
245
+ originalHashes,
246
+ path,
247
+ served,
248
+ );
249
+ } catch (error) {
250
+ if (options?.noPersist !== true) {
251
+ if (error instanceof RangeStaleError) {
252
+ try {
253
+ recordServed(hashStore, absolutePath, error.rangeHashes);
254
+ } catch (recordError) {
255
+ console.error("Failed to record served state from range-stale feedback:", recordError);
256
+ }
257
+ } else if (error instanceof AnchorMismatchError) {
258
+ try {
259
+ recordServed(hashStore, absolutePath, error.feedbackHashes);
260
+ } catch (recordError) {
261
+ console.error("Failed to record served state from anchor-mismatch feedback:", recordError);
262
+ }
263
+ }
264
+ }
265
+ throw error;
266
+ }
242
267
 
243
268
  const result = anchorResult.content;
244
269
  const isNoop = result === originalNormalized;
@@ -562,7 +587,16 @@ export function buildToolDef(): ToolDef {
562
587
  snapshotId: updatedSnapshotId,
563
588
  editMeta,
564
589
  };
565
- return buildChanged(successInput);
590
+ const changed = buildChanged(successInput);
591
+ if (changed.details.diff) {
592
+ try {
593
+ const store = await loadHashStore();
594
+ recordServedDiff(store, mutationTargetPath, changed.details.diff);
595
+ } catch (error) {
596
+ console.error("Failed to record served state from post-edit diff:", error);
597
+ }
598
+ }
599
+ return changed;
566
600
  });
567
601
  },
568
602
  };
package/src/served.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { HashStore } from "./hash-store";
2
+ import { isValidHashList } from "./hash-store";
3
+ import { HASH_CLASS } from "./hashline/alphabet";
4
+
5
+ const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
6
+
7
+ export function servedHashesFromDiff(diff: string): string[] {
8
+ const hashes: string[] = [];
9
+ for (const line of diff.split("\n")) {
10
+ const match = SERVED_DIFF_ROW_RE.exec(line);
11
+ if (match) hashes.push(match[1]!);
12
+ }
13
+ return hashes;
14
+ }
15
+
16
+ export function getServed(store: HashStore, path: string): Set<string> | undefined {
17
+ const row = store.stmts.servedGet(path);
18
+ if (!row) return undefined;
19
+ try {
20
+ const parsed = JSON.parse(row.hashes as string);
21
+ if (!isValidHashList(parsed)) {
22
+ store.stmts.servedDelete(path);
23
+ return undefined;
24
+ }
25
+ return new Set(parsed);
26
+ } catch {
27
+ store.stmts.servedDelete(path);
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export function recordServed(store: HashStore, path: string, hashes: string[]): void {
33
+ if (hashes.length === 0) return;
34
+ const existing = getServed(store, path) ?? new Set<string>();
35
+ let changed = false;
36
+ for (const hash of hashes) {
37
+ if (!existing.has(hash)) {
38
+ existing.add(hash);
39
+ changed = true;
40
+ }
41
+ }
42
+ if (!changed) return;
43
+ store.stmts.servedUpsert(path, JSON.stringify([...existing]), Date.now());
44
+ }
45
+
46
+ export function recordServedDiff(store: HashStore, path: string, diff: string): void {
47
+ recordServed(store, path, servedHashesFromDiff(diff));
48
+ }
49
+
50
+ export function clearServed(store: HashStore, path: string): void {
51
+ store.stmts.servedDelete(path);
52
+ }