pi-hashline-edit-pro 2.8.1 → 2.8.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 CHANGED
@@ -133,7 +133,7 @@ Notes:
133
133
 
134
134
  ## The grep tool
135
135
 
136
- `grep` replaces the built-in grep with an anchored search. Every matching line (and each requested context line) is returned as an `anchor│content` row, and those rows are recorded in the served state exactly like `read` output, so you can target them with `replace` or `insert` immediately without a separate `read`.
136
+ `grep` replaces the built-in grep with an anchored search backed by ripgrep. Every matching line (and each requested context line) is returned as `lineNumber anchor│content` the `anchor│content` part is served exactly like `read` output, so you can target it with `replace`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
137
137
 
138
138
  | Field | Description |
139
139
  | --- | --- |
@@ -146,10 +146,10 @@ Notes:
146
146
  | `limit` | Maximum number of matched lines to return (default: 100). |
147
147
 
148
148
  Notes:
149
- - Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
150
- - Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
149
+ - Results are grouped per file under a `=== path ===` header; every shown row is `lineNumber │ anchor│content` where `anchor│content` is the anchor it would have in `read` output.
150
+ - Directory searches use ripgrep, respecting `.gitignore`; `node_modules`, `.git`, `.tmp`, and `coverage` are always skipped. Binary, image, and oversized files are skipped silently.
151
151
  - Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
152
- - Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
152
+ - Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line).
153
153
  - `file_path` works as an alias for `path`.
154
154
  - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
155
155
  - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
package/index.ts CHANGED
@@ -14,13 +14,14 @@ import {
14
14
  toggleAutoRead,
15
15
  } from "./src/config";
16
16
  import { loadHashStore, pruneMissing } from "./src/hash-store";
17
- import { recordServedSafe, clearServed } from "./src/served";
17
+ import { recordServedSafe, clearServed, buildServedMap } from "./src/served";
18
18
  import { clearBoundaryBypass } from "./src/boundary-bypass";
19
19
  import { registerWriteHook } from "./src/write-hook";
20
20
  import { readNormFile } from "./src/file-reader";
21
21
  import { loadFileKindAndText } from "./src/file-kind";
22
22
  import { resolveInCwd } from "./src/fs-write";
23
23
  import { valAccess } from "./src/validation";
24
+ import { splitLines } from "./src/utils";
24
25
 
