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.
- package/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- 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
|
+
}
|