pi-hashline-edit-pro 2.6.5 → 2.7.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/src/grep.ts ADDED
@@ -0,0 +1,323 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { readdir, stat } from "fs/promises";
4
+ import { dirname, join, relative } from "path";
5
+ import { loadFileKindAndText } from "./file-kind";
6
+ import { readNormFile } from "./file-reader";
7
+ import { MAX_HASH_LINES, fmtRow } from "./hashline";
8
+ import { toCwd } from "./paths";
9
+ import { loadP, loadGuide } from "./prompts";
10
+ import { normReq } from "./replace-normalize";
11
+ import { recordServedSafe } from "./served";
12
+ import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
13
+
14
+ const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
15
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
16
+ const MAX_SCAN_FILES = 4000;
17
+ const MAX_SHOWN_ROWS = 2000;
18
+
19
+ export interface GrepReq {
20
+ pattern: string;
21
+ path?: string;
22
+ glob?: string;
23
+ context?: number;
24
+ ignoreCase?: boolean;
25
+ literal?: boolean;
26
+ limit?: number;
27
+ }
28
+
29
+ export function assertGrepReq(request: unknown): asserts request is GrepReq {
30
+ if (!isRec(request)) {
31
+ throw new Error("[E_BAD_SHAPE] Grep request must be an object.");
32
+ }
33
+ rejectUnknownFields(request, GREP_KS, "Grep request");
34
+ if (typeof request.pattern !== "string" || request.pattern.length === 0) {
35
+ throw new Error('[E_BAD_SHAPE] Grep request requires a non-empty "pattern" string.');
36
+ }
37
+ if (request.context !== undefined && (typeof request.context !== "number" || !Number.isInteger(request.context) || request.context < 0)) {
38
+ throw new Error('[E_BAD_SHAPE] Grep request field "context" must be a non-negative integer.');
39
+ }
40
+ if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isInteger(request.limit) || request.limit < 1)) {
41
+ throw new Error('[E_BAD_SHAPE] Grep request field "limit" must be a positive integer.');
42
+ }
43
+ }
44
+
45
+ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
46
+ const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
47
+ try {
48
+ return new RegExp(source, ignoreCase ? "ui" : "u");
49
+ } catch {
50
+ throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
51
+ }
52
+ }
53
+
54
+ function globToRegex(glob: string): RegExp {
55
+ let source = "";
56
+ let i = 0;
57
+ while (i < glob.length) {
58
+ const ch = glob[i]!;
59
+ if (ch === "*") {
60
+ if (glob[i + 1] === "*") {
61
+ i += 2;
62
+ if (glob[i] === "/") {
63
+ i += 1;
64
+ source += "(?:.*\\/)?";
65
+ } else {
66
+ source += ".*";
67
+ }
68
+ continue;
69
+ }
70
+ source += ".*";
71
+ } else if (ch === "?") {
72
+ source += "[^/]";
73
+ } else {
74
+ source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+ i += 1;
77
+ }
78
+ return new RegExp(`^${source}$`);
79
+ }
80
+
81
+ function isSkipableLoadError(error: unknown): boolean {
82
+ const code = errCode(error);
83
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
84
+ return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
85
+ }
86
+
87
+ interface FileHit {
88
+ path: string;
89
+ displayPath: string;
90
+ fileHashes: string[];
91
+ rows: string[];
92
+ hashes: string[];
93
+ matchCount: number;
94
+ totalMatchCount: number;
95
+ }
96
+
97
+ interface ScanState {
98
+ scanned: number;
99
+ stopped: boolean;
100
+ }
101
+
102
+ async function walkFiles(
103
+ root: string,
104
+ state: ScanState,
105
+ onFile: (absPath: string) => Promise<void>,
106
+ ): Promise<void> {
107
+ const queue: string[] = [root];
108
+ while (queue.length > 0 && !state.stopped) {
109
+ const dir = queue.pop()!;
110
+ let entries;
111
+ try {
112
+ entries = await readdir(dir, { withFileTypes: true });
113
+ } catch {
114
+ continue;
115
+ }
116
+ for (const entry of entries) {
117
+ if (state.stopped) break;
118
+ const full = join(dir, entry.name);
119
+ if (entry.isDirectory()) {
120
+ if (SKIP_DIRS.has(entry.name)) continue;
121
+ queue.push(full);
122
+ } else if (entry.isFile()) {
123
+ state.scanned += 1;
124
+ if (state.scanned > MAX_SCAN_FILES) {
125
+ state.stopped = true;
126
+ break;
127
+ }
128
+ await onFile(full);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ async function searchFile(
135
+ absPath: string,
136
+ globRoot: string,
137
+ cwd: string,
138
+ regex: RegExp,
139
+ globRegex: RegExp | undefined,
140
+ context: number,
141
+ maxMatches: number,
142
+ ): Promise<FileHit | undefined> {
143
+ const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
144
+ if (globRegex) {
145
+ const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
146
+ if (!globRegex.test(globPath)) return undefined;
147
+ }
148
+ let file;
149
+ try {
150
+ file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
151
+ } catch (error) {
152
+ if (isSkipableLoadError(error)) return undefined;
153
+ throw error;
154
+ }
155
+ if (file.kind !== "text") return undefined;
156
+ let norm;
157
+ try {
158
+ norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file, noPersist: true });
159
+ } catch (error) {
160
+ if (isSkipableLoadError(error)) return undefined;
161
+ throw error;
162
+ }
163
+ const lines = visLines(norm.normalized);
164
+ const matchLines: number[] = [];
165
+ for (let i = 0; i < lines.length; i++) {
166
+ if (regex.test(lines[i]!)) matchLines.push(i);
167
+ }
168
+ if (matchLines.length === 0) return undefined;
169
+ const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
170
+ const shown = new Set<number>();
171
+ for (const i of keptMatches) {
172
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
173
+ }
174
+ const sorted = [...shown].sort((a, b) => a - b);
175
+ const rows: string[] = [];
176
+ const hashes: string[] = [];
177
+ for (const idx of sorted) {
178
+ rows.push(fmtRow(norm.fileHashes[idx]!, lines[idx]!));
179
+ hashes.push(norm.fileHashes[idx]!);
180
+ }
181
+ return {
182
+ path: norm.absolutePath,
183
+ displayPath,
184
+ fileHashes: norm.fileHashes,
185
+ rows,
186
+ hashes,
187
+ matchCount: keptMatches.length,
188
+ totalMatchCount: matchLines.length,
189
+ };
190
+ }
191
+
192
+ const grepToolSchema = Type.Object(
193
+ {
194
+ pattern: Type.String({
195
+ description: "Search pattern (regex or literal string)",
196
+ }),
197
+ path: Type.Optional(
198
+ Type.String({
199
+ description: "Directory or file to search (default: current directory)",
200
+ }),
201
+ ),
202
+ glob: Type.Optional(
203
+ Type.String({
204
+ description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
205
+ }),
206
+ ),
207
+ ignoreCase: Type.Optional(
208
+ Type.Boolean({
209
+ description: "Case-insensitive search (default: false)",
210
+ }),
211
+ ),
212
+ literal: Type.Optional(
213
+ Type.Boolean({
214
+ description: "Treat pattern as literal string instead of regex (default: false)",
215
+ }),
216
+ ),
217
+ context: Type.Optional(
218
+ Type.Integer({
219
+ minimum: 0,
220
+ description: "Number of lines to show before and after each match (default: 0)",
221
+ }),
222
+ ),
223
+ limit: Type.Optional(
224
+ Type.Integer({
225
+ minimum: 1,
226
+ description: "Maximum number of matches to return (default: 100)",
227
+ }),
228
+ ),
229
+ },
230
+ { additionalProperties: false },
231
+ );
232
+
233
+ export function regGrep(pi: ExtensionAPI): void {
234
+ pi.registerTool({
235
+ name: "grep",
236
+ label: "Grep",
237
+ description: loadP("../prompts/grep.md"),
238
+ promptSnippet: loadP("../prompts/grep-snippet.md"),
239
+ promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
240
+ prepareArguments: makePrepareArguments(),
241
+ parameters: grepToolSchema,
242
+
243
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
244
+ const canonical = normReq(params);
245
+ assertGrepReq(canonical);
246
+ const req = canonical;
247
+ const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
248
+ const context = req.context ?? 0;
249
+ const limit = req.limit ?? 100;
250
+ const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
251
+ const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
252
+ abortIf(signal);
253
+ let baseStat;
254
+ try {
255
+ baseStat = await stat(base);
256
+ } catch (error) {
257
+ if (errCode(error) === "ENOENT") {
258
+ throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
259
+ }
260
+ throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
261
+ }
262
+ const globRoot = baseStat.isFile() ? dirname(base) : base;
263
+ const state: ScanState = { scanned: 0, stopped: false };
264
+ const files: string[] = [];
265
+ if (baseStat.isFile()) {
266
+ files.push(base);
267
+ } else {
268
+ await walkFiles(base, state, async (absPath) => {
269
+ files.push(absPath);
270
+ });
271
+ }
272
+ const hits: FileHit[] = [];
273
+ let matches = 0;
274
+ let limitTruncated = false;
275
+ let rowTruncated = false;
276
+ let rowCount = 0;
277
+ for (const absPath of files) {
278
+ abortIf(signal);
279
+ const remaining = limit - matches;
280
+ if (remaining <= 0) {
281
+ limitTruncated = true;
282
+ break;
283
+ }
284
+ const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
285
+ if (!hit) continue;
286
+ const rowBudget = MAX_SHOWN_ROWS - rowCount;
287
+ if (rowBudget <= 0) {
288
+ rowTruncated = true;
289
+ break;
290
+ }
291
+ const keptRows = hit.rows.slice(0, rowBudget);
292
+ const keptHashes = hit.hashes.slice(0, rowBudget);
293
+ rowCount += keptRows.length;
294
+ if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
295
+ if (keptRows.length < hit.rows.length) rowTruncated = true;
296
+ matches += hit.matchCount;
297
+ hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
298
+ }
299
+ for (const hit of hits) {
300
+ await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
301
+ }
302
+ const blocks = hits
303
+ .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
304
+ .join("\n");
305
+ const notes: string[] = [];
306
+ if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
307
+ if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
308
+ if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
309
+ const truncated = limitTruncated || rowTruncated;
310
+ const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
311
+ return {
312
+ content: [{ type: "text", text }],
313
+ details: {
314
+ metrics: {
315
+ matches,
316
+ files: hits.length,
317
+ truncated: truncated || state.stopped,
318
+ },
319
+ },
320
+ };
321
+ },
322
+ });
323
+ }
package/src/hash-store.ts CHANGED
@@ -66,6 +66,7 @@ interface Prepared {
66
66
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
67
67
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
68
68
  allHashes: (...params: SqlParams) => Record<string, unknown>[];
69
+ allServed: (...params: SqlParams) => Record<string, unknown>[];
69
70
  deleteOne: (...params: SqlParams) => void;
70
71
  upsert: (...params: SqlParams) => void;
71
72
  undoUpsert: (...params: SqlParams) => void;
@@ -118,6 +119,14 @@ export function parseHashList(raw: string, onInvalid: () => void): string[] | un
118
119
  return parsed;
119
120
  }
120
121
 
122
+ export function parseStoredHashes(
123
+ row: Record<string, unknown> | undefined,
124
+ onInvalid: () => void,
125
+ ): string[] | undefined {
126
+ if (!row) return undefined;
127
+ return parseHashList(row.hashes as string, onInvalid);
128
+ }
129
+
121
130
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
122
131
  if (typeof value !== "object" || value === null) return false;
123
132
  const v = value as Record<string, unknown>;
@@ -174,6 +183,14 @@ function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared }
174
183
  return withBusyRetry(() => openDb(storePath));
175
184
  }