25
26
  export default function (pi: ExtensionAPI): void {
26
27
  regRead(pi);
@@ -37,12 +38,15 @@ export default function (pi: ExtensionAPI): void {
37
38
  const active = pi.getActiveTools();
38
39
  pi.setActiveTools(active.filter((t) => t !== "edit"));
39
40
  await initHasher();
40
- try {
41
- const store = await loadHashStore();
42
- await pruneMissing(store);
43
- } catch (err) {
44
- console.error("Failed to load or prune hash store:", err);
45
- }
41
+ loadHashStore()
42
+ .then(store =>
43
+ pruneMissing(store).catch(err => {
44
+ console.error("Failed to prune hash store:", err);
45
+ }),
46
+ )
47
+ .catch(err => {
48
+ console.error("Failed to load hash store:", err);
49
+ });
46
50
  const config = await readConfig();
47
51
  autoRead = config.autoRead;
48
52
  const debugValue = process.env.PI_HASHLINE_DEBUG;
@@ -95,7 +99,9 @@ export default function (pi: ExtensionAPI): void {
95
99
  DEFAULT_MAX_BYTES,
96
100
  DEFAULT_MAX_LINES,
97
101
  );
98
- await recordServedSafe(absolutePath, preview.servedHashes, "auto-read", new Set(fileHashes));
102
+ const fileLines = splitLines(normalized);
103
+ const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
104
+ await recordServedSafe(absolutePath, servedMap, "auto-read", new Set(fileHashes));
99
105
  return {
100
106
  content: [
101
107
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep 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",
@@ -1,3 +1,3 @@
1
- - `grep`: every hit and `context` line comes back as `anchor│content` — use that anchor directly for `replace`/`insert` without a new `read`.
2
- - `grep`: use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
- - `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
1
+ - `grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
2
+ - `grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
3
+ - `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
@@ -1 +1 @@
1
- Search with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
1
+ Search with `lineNumber │ anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
package/prompts/grep.md CHANGED
@@ -1 +1 @@
1
- Search text files for a pattern. Every hit and each `context` line is returned as `anchor│content` so you can use that anchor directly for `replace`/`insert` without a new `read`. Directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
1
+ Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
package/src/commit.ts CHANGED
@@ -4,8 +4,9 @@ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-res
4
4
  import { saveUndo } from "./replace-undo";
5
5
  import { safeSnapId } from "./file-reader";
6
6
  import { writeAtomic } from "./fs-write";
7
- import { recordServedDiffSafe } from "./served";
7
+ import { recordServedSafe, buildServedMap, servedHashesFromDiff } from "./served";
8
8
  import { restoreEndings } from "./normalize";
9
+ import { splitLines } from "./utils";
9
10
 
10
11
  export interface CommitMeta {
11
12
  path: string;
@@ -102,7 +103,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
102
103
  };
103
104
  const changed = buildChanged(successInput, meta.verb);
104
105
  if (changed.details.diff) {
105
- await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(pipe.resultHashes));
106
+ const diffHashes = servedHashesFromDiff(changed.details.diff);
107
+ const resultLines = splitLines(pipe.result);
108
+ const servedMap = buildServedMap(pipe.resultHashes, resultLines, diffHashes);
109
+ await recordServedSafe(mutationTargetPath, servedMap, "post-edit diff", new Set(pipe.resultHashes));
106
110
  }
107
111
  return changed;
108
112
  }
package/src/constants.ts CHANGED
@@ -7,6 +7,6 @@ export const MAX_HASH_SOURCE_BYTES = 500;
7
7
  export const MAX_GREP_LINE_BYTES = 500;
8
8
 
9
9
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
- export const HASH_STORE_VERSION = 5;
10
+ export const HASH_STORE_VERSION = 6;
11
11
  export const NEW_CONTENT_NOT_ARRAY_MSG =
12
12
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
package/src/grep.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { readdir, stat } from "fs/promises";
4
+ import { stat } from "fs/promises";
5
5
  import { dirname, join, relative } from "path";
6
+ import { spawn, spawnSync } from "child_process";
7
+ import { createInterface } from "readline";
6
8
  import { tryReadNormFile } from "./file-reader";
7
9
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
8
10
  import { MAX_GREP_LINE_BYTES } from "./constants";
9
11
  import { toCwd } from "./paths";
10
12
  import { loadP, loadGuide } from "./prompts";
11
13
  import { normReq } from "./payload-contract";
12
- import { recordServedSafe } from "./served";
14
+ import { recordServedSafe, buildServedMap } from "./served";
13
15
  import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
14
16
 
15
17
  const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
16
- const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
17
- const MAX_SCAN_FILES = 4000;
18
18
 
19
19
  function cmp(a: string, b: string): number {
20
20
  return a < b ? -1 : a > b ? 1 : 0;
@@ -69,13 +69,11 @@ function unsafeRegex(pattern: string): never {
69
69
 
70
70
  function assertSafeRegex(pattern: string): void {
71
71
  if (pattern.length > 4096) unsafeRegex(pattern);
72
-
73
72
  const groups: RegexGroupRisk[] = [];
74
73
  let inClass = false;
75
74
  let escaped = false;
76
75
  let variableQuantifiers = 0;
77
76
  let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
78
-
79
77
  for (let i = 0; i < pattern.length; i++) {
80
78
  const ch = pattern[i]!;
81
79
  if (escaped) {
@@ -122,13 +120,17 @@ function assertSafeRegex(pattern: string): void {
122
120
  lastAtom = undefined;
123
121
  continue;
124
122
  }
125
-
126
123
  let quantifierLength = 0;
127
124
  if (ch === "*" || ch === "+" || ch === "?") {
128
125
  quantifierLength = 1;
129
126
  } else if (ch === "{") {
130
127
  quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
131
128
  }
129
+ if (ch === "{" && quantifierLength > 0) {
130
+ const quant = pattern.slice(i, i + quantifierLength);
131
+ const m = /^\{(\d+)/.exec(quant);
132
+ if (m && Number(m[1]) > 1000) unsafeRegex(pattern);
133
+ }
132
134
  if (quantifierLength > 0 && lastAtom) {
133
135
  if (ch === "?" && lastAtom.quantified) continue;
134
136
  const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
@@ -140,7 +142,6 @@ function assertSafeRegex(pattern: string): void {
140
142
  i += quantifierLength - 1;
141
143
  continue;
142
144
  }
143
-
144
145
  lastAtom = { groupRisky: false, quantified: false };
145
146
  }
146
147
  }
@@ -177,8 +178,10 @@ interface FileHit {
177
178
  path: string;
178
179
  displayPath: string;
179
180
  fileHashes: string[];
181
+ fileLines: string[];
180
182
  rows: string[];
181
183
  hashes: string[];
184
+ lineNumbers: number[];
182
185
  matchCount: number;
183
186
  totalMatchCount: number;
184
187
  fragmented: boolean[];
@@ -219,96 +222,41 @@ function grepHeadFragment(line: string): string {
219
222
  return head.length < line.length ? `${head}...` : head;
220
223
  }
221
224
 
222
- interface ScanState {
223
- scanned: number;
224
- stopped: boolean;
225
- }
226
-
227
- async function walkFiles(
228
- root: string,
229
- state: ScanState,
230
- onFile: (absPath: string) => Promise<void>,
231
- signal?: AbortSignal,
232
- ): Promise<void> {
233
- const queue: string[] = [root];
234
- let head = 0;
235
- while (head < queue.length && !state.stopped) {
236
- abortIf(signal);
237
- const dir = queue[head++]!;
238
- let entries;
239
- try {
240
- entries = await readdir(dir, { withFileTypes: true });
241
- } catch {
242
- continue;
243
- }
244
- entries.sort((a, b) => cmp(a.name, b.name));
245
- for (let ei = 0; ei < entries.length; ei++) {
246
- if ((ei & 127) === 0) abortIf(signal);
247
- if (state.stopped) break;
248
- const entry = entries[ei]!;
249
- const full = join(dir, entry.name);
250
- if (entry.isDirectory()) {
251
- if (SKIP_DIRS.has(entry.name)) continue;
252
- queue.push(full);
253
- } else if (entry.isFile()) {
254
- state.scanned += 1;
255
- if (state.scanned > MAX_SCAN_FILES) {
256
- state.stopped = true;
257
- break;
258
- }
259
- await onFile(full);
260
- }
261
- }
262
- }
263
- }
264
-
265
- async function searchFile(
266
- absPath: string,
267
- globRoot: string,
268
- cwd: string,
269
- regex: RegExp,
270
- globRegex: RegExp | undefined,
225
+ function makeHitFromIndices(
226
+ norm: { normalized: string; fileHashes: string[]; absolutePath: string },
227
+ displayPath: string,
228
+ matchIndices: number[],
271
229
  context: number,
272
- maxMatches: number,
273
- signal?: AbortSignal,
274
- ): Promise<FileHit | undefined> {
275
- const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
276
- if (globRegex) {
277
- const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
278
- if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
279
- }
280
- const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
281
- if (!norm) return undefined;
230
+ regex: RegExp | undefined,
231
+ totalMatchCount: number,
232
+ keptMatchCount: number,
233
+ ): FileHit {
282
234
  const lines = visLines(norm.normalized);
283
- const matchLines: number[] = [];
284
- for (let i = 0; i < lines.length; i++) {
285
- if ((i & 1023) === 0) abortIf(signal);
286
- if (i !== 0 && (i & 4095) === 0) await new Promise<void>((r) => setImmediate(r));
287
- if (regex.test(lines[i]!)) matchLines.push(i);
288
- }
289
- if (matchLines.length === 0) return undefined;
290
- const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
291
235
  const shown = new Set<number>();
292
- for (const i of keptMatches) {
236
+ const kept = matchIndices.slice(0, keptMatchCount);
237
+ for (const i of kept) {
293
238
  for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
294
239
  }
295
240
  const sorted = [...shown].sort((a, b) => a - b);
296
- const matchSet = new Set(matchLines);
241
+ const matchSet = new Set(matchIndices);
297
242
  const rows: string[] = [];
298
243
  const hashes: string[] = [];
244
+ const lineNumbers: number[] = [];
299
245
  const fragmented: boolean[] = [];
300
246
  for (const idx of sorted) {
301
247
  const hash = norm.fileHashes[idx]!;
302
248
  const line = lines[idx]!;
303
249
  const row = fmtRow(hash, line);
304
250
  if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
305
- const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
251
+ const content = matchSet.has(idx) && regex ? grepMatchFragment(line, regex) : grepHeadFragment(line);
306
252
  rows.push(fmtRow(hash, content));
307
253
  hashes.push(hash);
254
+ lineNumbers.push(idx + 1);
308
255
  fragmented.push(true);
309
256
  } else {
310
257
  rows.push(row);
311
258
  hashes.push(hash);
259
+ lineNumbers.push(idx + 1);
312
260
  fragmented.push(false);
313
261
  }
314
262
  }
@@ -316,14 +264,142 @@ async function searchFile(
316
264
  path: norm.absolutePath,
317
265
  displayPath,
318
266
  fileHashes: norm.fileHashes,
267
+ fileLines: lines,
319
268
  rows,
320
269
  hashes,
321
- matchCount: keptMatches.length,
322
- totalMatchCount: matchLines.length,
270
+ lineNumbers,
271
+ matchCount: kept.length,
272
+ totalMatchCount,
323
273
  fragmented,
324
274
  };
325
275
  }
326
276
 
277
+ async function resolveRgPath(): Promise<string> {
278
+ try {
279
+ const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
280
+ if (!r.error && r.status === 0) return "rg";
281
+ } catch {}
282
+ try {
283
+ const { homedir } = await import("os");
284
+ const { existsSync } = await import("fs");
285
+ const home = process.env.HOME ?? homedir();
286
+ const base = process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent");
287
+ const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
288
+ if (existsSync(bin)) {
289
+ const r = spawnSync(bin, ["--version"], { stdio: "pipe" });
290
+ if (!r.error && r.status === 0) return bin;
291
+ }
292
+ } catch {}
293
+ try {
294
+ const { createRequire } = await import("module");
295
+ const require = createRequire(import.meta.url);
296
+ const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
297
+ const { dirname } = await import("path");
298
+ const piDir = dirname(pkgPath);
299
+ const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
300
+ const mod = await import("file://" + toolsManagerPath);
301
+ if (mod.ensureTool) {
302
+ const p = await mod.ensureTool("rg", true);
303
+ if (p) return p;
304
+ }
305
+ } catch {}
306
+ throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
307
+ }
308
+
309
+ async function collectRgMatches(
310
+ rgPath: string,
311
+ pattern: string,
312
+ searchPath: string,
313
+ req: GrepReq,
314
+ signal?: AbortSignal,
315
+ ): Promise<Map<string, number[]>> {
316
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
317
+ if (req.ignoreCase) args.push("--ignore-case");
318
+ if (req.literal) args.push("--fixed-strings");
319
+ args.push("--", pattern, searchPath);
320
+ const result = new Map<string, number[]>();
321
+ return await new Promise<Map<string, number[]>>((resolve, reject) => {
322
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
323
+ const rl = createInterface({ input: child.stdout });
324
+ let stderr = "";
325
+ let timedOut = false;
326
+ const rgTimeout = setTimeout(() => {
327
+ timedOut = true;
328
+ if (!child.killed) child.kill("SIGKILL");
329
+ reject(new Error("rg timeout"));
330
+ }, 10000);
331
+ child.stderr?.on("data", (chunk) => {
332
+ stderr += chunk.toString();
333
+ });
334
+ const onAbort = () => {
335
+ if (!child.killed) child.kill("SIGKILL");
336
+ };
337
+ signal?.addEventListener("abort", onAbort, { once: true });
338
+ const cleanup = () => {
339
+ clearTimeout(rgTimeout);
340
+ rl.close();
341
+ signal?.removeEventListener("abort", onAbort);
342
+ };
343
+ rl.on("line", (line) => {
344
+ if (!line.trim()) return;
345
+ let event: { type?: string; data?: { path?: { text?: string }; line_number?: number } };
346
+ try {
347
+ event = JSON.parse(line);
348
+ } catch {
349
+ return;
350
+ }
351
+ if (event.type === "match") {
352
+ const filePath = event.data?.path?.text;
353
+ const lineNumber = event.data?.line_number;
354
+ if (typeof filePath === "string" && typeof lineNumber === "number") {
355
+ let abs: string;
356
+ try {
357
+ abs = filePath.startsWith("/") || /^[A-Za-z]:\\/.test(filePath) ? filePath : join(searchPath, filePath);
358
+ } catch {
359
+ abs = filePath;
360
+ }
361
+ const list = result.get(abs) ?? [];
362
+ list.push(lineNumber);
363
+ result.set(abs, list);
364
+ }
365
+ }
366
+ });
367
+ child.on("error", (error) => {
368
+ cleanup();
369
+ reject(error);
370
+ });
371
+ child.on("close", (code) => {
372
+ cleanup();
373
+ if (timedOut) return;
374
+ if (signal?.aborted) {
375
+ reject(new Error("Operation aborted"));
376
+ return;
377
+ }
378
+ if (code !== 0 && code !== 1) {
379
+ const msg = stderr.trim() || `ripgrep exited with code ${code}`;
380
+ reject(new Error(msg));
381
+ return;
382
+ }
383
+ resolve(result);
384
+ });
385
+ });
386
+ }
387
+
388
+ function gutterWidthFor(numbers: number[]): number {
389
+ let max = 0;
390
+ for (const n of numbers) if (n > max) max = n;
391
+ return String(max || 1).length;
392
+ }
393
+
394
+ function displayRowsForHit(hit: FileHit): string[] {
395
+ const width = gutterWidthFor(hit.lineNumbers);
396
+ return hit.rows.map((row, i) => {
397
+ const n = hit.lineNumbers[i]!;
398
+ const padded = String(n).padStart(width, " ");
399
+ return `${padded} │ ${row}`;
400
+ });
401
+ }
402
+
327
403
  const grepToolSchema = Type.Object(
328
404
  {
329
405
  pattern: Type.String({
@@ -380,10 +456,8 @@ export function regGrep(pi: ExtensionAPI): void {
380
456
  const canonical = normReq(params);
381
457
  assertGrepReq(canonical);
382
458
  const req = canonical;
383
- const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
384
459
  const context = req.context ?? 0;
385
460
  const limit = req.limit ?? 100;
386
- const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
387
461
  const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
388
462
  abortIf(signal);
389
463
  let baseStat;
@@ -396,16 +470,9 @@ export function regGrep(pi: ExtensionAPI): void {
396
470
  throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
397
471
  }
398
472
  const globRoot = baseStat.isFile() ? dirname(base) : base;
399
- const state: ScanState = { scanned: 0, stopped: false };
400
- const files: string[] = [];
401
- if (baseStat.isFile()) {
402
- files.push(base);
403
- } else {
404
- await walkFiles(base, state, async (absPath) => {
405
- files.push(absPath);
406
- }, signal);
407
- files.sort(cmp);
408
- }
473
+ const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
474
+ const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
475
+ const rgPath = await resolveRgPath();
409
476
  const hits: FileHit[] = [];
410
477
  let matches = 0;
411
478
  let limitTruncated = false;
@@ -417,17 +484,26 @@ export function regGrep(pi: ExtensionAPI): void {
417
484
  let truncatedBy: "lines" | "bytes" | null = null;
418
485
  let linesReplaced = 0;
419
486
  let countOnly = false;
420
- for (let f = 0; f < files.length; f++) {
487
+ const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
488
+ const sortedFiles = [...rgMatches.keys()].sort(cmp);
489
+ for (let f = 0; f < sortedFiles.length; f++) {
421
490
  abortIf(signal);
422
- const absPath = files[f]!;
491
+ const absPath = sortedFiles[f]!;
492
+ const allNums = rgMatches.get(absPath) ?? [];
493
+ const totalForFile = allNums.length;
494
+ const sortedNums = [...allNums].sort((a, b) => a - b);
495
+ const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
423
496
  if (countOnly) {
424
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER, signal);
425
- if (!hit) continue;
426
- totalRows += hit.rows.length;
427
- for (const row of hit.rows) totalBytes += Buffer.byteLength(row, "utf-8") + 1;
497
+ const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
498
+ if (!norm) continue;
499
+ const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
500
+ const display = displayRowsForHit(hit);
501
+ totalRows += display.length;
502
+ for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
428
503
  const remaining = limit - matches;
429
504
  if (remaining > 0) {
430
- matches += Math.min(hit.matchCount, remaining);
505
+ const add = Math.min(hit.matchCount, remaining);
506
+ matches += add;
431
507
  if (hit.matchCount > remaining) limitTruncated = true;
432
508
  } else {
433
509
  limitTruncated = true;
@@ -439,24 +515,36 @@ export function regGrep(pi: ExtensionAPI): void {
439
515
  limitTruncated = true;
440
516
  break;
441
517
  }
442
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining, signal);
518
+ const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
519
+ if (!norm) continue;
520
+ if (globRegex) {
521
+ const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
522
+ const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
523
+ if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
524
+ }
525
+ const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
443
526
  if (!hit) continue;
527
+ const display = displayRowsForHit(hit);
444
528
  const keptRows: string[] = [];
445
529
  const keptHashes: string[] = [];
446
- for (let i = 0; i < hit.rows.length; i++) {
447
- const row = hit.rows[i]!;
530
+ const keptLineNumbers: number[] = [];
531
+ const keptFragmented: boolean[] = [];
532
+ for (let i = 0; i < display.length; i++) {
533
+ const row = display[i]!;
448
534
  const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
449
535
  if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
450
536
  rowTruncated = true;
451
537
  if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
452
- for (let j = i; j < hit.rows.length; j++) {
538
+ for (let j = i; j < display.length; j++) {
453
539
  totalRows += 1;
454
- totalBytes += Buffer.byteLength(hit.rows[j]!, "utf-8") + 1;
540
+ totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
455
541
  }
456
542
  break;
457
543
  }
458
544
  keptRows.push(row);
459
- keptHashes.push(hit.hashes[i]);
545
+ keptHashes.push(hit.hashes[i]!);
546
+ keptLineNumbers.push(hit.lineNumbers[i]!);
547
+ keptFragmented.push(hit.fragmented[i]!);
460
548
  if (hit.fragmented[i]) linesReplaced += 1;
461
549
  rowCount += 1;
462
550
  byteCount += rowBytes;
@@ -465,12 +553,14 @@ export function regGrep(pi: ExtensionAPI): void {
465
553
  }
466
554
  if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
467
555
  matches += hit.matchCount;
468
- hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
556
+ const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
557
+ hits.push(displayHit);
469
558
  if (rowTruncated) countOnly = true;
470
559
  }
471
560
  hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
472
561
  for (const hit of hits) {
473
- await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
562
+ const servedMap = buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes);
563
+ await recordServedSafe(hit.path, servedMap, "grep", new Set(hit.fileHashes));
474
564
  }
475
565
  const blocks = hits
476
566
  .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
@@ -478,7 +568,6 @@ export function regGrep(pi: ExtensionAPI): void {
478
568
  const notes: string[] = [];
479
569
  if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
480
570
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
481
- if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
482
571
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
483
572
  const truncated = limitTruncated || rowTruncated;
484
573
  const truncation: TruncationResult | undefined = rowTruncated
@@ -505,7 +594,7 @@ export function regGrep(pi: ExtensionAPI): void {
505
594
  metrics: {
506
595
  matches,
507
596
  files: hits.length,
508
- truncated: truncated || state.stopped,
597
+ truncated,
509
598
  },
510
599
  },
511
600
  };
@@ -9,6 +9,15 @@ export function isValidHashList(value: unknown): value is string[] {
9
9
  return true;
10
10
  }
11
11
 
12
+ export function isValidServedMap(value: unknown): value is Record<string, string> {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
14
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
15
+ if (typeof k !== "string" || !HASH_RE.test(k)) return false;
16
+ if (typeof v !== "string") return false;
17
+ }
18
+ return true;
19
+ }
20
+
12
21
  export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
13
22
  let parsed: unknown;
14
23
  try {
@@ -26,11 +35,33 @@ export function parseHashList(raw: string, onInvalid: () => void, context?: stri
26
35
  return parsed;
27
36
  }
28
37
 
38
+ export function parseServedMap(raw: string, onInvalid: () => void, context?: string): Map<string, string> | undefined {
39
+ let parsed: unknown;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ } catch (error) {
43
+ console.error(`[parseServedMap]${context ? ` ${context}:` : ""} failed to parse stored served JSON:`, error);
44
+ onInvalid();
45
+ return undefined;
46
+ }
47
+ if (!isValidServedMap(parsed)) {
48
+ console.error(`[parseServedMap]${context ? ` ${context}:` : ""} stored served did not pass validation:`, (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
49
+ onInvalid();
50
+ return undefined;
51
+ }
52
+ return new Map(Object.entries(parsed as Record<string, string>));
53
+ }
54
+
29
55
  export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
30
56
  if (!row) return undefined;
31
57
  return parseHashList(row.hashes as string, onInvalid);
32
58
  }
33
59
 
60
+ export function parseStoredServed(row: Record<string, unknown> | undefined, onInvalid: () => void): Map<string, string> | undefined {
61
+ if (!row) return undefined;
62
+ return parseServedMap(row.hashes as string, onInvalid);
63
+ }
64
+
34
65
  export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
35
66
  if (typeof value !== "object" || value === null) return false;
36
67
  const v = value as Record<string, unknown>;
package/src/hash-store.ts CHANGED
@@ -6,10 +6,13 @@ import { initHasher, contentChecksum } from "./hashline/hasher";
6
6
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
7
7
  import {
8
8
  isValidHashList,
9
+ isValidServedMap,
9
10
  parseStoredHashes,
11
+ parseStoredServed,
10
12
  isValidSnapshot,
11
13
  isCorruptionError,
12
14
  parseHashList,
15
+ parseServedMap,
13
16
  } from "./hash-store/validation";
14
17
  import {
15
18
  withBusyRetry,
@@ -22,7 +25,7 @@ import {
22
25
  SNAPSHOT_CACHE_LIMIT,
23
26
  } from "./hash-store/cache";
24
27
 
25
- export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
28
+ export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parseStoredHashes, parseStoredServed, isCorruptionError };
26
29
  export { SNAPSHOT_CACHE_LIMIT };
27
30
  export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
28
31
 
@@ -272,6 +275,20 @@ async function openStore(storePath: string): Promise<HashStore> {
272
275
  opened = await openDbWithBusyRetryAsync(() => openDb(storePath));
273
276
  }
274
277
  const { db, stmts } = opened;
278
+ try {
279
+ const autoVacuum = (db.prepare("PRAGMA auto_vacuum").get() as { auto_vacuum: number }).auto_vacuum;
280
+ const pageCount = (db.prepare("PRAGMA page_count").get() as { page_count: number }).page_count;
281
+ const freelist = (db.prepare("PRAGMA freelist_count").get() as { freelist_count: number }).freelist_count;
282
+ if (autoVacuum === 0 && !existed) {
283
+ db.exec("PRAGMA auto_vacuum=INCREMENTAL");
284
+ } else if (freelist > 50 && freelist * 5 > pageCount) {
285
+ try {
286
+ db.exec("PRAGMA incremental_vacuum(50)");
287
+ } catch {
288
+ db.exec("VACUUM");
289
+ }
290
+ }
291
+ } catch {}
275
292
 
276
293
  if (process.platform !== "win32") {
277
294
  for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
@@ -551,10 +568,37 @@ function matchPathsByHashes(
551
568
  return matches;
552
569
  }
553
570
 
571
+ function matchPathsByServed(
572
+ rows: { path: string; hashes: string }[],
573
+ hashes: string[],
574
+ ): string[] {
575
+ const needed = new Set(hashes);
576
+ if (needed.size === 0) return [];
577
+ const matches: string[] = [];
578
+ for (const row of rows) {
579
+ try {
580
+ const parsed = JSON.parse(row.hashes) as unknown;
581
+ if (!isValidServedMap(parsed)) continue;
582
+ const keySet = new Set(Object.keys(parsed as Record<string, unknown>));
583
+ let ok = true;
584
+ for (const h of needed) {
585
+ if (!keySet.has(h)) {
586
+ ok = false;
587
+ break;
588
+ }
589
+ }
590
+ if (ok) matches.push(row.path);
591
+ } catch {
592
+ continue;
593
+ }
594
+ }
595
+ return matches;
596
+ }
597
+
554
598
  export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
555
599
  return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
556
600
  }
557
601
 
558
602
  export function findServedPaths(store: HashStore, hashes: string[]): string[] {
559
- return matchPathsByHashes(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
603
+ return matchPathsByServed(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
560
604
  }
@@ -154,7 +154,7 @@ export function applyEdit(
154
154
  signal?: AbortSignal,
155
155
  precomputedHashes?: string[],
156
156
  filePath?: string,
157
- servedHashes?: ReadonlySet<string>,
157
+ servedHashes?: ReadonlyMap<string, string>,
158
158
  skipBoundaryDedup?: boolean,
159
159
  ): {
160
160
  content: string;
@@ -190,7 +190,7 @@ export function applyEdit(
190
190
  fileHashes,
191
191
  filePath,
192
192
  );
193
- throw new AnchorMismatchError(feedback.text, feedback.hashes);
193
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
194
194
  }
195
195
 
196
196
  warnUnicodeEsc(prefixFixed, warnings);
@@ -233,7 +233,7 @@ export function applyEdit(
233
233
  fileHashes,
234
234
  filePath,
235
235
  );
236
- throw new AnchorMismatchError(feedback.text, feedback.hashes);
236
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
237
237
  }
238
238
  resolved = correctedResult.resolved;
239
239
  }
@@ -2,6 +2,7 @@ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, cl
2
2
  import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
+ import { contentChecksum } from "./hasher";
5
6
 
6
7
  export type RAnchor = {
7
8
  line: number;
@@ -90,14 +91,13 @@ export function fmtMismatchWithHashes(
90
91
  fileLines: string[],
91
92
  fileHashes: string[],
92
93
  filePath?: string,
93
- ): { text: string; hashes: string[] } {
94
+ ): { text: string; hashes: string[]; servedMap: Map<string, string> } {
94
95
  assertAligned(fileLines, fileHashes, "fmtMismatch");
95
-
96
96
  const out: string[] = [];
97
97
  const hashes: string[] = [];
98
+ const servedMap = new Map<string, string>();
98
99
  const notFound = mismatches.filter((m) => m.kind === "not_found");
99
100
  const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
100
-
101
101
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
102
102
  if (notFound.length > 0) {
103
103
  out.push(
@@ -110,8 +110,11 @@ export function fmtMismatchWithHashes(
110
110
  const to = Math.min(fileLines.length, ctx.line + 1);
111
111
  const rows: string[] = [];
112
112
  for (let ln = from; ln <= to; ln++) {
113
- hashes.push(fileHashes[ln - 1]!);
114
- rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
113
+ const h = fileHashes[ln - 1]!;
114
+ const c = fileLines[ln - 1] ?? "";
115
+ hashes.push(h);
116
+ servedMap.set(h, contentChecksum(c));
117
+ rows.push(` ${ln}: ${h}│${clipLine(c)}`);
115
118
  }
116
119
  out.push("");
117
120
  out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
@@ -128,7 +131,12 @@ export function fmtMismatchWithHashes(
128
131
  (m.candidates?.length ?? 0) > sample.length
129
132
  ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
130
133
  : "";
131
- for (const line of sample) hashes.push(fileHashes[line - 1]!);
134
+ for (const line of sample) {
135
+ const h = fileHashes[line - 1]!;
136
+ const c = fileLines[line - 1] ?? "";
137
+ hashes.push(h);
138
+ servedMap.set(h, contentChecksum(c));
139
+ }
132
140
  const lines = sample
133
141
  .map((line) => {
134
142
  const content = clipLine(fileLines[line - 1] ?? "");
@@ -140,8 +148,7 @@ export function fmtMismatchWithHashes(
140
148
  );
141
149
  }
142
150
  }
143
-
144
- return { text: out.join("\n"), hashes };
151
+ return { text: out.join("\n"), hashes, servedMap };
145
152
  }
146
153
 
147
154
 
@@ -482,45 +489,49 @@ export function valEdit(
482
489
  }
483
490
 
484
491
  export function resolveAnchorLine(
485
- ref: Anchor,
486
- fileLines: string[],
487
- fileHashes: string[],
488
- filePath?: string,
492
+ ref: Anchor,
493
+ fileLines: string[],
494
+ fileHashes: string[],
495
+ filePath?: string,
489
496
  ): number {
490
- const { resolved, mismatches } = valEdit(
491
- { hash_bounds: [ref, ref], content_lines: [] },
492
- fileLines,
493
- fileHashes,
494
- [],
495
- undefined,
496
- );
497
- if (mismatches.length > 0 || !resolved) {
498
- const feedback = fmtMismatchWithHashes(
499
- mismatches,
500
- fileLines,
501
- fileHashes,
502
- filePath,
503
- );
504
- throw new AnchorMismatchError(feedback.text, feedback.hashes);
505
- }
506
- return resolved.hash_bounds[0].line;
497
+ const { resolved, mismatches } = valEdit(
498
+ { hash_bounds: [ref, ref], content_lines: [] },
499
+ fileLines,
500
+ fileHashes,
501
+ [],
502
+ undefined,
503
+ );
504
+ if (mismatches.length > 0 || !resolved) {
505
+ const feedback = fmtMismatchWithHashes(
506
+ mismatches,
507
+ fileLines,
508
+ fileHashes,
509
+ filePath,
510
+ );
511
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
512
+ }
513
+ return resolved.hash_bounds[0].line;
507
514
  }
508
515
 
509
516
  export class RangeStaleError extends Error {
510
517
  readonly rangeHashes: string[];
511
- constructor(message: string, rangeHashes: string[]) {
518
+ readonly rangeServedMap: Map<string, string>;
519
+ constructor(message: string, rangeHashes: string[], rangeServedMap: Map<string, string>) {
512
520
  super(message);
513
521
  this.name = "RangeStaleError";
514
522
  this.rangeHashes = rangeHashes;
523
+ this.rangeServedMap = rangeServedMap;
515
524
  }
516
525
  }
517
526
 
518
527
  export class AnchorMismatchError extends Error {
519
528
  readonly feedbackHashes: string[];
520
- constructor(message: string, feedbackHashes: string[]) {
529
+ readonly feedbackMap: Map<string, string>;
530
+ constructor(message: string, feedbackHashes: string[], feedbackMap: Map<string, string>) {
521
531
  super(message);
522
532
  this.name = "AnchorMismatchError";
523
533
  this.feedbackHashes = feedbackHashes;
534
+ this.feedbackMap = feedbackMap;
524
535
  }
525
536
  }
526
537
 
@@ -528,7 +539,7 @@ export function assertRangeServed(
528
539
  resolved: RHEdit,
529
540
  fileLines: string[],
530
541
  fileHashes: string[],
531
- served: ReadonlySet<string>,
542
+ served: ReadonlyMap<string, string> | undefined,
532
543
  filePath?: string,
533
544
  ): void {
534
545
  assertAligned(fileLines, fileHashes, "assertRangeServed");
@@ -536,18 +547,23 @@ export function assertRangeServed(
536
547
  const endLine = resolved.hash_bounds[1].line;
537
548
  const mismatchLines: number[] = [];
538
549
  for (let line = startLine; line <= endLine; line++) {
539
- if (!served.has(fileHashes[line - 1]!)) mismatchLines.push(line);
550
+ const hash = fileHashes[line - 1]!;
551
+ const content = fileLines[line - 1]!;
552
+ const servedContent = served?.get(hash);
553
+ if (servedContent === undefined || servedContent !== contentChecksum(content)) mismatchLines.push(line);
540
554
  }
541
555
  if (mismatchLines.length === 0) return;
542
-
543
556
  const rangeLength = endLine - startLine + 1;
544
557
  const shownLength = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
545
558
  const rows: string[] = [];
546
559
  const shownHashes: string[] = [];
560
+ const shownMap = new Map<string, string>();
547
561
  for (let line = startLine; line < startLine + shownLength; line++) {
548
562
  const hash = fileHashes[line - 1]!;
563
+ const content = fileLines[line - 1]!;
549
564
  shownHashes.push(hash);
550
- rows.push(fmtRow(hash, clipLine(fileLines[line - 1])));
565
+ shownMap.set(hash, contentChecksum(content));
566
+ rows.push(fmtRow(hash, clipLine(content)));
551
567
  }
552
568
  const location = filePath ? ` in ${filePath}` : "";
553
569
  const first = mismatchLines[0]!;
@@ -561,7 +577,7 @@ export function assertRangeServed(
561
577
  : "";
562
578
  const message =
563
579
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
564
- throw new RangeStaleError(message, shownHashes);
580
+ throw new RangeStaleError(message, shownHashes, shownMap);
565
581
  }
566
582
 
567
583
  export { warnUnicodeEsc };
package/src/read.ts CHANGED
@@ -13,8 +13,8 @@ import { MAX_OVERSIZED_WARNING_LINES } from "./constants";
13
13
  import { readNormFile, safeSnapId } from "./file-reader";
14
14
  import { lineHashes, fmtRegion, fmtRow, HASH_SEP, MAX_HASH_LINES } from "./hashline";
15
15
  import { toCwd } from "./paths";
16
- import { abortIf, makePrepareArguments, numberedRead, visLines } from "./utils";
17
- import { recordServedSafe } from "./served";
16
+ import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } from "./utils";
17
+ import { recordServedSafe, buildServedMap } from "./served";
18
18
  import { loadP, loadGuide } from "./prompts";
19
19
  import { valAccess } from "./validation";
20
20
  import { Text } from "@earendil-works/pi-tui";
@@ -236,7 +236,9 @@ export function regRead(pi: ExtensionAPI): void {
236
236
  fileHashes,
237
237
  resolvedPath,
238
238
  );
239
- await recordServedSafe(resolvedPath, preview.servedHashes, "read", new Set(fileHashes));
239
+ const fileLines = splitLines(normalized);
240
+ const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
241
+ await recordServedSafe(resolvedPath, servedMap, "read", new Set(fileHashes));
240
242
  const snapshotId = await safeSnapId(absolutePath, "read");
241
243
  const previewText =
242
244
  hadUtf8DecodeErrors
@@ -4,11 +4,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
7
- import { recordServedDiff } from "./served";
7
+ import { recordServed, buildServedMap, servedHashesFromDiff } from "./served";
8
8
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
9
9
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
10
10
  import { genDiff, genPatch } from "./replace-diff";
11
- import { cntDiff, errCode, makePrepareArguments } from "./utils";
11
+ import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
14
  import { renderEditResult } from "./replace-render";
@@ -172,7 +172,10 @@ export function regUndo(pi: ExtensionAPI): void {
172
172
  try {
173
173
  const store = await loadHashStore();
174
174
  persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
175
- recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
175
+ const diffHashes = servedHashesFromDiff(undoDiff);
176
+ const undoLines = splitLines(undo.content);
177
+ const servedMap = buildServedMap(undo.hashes, undoLines, diffHashes);
178
+ recordServed(store, mutationTargetPath, servedMap, new Set(undo.hashes));
176
179
  } catch (error) {
177
180
  console.error("Failed to restore hash store snapshot after undo:", error);
178
181
  }
package/src/replace.ts CHANGED
@@ -119,8 +119,8 @@ function hashSpan(hashes: string[], from: string, to: string): [number, number]
119
119
  }
120
120
  async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
121
121
  if (noPersist === true) return;
122
- if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
123
- else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
122
+ if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeServedMap, "range-stale feedback", new Set(scopeHashes));
123
+ else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackMap, "anchor-mismatch feedback", new Set(scopeHashes));
124
124
  }
125
125
 
126
126
  function collectRemovedHashes(
package/src/served.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { loadHashStore, parseStoredHashes, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
1
+ import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
+ import { contentChecksum } from "./hashline/hasher";
3
4
 
4
5
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
5
6
 
@@ -12,60 +13,88 @@ export function servedHashesFromDiff(diff: string): string[] {
12
13
  return hashes;
13
14
  }
14
15
 
15
- export function getServed(store: HashStore, path: string): Set<string> | undefined {
16
+ export function servedMapFromDiff(diff: string): Map<string, string> {
17
+ const map = new Map<string, string>();
18
+ for (const line of diff.split("\n")) {
19
+ const match = SERVED_DIFF_ROW_RE.exec(line);
20
+ if (!match) continue;
21
+ const hash = match[1]!;
22
+ const content = line.slice(match[0].length);
23
+ map.set(hash, contentChecksum(content));
24
+ }
25
+ return map;
26
+ }
27
+
28
+ export function buildServedMap(fileHashes: string[], fileLines: string[], wantedHashes: string[]): Map<string, string> {
29
+ const index = new Map<string, number>();
30
+ for (let i = 0; i < fileHashes.length; i++) index.set(fileHashes[i]!, i);
31
+ const map = new Map<string, string>();
32
+ for (const h of wantedHashes) {
33
+ const idx = index.get(h);
34
+ if (idx !== undefined) map.set(h, contentChecksum(fileLines[idx]!));
35
+ }
36
+ return map;
37
+ }
38
+
39
+ export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
16
40
  const row = store.stmts.servedGet(path);
17
- const parsed = parseStoredHashes(row, () => store.stmts.servedDelete(path));
41
+ const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
18
42
  if (!parsed) return undefined;
19
- return new Set(parsed);
43
+ return parsed;
20
44
  }
21
45
 
22
46
  function computeUpdate(
23
47
  store: HashStore,
24
48
  path: string,
25
- hashes: string[],
49
+ entries: Map<string, string>,
26
50
  scope?: ReadonlySet<string>,
27
- ): Set<string> | undefined {
51
+ ): Map<string, string> | undefined {
28
52
  const existing = getServed(store, path);
29
- if (!existing && hashes.length === 0) return undefined;
30
- const set = existing ?? new Set<string>();
53
+ if (!existing && entries.size === 0 && !scope) return undefined;
54
+ const map = existing ? new Map(existing) : new Map<string, string>();
31
55
  let changed = false;
32
56
  if (scope) {
33
- for (const hash of set) {
57
+ for (const hash of [...map.keys()]) {
34
58
  if (!scope.has(hash)) {
35
- set.delete(hash);
59
+ map.delete(hash);
36
60
  changed = true;
37
61
  }
38
62
  }
39
63
  }
40
- for (const hash of hashes) {
41
- if (!set.has(hash)) {
42
- set.add(hash);
64
+ for (const [hash, content] of entries) {
65
+ const prev = map.get(hash);
66
+ if (prev !== content) {
67
+ map.set(hash, content);
43
68
  changed = true;
44
69
  }
45
70
  }
46
- if (!changed) return undefined;
47
- return set;
71
+ if (!changed && existing) return undefined;
72
+ if (map.size === 0 && (!existing || existing.size === 0)) return undefined;
73
+ if (!changed) return existing;
74
+ return map;
48
75
  }
49
76
 
50
77
  export function recordServed(
51
78
  store: HashStore,
52
79
  path: string,
53
- hashes: string[],
80
+ entries: Map<string, string>,
54
81
  scope?: ReadonlySet<string>,
55
82
  ): void {
56
83
  try {
57
84
  withStore(() => {
58
- const set = computeUpdate(store, path, hashes, scope);
59
- if (!set) return;
60
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
85
+ const map = computeUpdate(store, path, entries, scope);
86
+ if (!map) return;
87
+ const obj = Object.fromEntries(map);
88
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
61
89
  });
62
90
  return;
63
91
  } catch (error) {
64
92
  if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
65
93
  }
66
- const set = computeUpdate(store, path, hashes, scope);
67
- if (!set) return;
68
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
94
+ const map = computeUpdate(store, path, entries, scope);
95
+ if (!map) return;
96
+ const obj = Object.fromEntries(map);
97
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
69
98
  }
70
99
 
71
100
  export function recordServedDiff(
@@ -74,7 +103,7 @@ export function recordServedDiff(
74
103
  diff: string,
75
104
  scope?: ReadonlySet<string>,
76
105
  ): void {
77
- recordServed(store, path, servedHashesFromDiff(diff), scope);
106
+ recordServed(store, path, servedMapFromDiff(diff), scope);
78
107
  }
79
108
 
80
109
  export function clearServed(store: HashStore, path: string): void {
@@ -83,14 +112,14 @@ export function clearServed(store: HashStore, path: string): void {
83
112
 
84
113
  export async function recordServedSafe(
85
114
  path: string,
86
- hashes: string[],
115
+ entries: Map<string, string>,
87
116
  context: string,
88
117
  scope?: ReadonlySet<string>,
89
118
  ): Promise<void> {
90
- if (hashes.length === 0 && !scope) return;
119
+ if (entries.size === 0 && !scope) return;
91
120
  try {
92
121
  const store = await loadHashStore();
93
- recordServed(store, path, hashes, scope);
122
+ recordServed(store, path, entries, scope);
94
123
  } catch (error) {
95
124
  console.error(`Failed to record served state (${context}):`, error);
96
125
  }
@@ -103,5 +132,5 @@ export async function recordServedDiffSafe(
103
132
  scope?: ReadonlySet<string>,
104
133
  ): Promise<void> {
105
134
  if (!diff) return;
106
- await recordServedSafe(path, servedHashesFromDiff(diff), context, scope);
135
+ await recordServedSafe(path, servedMapFromDiff(diff), context, scope);
107
136
  }
package/src/write-hook.ts CHANGED
@@ -8,19 +8,19 @@ import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
8
8
 
9
9
  const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
10
10
 
11
- function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
11
+ function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
12
12
  for (let i = 0; i < lines.length; i++) {
13
13
  const match = HASH_ECHO_RE.exec(lines[i]!);
14
- if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
14
+ if (match && served.has(match[1]! as never)) return { line: i + 1, hash: match[1]! };
15
15
  }
16
16
  return undefined;
17
17
  }
18
18
 
19
- export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
19
+ export function findServedHashEcho(content: string, served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
20
20
  return searchEcho(splitLines(content), served);
21
21
  }
22
22
 
23
- export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
23
+ export function findEditHashEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
24
24
  return searchEcho(lines, served);
25
25
  }
26
26