pi-readseek 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
package/src/find.ts ADDED
@@ -0,0 +1,613 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
5
+ import { readdir, readFile, stat } from "node:fs/promises";
6
+ import { execFileSync, execFile } from "node:child_process";
7
+ import { resolve, relative, join } from "node:path";
8
+ import { resolveToCwd } from "./path-utils.js";
9
+ import * as findStat from "./find-stat.js";
10
+ import { parseRelativeOrIsoDate, parseSize } from "./find-parsers.js";
11
+ import { buildReadseekError } from "./readseek-value.js";
12
+ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
13
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
14
+
15
+ const MAX_BYTES = 50 * 1024; // 50 KB
16
+ const DEFAULT_LIMIT = 1000;
17
+ const FIND_PROMPT_METADATA = defineToolPromptMetadata({
18
+ promptUrl: new URL("../prompts/find.md", import.meta.url),
19
+ promptSnippet: "Find files recursively by name, respecting gitignore",
20
+ promptGuidelines: [
21
+ "Use find for recursive file-name discovery; use ls for one directory.",
22
+ "Use find path plus basename pattern rather than shell find commands.",
23
+ "Use find filters and sorting before limit for newest/largest file queries.",
24
+ ],
25
+ });
26
+
27
+ export const FIND_READSEEK = {
28
+ callable: true,
29
+ enabled: true,
30
+ policy: "read-only" as const,
31
+ readOnly: true,
32
+ pythonName: "find",
33
+ defaultExposure: "safe-by-default" as const,
34
+ };
35
+
36
+
37
+ export interface FindEntry {
38
+ path: string;
39
+ type: "file" | "dir";
40
+ }
41
+
42
+ export interface FindReadseekValue {
43
+ tool: "find";
44
+ pattern: string;
45
+ totalEntries: number;
46
+ truncated: boolean;
47
+ entries: FindEntry[];
48
+ }
49
+
50
+ export function isFdAvailable(): boolean {
51
+ try {
52
+ execFileSync("fd", ["--version"], { timeout: 3000, stdio: "pipe" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /** @internal — testable indirection for module-level state */
60
+ export const _testable = {
61
+ isFdAvailable,
62
+ fdHintShown: false,
63
+ };
64
+ /** @internal — test-only helper to reset the one-time hint flag */
65
+ export function _resetFdHintForTesting(): void {
66
+ _testable.fdHintShown = false;
67
+ }
68
+
69
+ function normalizePath(p: string): string {
70
+ return p.replace(/\\/g, "/");
71
+ }
72
+
73
+ /**
74
+ * Load .gitignore at a specific directory and return an ignore instance.
75
+ */
76
+ async function loadGitignore(dir: string): Promise<any | null> {
77
+ const ignore = (await import("ignore" as any)).default;
78
+ const gitignorePath = join(dir, ".gitignore");
79
+ try {
80
+ const content = await readFile(gitignorePath, "utf-8");
81
+ const ig = ignore();
82
+ ig.add(content);
83
+ return ig;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ async function findWithNodeFallback(
90
+ searchPath: string,
91
+ matcher: (basename: string) => boolean,
92
+ type: "file" | "dir" | "any",
93
+ maxDepth?: number,
94
+ ): Promise<FindEntry[]> {
95
+ const ignore = (await import("ignore" as any)).default;
96
+
97
+ const entries: FindEntry[] = [];
98
+
99
+ // Stack of ignore instances — each directory can add its own
100
+ async function walk(
101
+ dir: string,
102
+ depth: number,
103
+ parentIgnores: Array<{ ig: any; base: string }>,
104
+ ): Promise<void> {
105
+ if (maxDepth !== undefined && depth > maxDepth) return;
106
+
107
+ // Check for .gitignore at this directory level
108
+ const localIgnores = [...parentIgnores];
109
+ const localIg = await loadGitignore(dir);
110
+ if (localIg) {
111
+ localIgnores.push({ ig: localIg, base: dir });
112
+ }
113
+
114
+ let dirents;
115
+ try {
116
+ dirents = await readdir(dir, { withFileTypes: true });
117
+ } catch {
118
+ return; // Permission denied or similar
119
+ }
120
+
121
+ for (const dirent of dirents) {
122
+ const fullPath = join(dir, dirent.name);
123
+ const relFromRoot = normalizePath(relative(searchPath, fullPath));
124
+
125
+ // Skip .git directory
126
+ if (dirent.name === ".git" && dirent.isDirectory()) continue;
127
+
128
+ // Check ignore rules — each ignore instance checks paths relative to its base
129
+ let ignored = false;
130
+ for (const { ig, base } of localIgnores) {
131
+ const relFromBase = normalizePath(relative(base, fullPath));
132
+ const checkPath = dirent.isDirectory() ? relFromBase + "/" : relFromBase;
133
+ if (ig.ignores(checkPath)) {
134
+ ignored = true;
135
+ break;
136
+ }
137
+ }
138
+ if (ignored) continue;
139
+
140
+ if (dirent.isDirectory()) {
141
+ if ((type === "dir" || type === "any") && matcher(dirent.name)) {
142
+ entries.push({ path: relFromRoot, type: "dir" });
143
+ }
144
+ await walk(fullPath, depth + 1, localIgnores);
145
+ } else {
146
+ if ((type === "file" || type === "any") && matcher(dirent.name)) {
147
+ entries.push({ path: relFromRoot, type: "file" });
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // Start with a root-level ignore that always excludes .git
154
+ const rootIg = ignore();
155
+ rootIg.add(".git");
156
+ await walk(searchPath, 1, [{ ig: rootIg, base: searchPath }]);
157
+
158
+ // Sort lexicographically by path
159
+ entries.sort((a, b) => a.path.localeCompare(b.path));
160
+
161
+ return entries;
162
+ }
163
+
164
+ async function findWithFd(
165
+ searchPath: string,
166
+ pattern: string,
167
+ type: "file" | "dir" | "any",
168
+ maxDepth?: number,
169
+ ): Promise<FindEntry[]> {
170
+ return new Promise((resolve_, reject) => {
171
+ const args: string[] = ["--glob", pattern, "--hidden", "--color", "never"];
172
+
173
+ if (type === "file") args.push("--type", "f");
174
+ else if (type === "dir") args.push("--type", "d");
175
+
176
+ if (maxDepth !== undefined) args.push("--max-depth", String(maxDepth));
177
+
178
+ args.push(".");
179
+
180
+ execFile("fd", args, { maxBuffer: 10 * 1024 * 1024, cwd: searchPath }, (err, stdout, _stderr) => {
181
+ if (err && !stdout) {
182
+ // fd returns exit code 1 when no matches found
183
+ if ((err as any).code === 1) {
184
+ resolve_([]);
185
+ return;
186
+ }
187
+ reject(err);
188
+ return;
189
+ }
190
+
191
+ const lines = stdout.trim().split("\n").filter((l) => l.length > 0);
192
+ const entries: FindEntry[] = [];
193
+
194
+ for (const line of lines) {
195
+ // fd outputs paths relative to its search directory
196
+ let relPath = normalizePath(line.trim());
197
+ // Remove leading ./ if present
198
+ if (relPath.startsWith("./")) relPath = relPath.slice(2);
199
+ // Remove trailing / (fd adds it for directories)
200
+ if (relPath.endsWith("/")) relPath = relPath.slice(0, -1);
201
+ if (!relPath || relPath.startsWith("..")) continue;
202
+
203
+ if (type === "file") {
204
+ entries.push({ path: relPath, type: "file" });
205
+ } else if (type === "dir") {
206
+ entries.push({ path: relPath, type: "dir" });
207
+ } else {
208
+ // For "any", we need to determine the type
209
+ try {
210
+ const { statSync } = require("node:fs");
211
+ const fullPath = resolve(searchPath, relPath);
212
+ const s = statSync(fullPath);
213
+ entries.push({ path: relPath, type: s.isDirectory() ? "dir" : "file" });
214
+ } catch {
215
+ entries.push({ path: relPath, type: "file" });
216
+ }
217
+ }
218
+ }
219
+
220
+ entries.sort((a, b) => a.path.localeCompare(b.path));
221
+ resolve_(entries);
222
+ });
223
+ });
224
+ }
225
+
226
+ async function findWithFdRegex(
227
+ searchPath: string,
228
+ matcher: (basename: string) => boolean,
229
+ type: "file" | "dir" | "any",
230
+ maxDepth?: number,
231
+ ): Promise<FindEntry[]> {
232
+ return new Promise((resolve_, reject) => {
233
+ const args: string[] = ["--glob", "*", "--hidden", "--color", "never"];
234
+ if (type === "file") args.push("--type", "f");
235
+ else if (type === "dir") args.push("--type", "d");
236
+ if (maxDepth !== undefined) args.push("--max-depth", String(maxDepth));
237
+ args.push(".");
238
+ execFile("fd", args, { maxBuffer: 10 * 1024 * 1024, cwd: searchPath }, (err, stdout, _stderr) => {
239
+ if (err && !stdout) {
240
+ if ((err as any).code === 1) return resolve_([]);
241
+ return reject(err);
242
+ }
243
+ const lines = stdout.trim().split("\n").filter((l) => l.length > 0);
244
+ const entries: FindEntry[] = [];
245
+ for (const line of lines) {
246
+ let relPath = normalizePath(line.trim());
247
+ if (relPath.startsWith("./")) relPath = relPath.slice(2);
248
+ const hadTrailingSlash = relPath.endsWith("/");
249
+ if (hadTrailingSlash) relPath = relPath.slice(0, -1);
250
+ if (!relPath || relPath.startsWith("..")) continue;
251
+ const name = relPath.split("/").pop() ?? relPath;
252
+ if (!matcher(name)) continue;
253
+ if (type === "file") entries.push({ path: relPath, type: "file" });
254
+ else if (type === "dir") entries.push({ path: relPath, type: "dir" });
255
+ else if (hadTrailingSlash) entries.push({ path: relPath, type: "dir" });
256
+ else entries.push({ path: relPath, type: "file" });
257
+ }
258
+ entries.sort((a, b) => a.path.localeCompare(b.path));
259
+ resolve_(entries);
260
+ });
261
+ });
262
+ }
263
+
264
+ function formatOutput(
265
+ entries: FindEntry[],
266
+ totalCount: number,
267
+ truncated: boolean,
268
+ pattern: string,
269
+ showFdHint: boolean,
270
+ ): string {
271
+ if (entries.length === 0 && !truncated) {
272
+ const text = `No files found matching pattern: ${pattern}`;
273
+ return showFdHint ? text + "\nHint: Install fd for faster file discovery: brew install fd" : text;
274
+ }
275
+
276
+ const lines: string[] = [];
277
+ for (const e of entries) {
278
+ lines.push(e.type === "dir" ? `${e.path}/` : e.path);
279
+ }
280
+ if (truncated) {
281
+ const remaining = totalCount - entries.length;
282
+ lines.push(`[… ${remaining} more entries — refine pattern or increase limit]`);
283
+ }
284
+ if (showFdHint) {
285
+ lines.push("Hint: Install fd for faster file discovery: brew install fd");
286
+ }
287
+
288
+ let text = lines.join("\n");
289
+ const bytes = Buffer.byteLength(text, "utf8");
290
+ if (bytes > MAX_BYTES) {
291
+ text = Buffer.from(text, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n[… truncated at 50 KB]";
292
+ }
293
+ return text;
294
+ }
295
+
296
+ export function registerFindTool(pi: ExtensionAPI) {
297
+ const tool: Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof FIND_READSEEK } = {
298
+ name: "find",
299
+ label: "find",
300
+ description: FIND_PROMPT_METADATA.description,
301
+ promptSnippet: FIND_PROMPT_METADATA.promptSnippet,
302
+ promptGuidelines: FIND_PROMPT_METADATA.promptGuidelines,
303
+ ptc: FIND_READSEEK,
304
+ parameters: Type.Object(
305
+ {
306
+ pattern: Type.String({ description: "Glob or basename pattern" }),
307
+ path: Type.Optional(Type.String({ description: "Search root" })),
308
+ limit: Type.Optional(Type.Number({ description: "Max entries" })),
309
+ type: Type.Optional(
310
+ Type.Union(
311
+ [Type.Literal("file"), Type.Literal("dir"), Type.Literal("any")],
312
+ { description: "Entry type filter" },
313
+ ),
314
+ ),
315
+ maxDepth: Type.Optional(Type.Number({ description: "Max directory depth" })),
316
+ regex: Type.Optional(
317
+ Type.Boolean({ description: "Treat pattern as regex" }),
318
+ ),
319
+ sortBy: Type.Optional(
320
+ Type.Union(
321
+ [Type.Literal("name"), Type.Literal("mtime"), Type.Literal("size")],
322
+ { description: "Sort key" },
323
+ ),
324
+ ),
325
+ reverse: Type.Optional(
326
+ Type.Boolean({ description: "Reverse sort order" }),
327
+ ),
328
+ modifiedSince: Type.Optional(Type.String({ description: "Modified after time" })),
329
+ minSize: Type.Optional(
330
+ Type.Union([Type.Number(), Type.String()], { description: "Minimum file size" }),
331
+ ),
332
+ maxSize: Type.Optional(
333
+ Type.Union([Type.Number(), Type.String()], { description: "Maximum file size" }),
334
+ ),
335
+ },
336
+ { required: ["pattern"] },
337
+ ),
338
+ async execute(
339
+ _toolCallId: string,
340
+ params: {
341
+ pattern: string;
342
+ path?: string;
343
+ limit?: number;
344
+ type?: "file" | "dir" | "any";
345
+ maxDepth?: number;
346
+ regex?: boolean;
347
+ sortBy?: "name" | "mtime" | "size";
348
+ reverse?: boolean;
349
+ modifiedSince?: string;
350
+ minSize?: number | string;
351
+ maxSize?: number | string;
352
+ },
353
+ _signal: AbortSignal | undefined,
354
+ _onUpdate: any,
355
+ ctx: any,
356
+ ) {
357
+ const cwd: string = ctx?.cwd ?? process.cwd();
358
+ const searchPath = params.path ? resolveToCwd(params.path, cwd) : cwd;
359
+ const limit = params.limit ?? DEFAULT_LIMIT;
360
+ const type = params.type ?? "file";
361
+ const pattern = params.pattern;
362
+ const maxDepthCoerced = coerceObviousBase10Int(params.maxDepth, "maxDepth");
363
+ if (!maxDepthCoerced.ok) {
364
+ const message = `Error: ${maxDepthCoerced.message}`;
365
+ return {
366
+ content: [{ type: "text" as const, text: message }],
367
+ isError: true,
368
+ details: {
369
+ readseekValue: {
370
+ tool: "find" as const,
371
+ ok: false,
372
+ path: params.path ?? searchPath,
373
+ error: buildReadseekError("invalid-params-combo", maxDepthCoerced.message),
374
+ },
375
+ },
376
+ };
377
+ }
378
+ if (maxDepthCoerced.value !== undefined && maxDepthCoerced.value < 0) {
379
+ const message = `Invalid maxDepth: expected a non-negative integer, received ${maxDepthCoerced.value}.`;
380
+ return {
381
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
382
+ isError: true,
383
+ details: {
384
+ readseekValue: {
385
+ tool: "find" as const,
386
+ ok: false,
387
+ path: params.path ?? searchPath,
388
+ error: buildReadseekError("invalid-params-combo", message),
389
+ },
390
+ },
391
+ };
392
+ }
393
+ params = { ...params, maxDepth: maxDepthCoerced.value };
394
+
395
+ // Check if path exists
396
+ try {
397
+ const s = await stat(searchPath);
398
+ if (!s.isDirectory()) {
399
+ const message = `Error: '${params.path ?? searchPath}' is not a directory`;
400
+ return {
401
+ content: [{ type: "text" as const, text: message }],
402
+ isError: true,
403
+ details: {
404
+ readseekValue: {
405
+ tool: "find",
406
+ ok: false,
407
+ path: params.path ?? searchPath,
408
+ error: buildReadseekError(
409
+ "path-not-directory",
410
+ message,
411
+ `Use ls on a directory, or read(${JSON.stringify(params.path ?? searchPath)}) for a single file.`,
412
+ ),
413
+ },
414
+ },
415
+ };
416
+ }
417
+ } catch (err: any) {
418
+ const target = params.path ?? searchPath;
419
+ const code =
420
+ err?.code === "EACCES" || err?.code === "EPERM"
421
+ ? "permission-denied"
422
+ : err?.code === "ENOENT"
423
+ ? "path-not-found"
424
+ : "fs-error";
425
+ const message =
426
+ code === "permission-denied"
427
+ ? `Error: permission denied for path '${target}'`
428
+ : code === "path-not-found"
429
+ ? `Error: path '${target}' does not exist`
430
+ : `Error: could not access path '${target}': ${err?.message ?? String(err)}`;
431
+ return {
432
+ content: [{ type: "text" as const, text: message }],
433
+ isError: true,
434
+ details: {
435
+ readseekValue: {
436
+ tool: "find",
437
+ ok: false,
438
+ path: target,
439
+ error: buildReadseekError(code, message, undefined, code === "fs-error"
440
+ ? { fsCode: err?.code, fsMessage: err?.message }
441
+ : undefined),
442
+ },
443
+ },
444
+ };
445
+ }
446
+
447
+ const useFd = _testable.isFdAvailable();
448
+ const showFdHint = !useFd && !_testable.fdHintShown;
449
+ if (showFdHint) _testable.fdHintShown = true;
450
+
451
+ let matcher: (basename: string) => boolean;
452
+ if (params.regex) {
453
+ let re: RegExp;
454
+ try {
455
+ re = new RegExp(pattern);
456
+ } catch (err) {
457
+ const message =
458
+ `Error: invalid regex for fields 'pattern'/'regex' ` +
459
+ `(${JSON.stringify(pattern)}): ${(err as Error).message}`;
460
+ return {
461
+ content: [{ type: "text" as const, text: message }],
462
+ isError: true,
463
+ details: {
464
+ readseekValue: {
465
+ tool: "find",
466
+ ok: false,
467
+ path: params.path ?? searchPath,
468
+ error: buildReadseekError("invalid-params-combo", message),
469
+ },
470
+ },
471
+ };
472
+ }
473
+ matcher = (basename: string) => re.test(basename);
474
+ } else {
475
+ const picomatch = (await import("picomatch" as any)).default;
476
+ const isMatch = picomatch(pattern, { basename: true, dot: true });
477
+ matcher = (basename: string) => isMatch(basename);
478
+ }
479
+ let allEntries: FindEntry[];
480
+ if (useFd) {
481
+ allEntries = params.regex
482
+ ? await findWithFdRegex(searchPath, matcher, type, params.maxDepth)
483
+ : await findWithFd(searchPath, pattern, type, params.maxDepth);
484
+ } else {
485
+ allEntries = await findWithNodeFallback(searchPath, matcher, type, params.maxDepth);
486
+ }
487
+
488
+ let modifiedSinceMs: number | null = null;
489
+ let minSizeBytes: number | null = null;
490
+ let maxSizeBytes: number | null = null;
491
+ try {
492
+ if (params.modifiedSince !== undefined) {
493
+ modifiedSinceMs = parseRelativeOrIsoDate("modifiedSince", params.modifiedSince).getTime();
494
+ }
495
+ if (params.minSize !== undefined) {
496
+ minSizeBytes = parseSize("minSize", params.minSize);
497
+ }
498
+ if (params.maxSize !== undefined) {
499
+ maxSizeBytes = parseSize("maxSize", params.maxSize);
500
+ }
501
+ } catch (err) {
502
+ const message = `Error: ${(err as Error).message}`;
503
+ return {
504
+ content: [{ type: "text" as const, text: message }],
505
+ isError: true,
506
+ details: {
507
+ readseekValue: {
508
+ tool: "find",
509
+ ok: false,
510
+ path: params.path ?? searchPath,
511
+ error: buildReadseekError("invalid-params-combo", message),
512
+ },
513
+ },
514
+ };
515
+ }
516
+ const sortBy = params.sortBy ?? "name";
517
+ const dir = params.reverse ? -1 : 1;
518
+ const needsStat =
519
+ sortBy === "mtime" ||
520
+ sortBy === "size" ||
521
+ modifiedSinceMs !== null ||
522
+ minSizeBytes !== null ||
523
+ maxSizeBytes !== null;
524
+ let statsByIndex: (import("node:fs").Stats | null)[] = [];
525
+ if (needsStat) {
526
+ statsByIndex = await findStat.statAllWithConcurrency(
527
+ allEntries.map((e) => e.path),
528
+ searchPath,
529
+ );
530
+ }
531
+ const filtered: Array<{ entry: FindEntry; st: import("node:fs").Stats | null }> = [];
532
+ for (let i = 0; i < allEntries.length; i++) {
533
+ const entry = allEntries[i];
534
+ const st = needsStat ? statsByIndex[i] ?? null : null;
535
+ if (modifiedSinceMs !== null) {
536
+ if (!st) continue;
537
+ if (st.mtimeMs <= modifiedSinceMs) continue;
538
+ }
539
+ if ((minSizeBytes !== null || maxSizeBytes !== null) && entry.type === "file") {
540
+ if (!st) continue;
541
+ if (minSizeBytes !== null && st.size < minSizeBytes) continue;
542
+ if (maxSizeBytes !== null && st.size > maxSizeBytes) continue;
543
+ }
544
+ filtered.push({ entry, st });
545
+ }
546
+ filtered.sort((a, b) => {
547
+ if (sortBy === "mtime") {
548
+ const cmp = ((a.st?.mtimeMs ?? 0) - (b.st?.mtimeMs ?? 0)) * dir;
549
+ if (cmp !== 0) return cmp;
550
+ return a.entry.path.localeCompare(b.entry.path);
551
+ }
552
+ if (sortBy === "name") {
553
+ return a.entry.path.localeCompare(b.entry.path) * dir;
554
+ }
555
+ if (sortBy === "size") {
556
+ const cmp = ((a.st?.size ?? 0) - (b.st?.size ?? 0)) * dir;
557
+ if (cmp !== 0) return cmp;
558
+ return a.entry.path.localeCompare(b.entry.path);
559
+ }
560
+ return a.entry.path.localeCompare(b.entry.path);
561
+ });
562
+ allEntries = filtered.map((d) => d.entry);
563
+
564
+ const totalCount = allEntries.length;
565
+ const truncated = totalCount > limit;
566
+ const displayed = truncated ? allEntries.slice(0, limit) : allEntries;
567
+
568
+ const outputText = formatOutput(displayed, totalCount, truncated, pattern, showFdHint);
569
+ const readseekValue: FindReadseekValue = {
570
+ tool: "find",
571
+ pattern,
572
+ totalEntries: totalCount,
573
+ truncated,
574
+ entries: displayed,
575
+ };
576
+
577
+ return {
578
+ content: [{ type: "text" as const, text: outputText }],
579
+ details: { readseekValue },
580
+ };
581
+ },
582
+
583
+ renderCall(args: any, theme: any, context: any = {}) {
584
+ const { pattern, path } = args as { pattern: string; path?: string };
585
+ const cwd = context.cwd ?? process.cwd();
586
+ const label = renderToolLabel(theme, "find");
587
+ const target = path
588
+ ? `${theme.fg("muted", pattern)}${theme.fg("muted", " in ")}${linkToolPath(theme.fg("muted", path), path, cwd)}`
589
+ : theme.fg("muted", pattern);
590
+ return new Text(clampLineToWidth(`${label} ${target}`, context.width), 0, 0);
591
+ },
592
+
593
+ renderResult(result: any, options: any, theme: any, context: any = {}) {
594
+ const expanded = isRendererExpanded(options, context);
595
+ const width = context.width ?? options?.width;
596
+ const output = result.content[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "";
597
+ if (result.isError || context.isError) {
598
+ const firstLine = output.split("\n")[0] || "error";
599
+ const body = expanded && output ? output : firstLine;
600
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
601
+ }
602
+ const readseekValue = result.details?.readseekValue as { totalEntries?: number } | undefined;
603
+ const total = readseekValue?.totalEntries ?? output.split("\n").filter((l: string) => l.length > 0 && !l.startsWith("[") && !l.startsWith("Hint")).length;
604
+ if (total === 0) return new Text(summaryLine("no results"), 0, 0);
605
+ let text = summaryLine(`${total} ${total === 1 ? "result" : "results"} returned`, { hidden: !!output && !expanded });
606
+ if (expanded && output) text += `\n${output}`;
607
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
608
+ },
609
+ };
610
+
611
+ pi.registerTool(tool);
612
+ return tool;
613
+ }
@@ -0,0 +1,79 @@
1
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
2
+ import { resolveReadseekJsonSettings } from "./readseek-settings.js";
3
+
4
+ const POSITIVE_BASE10_INT = /^[1-9][0-9]*$/;
5
+
6
+ /**
7
+ * Strict positive base-10 integer parser used by hashline env knobs.
8
+ *
9
+ * Accepts: trimmed strings matching /^[1-9][0-9]*$/ that parse to a finite
10
+ * positive integer.
11
+ *
12
+ * Rejects: undefined, empty, whitespace-only, "0", negative, signed, hex
13
+ * ("0x10"), exponent ("1e3"), decimal ("3.14"), separators ("1,000" /
14
+ * "1_000"), embedded whitespace ("5 5").
15
+ *
16
+ * Returns `undefined` on rejection; never throws.
17
+ */
18
+ export function parsePositiveBase10Int(raw: string | undefined | null): number | undefined {
19
+ if (raw === undefined || raw === null) return undefined;
20
+ const trimmed = String(raw).trim();
21
+ if (!POSITIVE_BASE10_INT.test(trimmed)) return undefined;
22
+ const parsed = Number.parseInt(trimmed, 10);
23
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
24
+ return parsed;
25
+ }
26
+
27
+ export interface GrepOutputBudget {
28
+ maxLines: number;
29
+ maxBytes: number;
30
+ }
31
+
32
+ /**
33
+ * Effective grep-output ceilings used as clamp upper bounds and as the
34
+ * fallback defaults when env vars are unset/invalid.
35
+ *
36
+ * The bytes ceiling is the already-tightened 50 KiB used by `buildGrepOutput`
37
+ * today, NOT the unclamped `DEFAULT_MAX_BYTES`.
38
+ */
39
+ export const GREP_OUTPUT_DEFAULT_MAX_LINES = DEFAULT_MAX_LINES;
40
+ export const GREP_OUTPUT_DEFAULT_MAX_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
41
+
42
+ function resolveEnvDimension(rawEnvValue: string | undefined, ceiling: number): number | undefined {
43
+ const parsed = parsePositiveBase10Int(rawEnvValue);
44
+ return parsed === undefined ? undefined : Math.min(parsed, ceiling);
45
+ }
46
+
47
+ function resolveDimension(rawEnvValue: string | undefined, jsonValue: number | undefined, ceiling: number): number {
48
+ if (rawEnvValue !== undefined) {
49
+ const envValue = resolveEnvDimension(rawEnvValue, ceiling);
50
+ if (envValue !== undefined) return envValue;
51
+ }
52
+ if (jsonValue !== undefined) return Math.min(jsonValue, ceiling);
53
+ return ceiling;
54
+ }
55
+
56
+ /**
57
+ * Resolve the effective grep visible-output budget. Re-reads `process.env`
58
+ * on every call (no memoization) so tests and long-lived agent sessions can
59
+ * change the env vars dynamically.
60
+ *
61
+ * Invalid / zero / negative env values fall back to the current defaults.
62
+ * Above-default values clamp to the current defaults. Below-default values
63
+ * are used as-is.
64
+ */
65
+ export function resolveGrepOutputBudget(): GrepOutputBudget {
66
+ const settings = resolveReadseekJsonSettings().settings.grep;
67
+ return {
68
+ maxLines: resolveDimension(
69
+ process.env.PI_HASHLINE_GREP_MAX_LINES,
70
+ settings?.maxLines,
71
+ GREP_OUTPUT_DEFAULT_MAX_LINES,
72
+ ),
73
+ maxBytes: resolveDimension(
74
+ process.env.PI_HASHLINE_GREP_MAX_BYTES,
75
+ settings?.maxBytes,
76
+ GREP_OUTPUT_DEFAULT_MAX_BYTES,
77
+ ),
78
+ };
79
+ }