pi-hashline-edit-pro 2.8.0 → 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/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;
@@ -47,6 +47,7 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
47
47
  }
48
48
 
49
49
  function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
50
+ if (!literal) assertSafeRegex(pattern);
50
51
  const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
51
52
  try {
52
53
  return new RegExp(source, ignoreCase ? "ui" : "u");
@@ -55,6 +56,96 @@ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): Reg
55
56
  }
56
57
  }
57
58
 
59
+ interface RegexGroupRisk {
60
+ hasQuantifier: boolean;
61
+ hasAlternation: boolean;
62
+ }
63
+
64
+ function unsafeRegex(pattern: string): never {
65
+ throw new Error(
66
+ `[E_UNSAFE_REGEX] Refusing potentially exponential regex: ${pattern}. Use literal: true or simplify the expression.`,
67
+ );
68
+ }
69
+
70
+ function assertSafeRegex(pattern: string): void {
71
+ if (pattern.length > 4096) unsafeRegex(pattern);
72
+ const groups: RegexGroupRisk[] = [];
73
+ let inClass = false;
74
+ let escaped = false;
75
+ let variableQuantifiers = 0;
76
+ let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
77
+ for (let i = 0; i < pattern.length; i++) {
78
+ const ch = pattern[i]!;
79
+ if (escaped) {
80
+ if (!inClass && (/[1-9]/.test(ch) || (ch === "k" && pattern[i + 1] === "<"))) {
81
+ unsafeRegex(pattern);
82
+ }
83
+ escaped = false;
84
+ lastAtom = { groupRisky: false, quantified: false };
85
+ continue;
86
+ }
87
+ if (ch === "\\") {
88
+ escaped = true;
89
+ continue;
90
+ }
91
+ if (inClass) {
92
+ if (ch === "]") {
93
+ inClass = false;
94
+ lastAtom = { groupRisky: false, quantified: false };
95
+ }
96
+ continue;
97
+ }
98
+ if (ch === "[") {
99
+ inClass = true;
100
+ continue;
101
+ }
102
+ if (ch === "(") {
103
+ groups.push({ hasQuantifier: false, hasAlternation: false });
104
+ lastAtom = undefined;
105
+ continue;
106
+ }
107
+ if (ch === ")") {
108
+ const group = groups.pop();
109
+ if (group) {
110
+ lastAtom = {
111
+ groupRisky: group.hasQuantifier || group.hasAlternation,
112
+ quantified: false,
113
+ };
114
+ }
115
+ continue;
116
+ }
117
+ if (ch === "|") {
118
+ const group = groups.at(-1);
119
+ if (group) group.hasAlternation = true;
120
+ lastAtom = undefined;
121
+ continue;
122
+ }
123
+ let quantifierLength = 0;
124
+ if (ch === "*" || ch === "+" || ch === "?") {
125
+ quantifierLength = 1;
126
+ } else if (ch === "{") {
127
+ quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
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
+ }
134
+ if (quantifierLength > 0 && lastAtom) {
135
+ if (ch === "?" && lastAtom.quantified) continue;
136
+ const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
137
+ if (variable && ++variableQuantifiers > 1) unsafeRegex(pattern);
138
+ if (lastAtom.groupRisky) unsafeRegex(pattern);
139
+ const group = groups.at(-1);
140
+ if (group) group.hasQuantifier = true;
141
+ lastAtom.quantified = true;
142
+ i += quantifierLength - 1;
143
+ continue;
144
+ }
145
+ lastAtom = { groupRisky: false, quantified: false };
146
+ }
147
+ }
148
+
58
149
  function globToRegex(glob: string): RegExp {
59
150
  if (glob.startsWith("/")) glob = glob.slice(1);
60
151
  let source = "";
@@ -87,8 +178,10 @@ interface FileHit {
87
178
  path: string;
88
179
  displayPath: string;
89
180
  fileHashes: string[];
181
+ fileLines: string[];
90
182
  rows: string[];
91
183
  hashes: string[];
184
+ lineNumbers: number[];
92
185
  matchCount: number;
93
186
  totalMatchCount: number;
94
187
  fragmented: boolean[];
@@ -129,89 +222,41 @@ function grepHeadFragment(line: string): string {
129
222
  return head.length < line.length ? `${head}...` : head;
130
223
  }
131
224
 
132
- interface ScanState {
133
- scanned: number;
134
- stopped: boolean;
135
- }
136
-
137
- async function walkFiles(
138
- root: string,
139
- state: ScanState,
140
- onFile: (absPath: string) => Promise<void>,
141
- ): Promise<void> {
142
- const queue: string[] = [root];
143
- let head = 0;
144
- while (head < queue.length && !state.stopped) {
145
- const dir = queue[head++]!;
146
- let entries;
147
- try {
148
- entries = await readdir(dir, { withFileTypes: true });
149
- } catch {
150
- continue;
151
- }
152
- entries.sort((a, b) => cmp(a.name, b.name));
153
- for (const entry of entries) {
154
- if (state.stopped) break;
155
- const full = join(dir, entry.name);
156
- if (entry.isDirectory()) {
157
- if (SKIP_DIRS.has(entry.name)) continue;
158
- queue.push(full);
159
- } else if (entry.isFile()) {
160
- state.scanned += 1;
161
- if (state.scanned > MAX_SCAN_FILES) {
162
- state.stopped = true;
163
- break;
164
- }
165
- await onFile(full);
166
- }
167
- }
168
- }
169
- }
170
-
171
- async function searchFile(
172
- absPath: string,
173
- globRoot: string,
174
- cwd: string,
175
- regex: RegExp,
176
- globRegex: RegExp | undefined,
225
+ function makeHitFromIndices(
226
+ norm: { normalized: string; fileHashes: string[]; absolutePath: string },
227
+ displayPath: string,
228
+ matchIndices: number[],
177
229
  context: number,
178
- maxMatches: number,
179
- ): Promise<FileHit | undefined> {
180
- const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
181
- if (globRegex) {
182
- const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
183
- if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
184
- }
185
- const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true });
186
- if (!norm) return undefined;
230
+ regex: RegExp | undefined,
231
+ totalMatchCount: number,
232
+ keptMatchCount: number,
233
+ ): FileHit {
187
234
  const lines = visLines(norm.normalized);
188
- const matchLines: number[] = [];
189
- for (let i = 0; i < lines.length; i++) {
190
- if (regex.test(lines[i]!)) matchLines.push(i);
191
- }
192
- if (matchLines.length === 0) return undefined;
193
- const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
194
235
  const shown = new Set<number>();
195
- for (const i of keptMatches) {
236
+ const kept = matchIndices.slice(0, keptMatchCount);
237
+ for (const i of kept) {
196
238
  for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
197
239
  }
198
240
  const sorted = [...shown].sort((a, b) => a - b);
199
- const matchSet = new Set(matchLines);
241
+ const matchSet = new Set(matchIndices);
200
242
  const rows: string[] = [];
201
243
  const hashes: string[] = [];
244
+ const lineNumbers: number[] = [];
202
245
  const fragmented: boolean[] = [];
203
246
  for (const idx of sorted) {
204
247
  const hash = norm.fileHashes[idx]!;
205
248
  const line = lines[idx]!;
206
249
  const row = fmtRow(hash, line);
207
250
  if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
208
- const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
251
+ const content = matchSet.has(idx) && regex ? grepMatchFragment(line, regex) : grepHeadFragment(line);
209
252
  rows.push(fmtRow(hash, content));
210
253
  hashes.push(hash);
254
+ lineNumbers.push(idx + 1);
211
255
  fragmented.push(true);
212
256
  } else {
213
257
  rows.push(row);
214
258
  hashes.push(hash);
259
+ lineNumbers.push(idx + 1);
215
260
  fragmented.push(false);
216
261
  }
217
262
  }
@@ -219,14 +264,142 @@ async function searchFile(
219
264
  path: norm.absolutePath,
220
265
  displayPath,
221
266
  fileHashes: norm.fileHashes,
267
+ fileLines: lines,
222
268
  rows,
223
269
  hashes,
224
- matchCount: keptMatches.length,
225
- totalMatchCount: matchLines.length,
270
+ lineNumbers,
271
+ matchCount: kept.length,
272
+ totalMatchCount,
226
273
  fragmented,
227
274
  };
228
275
  }
229
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
+
230
403
  const grepToolSchema = Type.Object(
231
404
  {
232
405
  pattern: Type.String({
@@ -283,10 +456,8 @@ export function regGrep(pi: ExtensionAPI): void {
283
456
  const canonical = normReq(params);
284
457
  assertGrepReq(canonical);
285
458
  const req = canonical;
286
- const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
287
459
  const context = req.context ?? 0;
288
460
  const limit = req.limit ?? 100;
289
- const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
290
461
  const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
291
462
  abortIf(signal);
292
463
  let baseStat;
@@ -299,16 +470,9 @@ export function regGrep(pi: ExtensionAPI): void {
299
470
  throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
300
471
  }
301
472
  const globRoot = baseStat.isFile() ? dirname(base) : base;
302
- const state: ScanState = { scanned: 0, stopped: false };
303
- const files: string[] = [];
304
- if (baseStat.isFile()) {
305
- files.push(base);
306
- } else {
307
- await walkFiles(base, state, async (absPath) => {
308
- files.push(absPath);
309
- });
310
- files.sort(cmp);
311
- }
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();
312
476
  const hits: FileHit[] = [];
313
477
  let matches = 0;
314
478
  let limitTruncated = false;
@@ -320,17 +484,26 @@ export function regGrep(pi: ExtensionAPI): void {
320
484
  let truncatedBy: "lines" | "bytes" | null = null;
321
485
  let linesReplaced = 0;
322
486
  let countOnly = false;
323
- 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++) {
324
490
  abortIf(signal);
325
- 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);
326
496
  if (countOnly) {
327
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, Number.MAX_SAFE_INTEGER);
328
- if (!hit) continue;
329
- totalRows += hit.rows.length;
330
- 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;
331
503
  const remaining = limit - matches;
332
504
  if (remaining > 0) {
333
- matches += Math.min(hit.matchCount, remaining);
505
+ const add = Math.min(hit.matchCount, remaining);
506
+ matches += add;
334
507
  if (hit.matchCount > remaining) limitTruncated = true;
335
508
  } else {
336
509
  limitTruncated = true;
@@ -342,24 +515,36 @@ export function regGrep(pi: ExtensionAPI): void {
342
515
  limitTruncated = true;
343
516
  break;
344
517
  }
345
- const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
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));
346
526
  if (!hit) continue;
527
+ const display = displayRowsForHit(hit);
347
528
  const keptRows: string[] = [];
348
529
  const keptHashes: string[] = [];
349
- for (let i = 0; i < hit.rows.length; i++) {
350
- 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]!;
351
534
  const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
352
535
  if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
353
536
  rowTruncated = true;
354
537
  if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
355
- for (let j = i; j < hit.rows.length; j++) {
538
+ for (let j = i; j < display.length; j++) {
356
539
  totalRows += 1;
357
- totalBytes += Buffer.byteLength(hit.rows[j]!, "utf-8") + 1;
540
+ totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
358
541
  }
359
542
  break;
360
543
  }
361
544
  keptRows.push(row);
362
- keptHashes.push(hit.hashes[i]);
545
+ keptHashes.push(hit.hashes[i]!);
546
+ keptLineNumbers.push(hit.lineNumbers[i]!);
547
+ keptFragmented.push(hit.fragmented[i]!);
363
548
  if (hit.fragmented[i]) linesReplaced += 1;
364
549
  rowCount += 1;
365
550
  byteCount += rowBytes;
@@ -368,12 +553,14 @@ export function regGrep(pi: ExtensionAPI): void {
368
553
  }
369
554
  if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
370
555
  matches += hit.matchCount;
371
- hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
556
+ const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
557
+ hits.push(displayHit);
372
558
  if (rowTruncated) countOnly = true;
373
559
  }
374
560
  hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
375
561
  for (const hit of hits) {
376
- 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));
377
564
  }
378
565
  const blocks = hits
379
566
  .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
@@ -381,7 +568,6 @@ export function regGrep(pi: ExtensionAPI): void {
381
568
  const notes: string[] = [];
382
569
  if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
383
570
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
384
- if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
385
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.]`);
386
572
  const truncated = limitTruncated || rowTruncated;
387
573
  const truncation: TruncationResult | undefined = rowTruncated
@@ -408,7 +594,7 @@ export function regGrep(pi: ExtensionAPI): void {
408
594
  metrics: {
409
595
  matches,
410
596
  files: hits.length,
411
- truncated: truncated || state.stopped,
597
+ truncated,
412
598
  },
413
599
  },
414
600
  };
@@ -0,0 +1,18 @@
1
+ export interface SnapshotCacheEntry {
2
+ checksum: string;
3
+ lineCount: number;
4
+ hashes: string[];
5
+ }
6
+
7
+ export const SNAPSHOT_CACHE_LIMIT = 256;
8
+
9
+ export const snapshotCache = new Map<string, SnapshotCacheEntry>();
10
+
11
+ export function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
12
+ snapshotCache.delete(path);
13
+ snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
14
+ if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
15
+ const oldest = snapshotCache.keys().next().value;
16
+ if (oldest !== undefined) snapshotCache.delete(oldest);
17
+ }
18
+ }
@@ -0,0 +1,48 @@
1
+ import { isBusyError } from "./validation";
2
+
3
+ const sleepSab = new Int32Array(new SharedArrayBuffer(4));
4
+
5
+ function sleepSync(ms: number): void {
6
+ Atomics.wait(sleepSab, 0, 0, ms);
7
+ }
8
+
9
+ const BUSY_RETRIES = 3;
10
+ const BUSY_RETRY_DELAY_MS = 50;
11
+
12
+ export function withBusyRetry<T>(fn: () => T): T {
13
+ let lastError: unknown;
14
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
15
+ try {
16
+ return fn();
17
+ } catch (error) {
18
+ lastError = error;
19
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
20
+ sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
21
+ }
22
+ }
23
+ throw lastError;
24
+ }
25
+
26
+ export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
27
+ let lastError: unknown;
28
+ for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
29
+ try {
30
+ return fn();
31
+ } catch (error) {
32
+ lastError = error;
33
+ if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
34
+ await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
35
+ }
36
+ }
37
+ throw lastError;
38
+ }
39
+
40
+ export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
41
+ return (...params) => {
42
+ withBusyRetry(() => { stmt.run(...params); });
43
+ };
44
+ }
45
+
46
+ export async function openDbWithBusyRetryAsync<T>(fn: () => T): Promise<T> {
47
+ return withBusyRetryAsync(fn);
48
+ }
@@ -0,0 +1,93 @@
1
+ import { HASH_RE } from "../hashline/alphabet";
2
+
3
+ export function isValidHashList(value: unknown): value is string[] {
4
+ if (!Array.isArray(value)) return false;
5
+ for (const hash of value) {
6
+ if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
7
+ }
8
+ if (new Set(value).size !== value.length) return false;
9
+ return true;
10
+ }
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
+
21
+ export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ } catch (error) {
26
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
27
+ onInvalid();
28
+ return undefined;
29
+ }
30
+ if (!isValidHashList(parsed)) {
31
+ console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
32
+ onInvalid();
33
+ return undefined;
34
+ }
35
+ return parsed;
36
+ }
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
+
55
+ export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
56
+ if (!row) return undefined;
57
+ return parseHashList(row.hashes as string, onInvalid);
58
+ }
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
+
65
+ export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
66
+ if (typeof value !== "object" || value === null) return false;
67
+ const v = value as Record<string, unknown>;
68
+ if (typeof v.content !== "string") return false;
69
+ return isValidHashList(v.hashes);
70
+ }
71
+
72
+ export function isCorruptionError(error: unknown): boolean {
73
+ if (error && typeof error === "object") {
74
+ const errcode = (error as { errcode?: unknown }).errcode;
75
+ if (typeof errcode === "number") {
76
+ return errcode === 11 || errcode === 24 || errcode === 26;
77
+ }
78
+ const code = (error as { code?: unknown }).code;
79
+ if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
80
+ }
81
+ return (
82
+ error instanceof Error &&
83
+ /corrupt|not a database|malformed|database disk image/i.test(error.message)
84
+ );
85
+ }
86
+
87
+ export function isBusyError(error: unknown): boolean {
88
+ if (error && typeof error === "object") {
89
+ const errcode = (error as { errcode?: unknown }).errcode;
90
+ if (typeof errcode === "number") return errcode === 5 || errcode === 6;
91
+ }
92
+ return error instanceof Error && /busy|locked/i.test(error.message);
93
+ }