176
185
 
186
+ function retriedWrite(
187
+ stmt: { run(...params: SqlParams): unknown },
188
+ ): (...params: SqlParams) => void {
189
+ return (...params) => {
190
+ withBusyRetry(() => { stmt.run(...params); });
191
+ };
192
+ }
193
+
177
194
  let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
178
195
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
179
196
  let exitHandlerRegistered = false;
@@ -238,6 +255,7 @@ function buildStore(
238
255
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
239
256
  db.exec("DELETE FROM snapshots");
240
257
  db.exec("DELETE FROM undo");
258
+ db.exec("DELETE FROM served");
241
259
  }
242
260
  db.prepare(
243
261
  "INSERT INTO meta (key, value) VALUES ('version', ?) " +
@@ -246,6 +264,7 @@ function buildStore(
246
264
  const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
247
265
  const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
248
266
  const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
267
+ const allServedStmt = db.prepare("SELECT path, hashes FROM served");
249
268
  const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
250
269
  const upsertStmt = db.prepare(
251
270
  "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
@@ -269,14 +288,15 @@ function buildStore(
269
288
  get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
270
289
  allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
271
290
  allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
272
- deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
273
- upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
274
- undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
291
+ allServed: (...params) => allServedStmt.all(...params) as Record<string, unknown>[],
292
+ deleteOne: retriedWrite(delStmt),
293
+ upsert: retriedWrite(upsertStmt),
294
+ undoUpsert: retriedWrite(undoUpsertStmt),
275
295
  undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
276
- undoDelete: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
296
+ undoDelete: retriedWrite(undoDelStmt),
277
297
  servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
278
- servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
279
- servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
298
+ servedUpsert: retriedWrite(servedUpsertStmt),
299
+ servedDelete: retriedWrite(servedDelStmt),
280
300
  };
281
301
  return { db, stmts };
282
302
  }
@@ -489,8 +509,7 @@ export function getSnapshot(
489
509
  return cached.hashes.slice();
490
510
  }
491
511
  const row = store.stmts.get(path, checksum, lineCount);
492
- if (!row) return undefined;
493
- const parsed = parseHashList(row.hashes as string, () => {
512
+ const parsed = parseStoredHashes(row, () => {
494
513
  if (deleteCorrupt) store.stmts.deleteOne(path);
495
514
  snapshotCache.delete(path);
496
515
  });
@@ -525,7 +544,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
525
544
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
526
545
  const row = store.stmts.undoGet(path);
527
546
  if (!row) return undefined;
528
- const parsed = parseHashList(row.hashes as string, () => store.stmts.undoDelete(path));
547
+ const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
529
548
  if (!parsed) return undefined;
530
549
  return {
531
550
  content: row.content as string,
@@ -571,14 +590,15 @@ export async function pruneMissing(store: HashStore): Promise<void> {
571
590
  for (const path of missing) {
572
591
  store.stmts.deleteOne(path);
573
592
  snapshotCache.delete(path);
574
- store.stmts.undoDelete(path);
575
593
  store.stmts.servedDelete(path);
576
594
  }
577
595
  });
578
596
  }
579
597
 
580
- export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
581
- const rows = store.stmts.allHashes() as { path: string; hashes: string }[];
598
+ function matchPathsByHashes(
599
+ rows: { path: string; hashes: string }[],
600
+ hashes: string[],
601
+ ): string[] {
582
602
  const matches: string[] = [];
583
603
  for (const row of rows) {
584
604
  try {
@@ -591,3 +611,11 @@ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[]
591
611
  }
592
612
  return matches;
593
613
  }
614
+
615
+ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
616
+ return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
617
+ }
618
+
619
+ export function findServedPaths(store: HashStore, hashes: string[]): string[] {
620
+ return matchPathsByHashes(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
621
+ }
@@ -1,5 +1,5 @@
1
1
  import { abortIf, splitLines } from "../utils";
2
- import { _lineHashesPure, HASH_SEP } from "./hash";
2
+ import { _lineHashesPure } from "./hash";
3
3
  import {
4
4
  valEdit,
5
5
  stripBarePrefixes,
@@ -268,20 +268,7 @@ export function applyEdit(
268
268
  };
269
269
  }
270
270
 
271
- export function fmtRegion(
272
- hashes: string[],
273
- lines: string[],
274
- ): string {
275
- if (hashes.length !== lines.length) {
276
- throw new Error(
277
- `fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
278
- );
279
- }
280
- return lines
281
- .map((line, index) => `${hashes[index]}${HASH_SEP}${line}`)
282
- .join("\n");
283
- }
284
-
271
+ export { fmtRegion, fmtRow } from "./resolve";
285
272
  export function changedRange(
286
273
  original: string,
287
274
  result: string,
@@ -47,6 +47,30 @@ export const HL_PREFIX_MINUS_RE = new RegExp(
47
47
 
48
48
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
49
49
 
50
+ export type RowPrefixKind = "bare" | "plus" | "minus";
51
+
52
+ export type StrippedRow = {
53
+ text: string;
54
+ kind: RowPrefixKind | null;
55
+ hash: string | undefined;
56
+ };
57
+
58
+ export function stripRowPrefix(line: string): StrippedRow {
59
+ const bare = line.match(HL_BARE_PREFIX_RE);
60
+ if (bare) {
61
+ return { text: line.slice(bare[0].length), kind: "bare", hash: bare[1] };
62
+ }
63
+ const plus = line.match(HL_PREFIX_PLUS_RE);
64
+ if (plus) {
65
+ return { text: line.slice(plus[0].length), kind: "plus", hash: plus[1] };
66
+ }
67
+ const minus = line.match(HL_PREFIX_MINUS_RE);
68
+ if (minus) {
69
+ return { text: line.slice(minus[0].length), kind: "minus", hash: minus[1] };
70
+ }
71
+ return { text: line, kind: null, hash: undefined };
72
+ }
73
+
50
74
  export function canon(line: string): string {
51
75
  return line.replace(/\r/g, "").trimEnd();
52
76
  }
@@ -27,6 +27,8 @@ export {
27
27
  type BDup,
28
28
  type AutoFix,
29
29
  resEdit,
30
+ stripAnchorRow,
31
+ resolveAnchorLine,
30
32
  valEdit,
31
33
  stripBarePrefixes,
32
34
  stripDiffPrefixes,
@@ -41,5 +43,6 @@ export {
41
43
  buildIdx,
42
44
  applyEdit,
43
45
  fmtRegion,
46
+ fmtRow,
44
47
  changedRange,
45
48
  } from "./apply";