@shanepadgett/tau-agent 0.27.0 → 0.28.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.
Files changed (113) hide show
  1. package/extensions/context/sync.ts +1 -9
  2. package/extensions/context-pruning/prune.ts +2 -2
  3. package/extensions/explore/README.md +13 -21
  4. package/extensions/explore/ast/adapter.ts +97 -0
  5. package/extensions/explore/ast/engine.ts +470 -0
  6. package/extensions/explore/ast/format/ast-search.ts +97 -0
  7. package/extensions/explore/ast/format/composite.ts +26 -0
  8. package/extensions/explore/ast/format/context.ts +66 -0
  9. package/extensions/explore/ast/format/deps.ts +86 -0
  10. package/extensions/explore/ast/format/discover.ts +85 -0
  11. package/extensions/explore/ast/format/impact.ts +105 -0
  12. package/extensions/explore/ast/format/outline.ts +66 -0
  13. package/extensions/explore/ast/format/relationships.ts +81 -0
  14. package/extensions/explore/ast/format/show.ts +38 -0
  15. package/extensions/explore/ast/grammars/kotlin.wasm +0 -0
  16. package/extensions/explore/ast/grammars/manifest.json +41 -0
  17. package/extensions/explore/ast/grammars/manifest.ts +99 -0
  18. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  19. package/extensions/explore/ast/grammars/swift.wasm +0 -0
  20. package/extensions/explore/ast/graph/file-graph.ts +360 -0
  21. package/extensions/explore/ast/graph/relationships.ts +597 -0
  22. package/extensions/explore/ast/identity.ts +119 -0
  23. package/extensions/explore/ast/ir.ts +136 -0
  24. package/extensions/explore/ast/languages/csharp-file-deps.ts +189 -0
  25. package/extensions/explore/ast/languages/csharp.ts +493 -0
  26. package/extensions/explore/ast/languages/file-dep-util.ts +120 -0
  27. package/extensions/explore/ast/languages/fixtures/sample.cs +17 -0
  28. package/extensions/explore/ast/languages/fixtures/sample.java +21 -0
  29. package/extensions/explore/ast/languages/fixtures/sample.kt +15 -0
  30. package/extensions/explore/ast/languages/fixtures/sample.odin +28 -0
  31. package/extensions/explore/ast/languages/fixtures/sample.rs +31 -0
  32. package/extensions/explore/ast/languages/fixtures/sample.swift +25 -0
  33. package/extensions/explore/ast/languages/go-file-deps.ts +139 -0
  34. package/extensions/explore/ast/languages/go.ts +545 -0
  35. package/extensions/explore/ast/languages/java.ts +412 -0
  36. package/extensions/explore/ast/languages/jvm-file-deps.ts +217 -0
  37. package/extensions/explore/ast/languages/kotlin.ts +611 -0
  38. package/extensions/explore/ast/languages/odin-file-deps.ts +175 -0
  39. package/extensions/explore/ast/languages/odin.ts +407 -0
  40. package/extensions/explore/ast/languages/rust-file-deps.ts +159 -0
  41. package/extensions/explore/ast/languages/rust.ts +506 -0
  42. package/extensions/explore/ast/languages/swift-file-deps.ts +59 -0
  43. package/extensions/explore/ast/languages/swift.ts +466 -0
  44. package/extensions/explore/ast/languages/tree.ts +220 -0
  45. package/extensions/explore/ast/languages/typescript-file-deps.ts +259 -0
  46. package/extensions/explore/ast/languages/typescript-package-surface.ts +440 -0
  47. package/extensions/explore/ast/languages/typescript.ts +751 -0
  48. package/extensions/explore/ast/markdown.ts +145 -0
  49. package/extensions/explore/ast/package-surface.ts +42 -0
  50. package/extensions/explore/ast/queries/ast-search.ts +306 -0
  51. package/extensions/explore/ast/queries/composite-target.ts +77 -0
  52. package/extensions/explore/ast/queries/context.ts +406 -0
  53. package/extensions/explore/ast/queries/discover.ts +427 -0
  54. package/extensions/explore/ast/queries/impact.ts +250 -0
  55. package/extensions/explore/ast/queries/outline.ts +203 -0
  56. package/extensions/explore/ast/queries/show.ts +262 -0
  57. package/extensions/explore/ast/query.ts +48 -0
  58. package/extensions/explore/ast/read/hook.ts +125 -0
  59. package/extensions/explore/ast/read/policy.ts +55 -0
  60. package/extensions/explore/ast/registry.ts +120 -0
  61. package/extensions/explore/ast/scan.ts +76 -0
  62. package/extensions/explore/ast/slice.ts +44 -0
  63. package/extensions/explore/ast/tools/ast-search.ts +138 -0
  64. package/extensions/explore/ast/tools/context.ts +126 -0
  65. package/extensions/explore/ast/tools/deps.ts +72 -0
  66. package/extensions/explore/ast/tools/discover.ts +208 -0
  67. package/extensions/explore/ast/tools/impact.ts +135 -0
  68. package/extensions/explore/ast/tools/outline.ts +200 -0
  69. package/extensions/explore/ast/tools/relationships.ts +160 -0
  70. package/extensions/explore/ast/tools/render.ts +121 -0
  71. package/extensions/explore/ast/tools/reverse-deps.ts +72 -0
  72. package/extensions/explore/ast/tools/show.ts +116 -0
  73. package/extensions/explore/guidance.ts +52 -0
  74. package/extensions/explore/index.ts +82 -92
  75. package/extensions/explore/read/autoread.ts +118 -0
  76. package/extensions/explore/settings.ts +49 -31
  77. package/extensions/explore/traverse.ts +254 -91
  78. package/extensions/handoff/index.ts +1 -1
  79. package/extensions/publish/index.ts +18 -85
  80. package/extensions/publish/publish-ui.ts +0 -4
  81. package/extensions/soul/README.md +7 -4
  82. package/extensions/soul/index.ts +11 -5
  83. package/extensions/soul/prompt.ts +31 -13
  84. package/extensions/soul/settings.ts +7 -2
  85. package/extensions/subagent/agents/context-sync.md +6 -10
  86. package/extensions/subagent/agents/review.md +24 -55
  87. package/extensions/subagent/agents/scout.md +38 -115
  88. package/extensions/subagent/run.ts +1 -1
  89. package/extensions/tau-help/help.md +2 -4
  90. package/package.json +6 -4
  91. package/schemas/tau.schema.json +35 -30
  92. package/{extensions/explore → shared}/autoread.ts +21 -7
  93. package/shared/events.ts +1 -1
  94. package/shared/full-file-knowledge.ts +49 -0
  95. package/extensions/explore/ast-guidance.ts +0 -67
  96. package/extensions/explore/ast-languages.ts +0 -61
  97. package/extensions/explore/ast-tools.ts +0 -1994
  98. package/extensions/explore/ast-worker.ts +0 -1025
  99. package/extensions/explore/find.ts +0 -147
  100. package/extensions/explore/full-file-knowledge.ts +0 -234
  101. package/extensions/explore/grep.ts +0 -749
  102. package/extensions/explore/limits.ts +0 -22
  103. package/extensions/explore/ls.ts +0 -113
  104. package/extensions/explore/orientation-state.ts +0 -280
  105. package/extensions/explore/path-display.ts +0 -41
  106. package/extensions/explore/path-tree.ts +0 -150
  107. package/extensions/explore/read-cache.ts +0 -273
  108. package/extensions/explore/read-snapshots.ts +0 -57
  109. package/extensions/explore/read-stats-panel.ts +0 -176
  110. package/extensions/explore/read-stats.ts +0 -138
  111. package/extensions/explore/read.ts +0 -372
  112. package/extensions/explore/result.ts +0 -29
  113. package/native-bin/darwin-arm64/tau-ast +0 -0
@@ -1,749 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { lstat, readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
- import { createInterface } from "node:readline";
5
- import { defineTool } from "@earendil-works/pi-coding-agent";
6
- import { Text } from "@earendil-works/pi-tui";
7
- import { type Static, Type } from "typebox";
8
- import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
9
- import { divideBudget, normalizeCountLimit, normalizeNonNegativeInteger } from "./limits.ts";
10
- import {
11
- formatPathForDisplay,
12
- isWithinPath,
13
- pathResolutionError,
14
- resolveExplorePath,
15
- stripLeadingAt,
16
- toSlashPath,
17
- } from "./path-display.ts";
18
- import { createExploreTextResult, type ExploreTextDetails, expandedExploreText } from "./result.ts";
19
-
20
- const grepQueryParams = Type.Object(
21
- {
22
- patterns: Type.Array(Type.String(), {
23
- minItems: 1,
24
- description: "Search patterns. Use an array even for one pattern.",
25
- }),
26
- paths: Type.Optional(Type.Array(Type.String(), { description: "Search roots. Use an array even for one path." })),
27
- include: Type.Optional(
28
- Type.Array(Type.String(), { description: "Include globs. Use an array even for one glob." }),
29
- ),
30
- exclude: Type.Optional(
31
- Type.Array(Type.String(), { description: "Exclude globs. Use an array even for one glob." }),
32
- ),
33
- regex: Type.Optional(Type.Boolean()),
34
- case: Type.Optional(Type.Union([Type.Literal("smart"), Type.Literal("sensitive"), Type.Literal("insensitive")])),
35
- word: Type.Optional(Type.Boolean()),
36
- context: Type.Optional(Type.Number()),
37
- hidden: Type.Optional(Type.Boolean()),
38
- noIgnore: Type.Optional(Type.Boolean()),
39
- },
40
- { additionalProperties: false },
41
- );
42
-
43
- const grepParams = Type.Object(
44
- {
45
- queries: Type.Array(grepQueryParams, { minItems: 1 }),
46
- limit: Type.Optional(Type.Number()),
47
- maxPerFile: Type.Optional(Type.Number()),
48
- maxLineLength: Type.Optional(Type.Number()),
49
- contextOnly: Type.Optional(Type.Boolean()),
50
- stopAfterLimit: Type.Optional(Type.Boolean()),
51
- hidden: Type.Optional(Type.Boolean()),
52
- noIgnore: Type.Optional(Type.Boolean()),
53
- },
54
- { additionalProperties: false },
55
- );
56
-
57
- export type GrepParams = Static<typeof grepParams>;
58
- type GrepQuery = Static<typeof grepQueryParams>;
59
-
60
- interface RgMatch {
61
- absolutePath: string;
62
- displayPath: string;
63
- lineNumber: number;
64
- text: string;
65
- matchIndex: number;
66
- matchLength: number;
67
- }
68
-
69
- interface FileMatchCount {
70
- absolutePath: string;
71
- displayPath: string;
72
- matchCount: number;
73
- }
74
-
75
- interface SelectedFile extends FileMatchCount {
76
- take: number;
77
- omittedInFile: number;
78
- }
79
-
80
- interface RenderedLine {
81
- lineNumber: number;
82
- kind: "match" | "context";
83
- text: string;
84
- matchIndex?: number;
85
- matchLength?: number;
86
- }
87
-
88
- interface GrepFileGroup {
89
- displayPath: string;
90
- lineCount?: number;
91
- lines: RenderedLine[];
92
- omittedInFile: number;
93
- }
94
-
95
- interface GrepQueryOutput {
96
- groups: GrepFileGroup[];
97
- omittedByLimit: number;
98
- limit: number;
99
- maxPerFile: number;
100
- stoppedAfterLimit: boolean;
101
- }
102
-
103
- const STDERR_LIMIT = 16_384;
104
- const PARAM_KEYS = new Set([
105
- "queries",
106
- "limit",
107
- "maxPerFile",
108
- "maxLineLength",
109
- "contextOnly",
110
- "stopAfterLimit",
111
- "hidden",
112
- "noIgnore",
113
- ]);
114
- const QUERY_KEYS = new Set([
115
- "patterns",
116
- "paths",
117
- "include",
118
- "exclude",
119
- "regex",
120
- "case",
121
- "word",
122
- "context",
123
- "hidden",
124
- "noIgnore",
125
- ]);
126
- const NOISE_PATH_NAMES = [
127
- ".git",
128
- "node_modules",
129
- "dist",
130
- "build",
131
- "coverage",
132
- ".cache",
133
- ".next",
134
- ".turbo",
135
- ".parcel-cache",
136
- "out",
137
- ];
138
-
139
- function normalizeMaxLineLength(value: number | undefined): number {
140
- return Math.max(20, normalizeCountLimit(value, 200));
141
- }
142
-
143
- function isRecord(value: unknown): value is Record<string, unknown> {
144
- return typeof value === "object" && value !== null && !Array.isArray(value);
145
- }
146
-
147
- function assertStructuredParams(value: unknown): asserts value is GrepParams {
148
- if (!isRecord(value)) throw new Error("grep requires structured parameters object");
149
- assertKnownKeys(value, PARAM_KEYS, "params");
150
- if (!Array.isArray(value.queries)) throw new Error("grep queries must be an array of query objects");
151
- if (value.queries.length === 0) throw new Error("grep requires at least one query");
152
- if (value.queries.some(Array.isArray)) {
153
- throw new Error("grep queries must be structured objects; raw argv arrays are not supported");
154
- }
155
- assertOptionalNumber(value, "limit", "params.limit");
156
- assertOptionalNumber(value, "maxPerFile", "params.maxPerFile");
157
- assertOptionalNumber(value, "maxLineLength", "params.maxLineLength");
158
- assertOptionalBoolean(value, "contextOnly", "params.contextOnly");
159
- assertOptionalBoolean(value, "stopAfterLimit", "params.stopAfterLimit");
160
- assertOptionalBoolean(value, "hidden", "params.hidden");
161
- assertOptionalBoolean(value, "noIgnore", "params.noIgnore");
162
-
163
- for (let index = 0; index < value.queries.length; index += 1) {
164
- const query = value.queries[index];
165
- const path = `queries[${index}]`;
166
- if (!isRecord(query)) throw new Error(`grep ${path} must be an object`);
167
- assertKnownKeys(query, QUERY_KEYS, path);
168
- assertStringArray(query.patterns, `${path}.patterns`, true);
169
- assertOptionalStringArray(query, "paths", `${path}.paths`);
170
- assertOptionalStringArray(query, "include", `${path}.include`);
171
- assertOptionalStringArray(query, "exclude", `${path}.exclude`);
172
- assertOptionalBoolean(query, "regex", `${path}.regex`);
173
- assertOptionalBoolean(query, "word", `${path}.word`);
174
- assertOptionalBoolean(query, "hidden", `${path}.hidden`);
175
- assertOptionalBoolean(query, "noIgnore", `${path}.noIgnore`);
176
- assertOptionalNumber(query, "context", `${path}.context`);
177
- if (
178
- query.case !== undefined &&
179
- query.case !== "smart" &&
180
- query.case !== "sensitive" &&
181
- query.case !== "insensitive"
182
- ) {
183
- throw new Error(`${path}.case must be smart, sensitive, or insensitive`);
184
- }
185
- }
186
- }
187
-
188
- function assertKnownKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, path: string): void {
189
- const extra = Object.keys(value).filter((key) => !allowed.has(key));
190
- if (extra.length > 0) throw new Error(`grep ${path} has unknown key: ${extra[0]}`);
191
- }
192
-
193
- function assertStringArray(value: unknown, path: string, required: boolean): void {
194
- if (value === undefined && !required) return;
195
- if (!Array.isArray(value)) throw new Error(`grep ${path} must be an array of strings`);
196
- if (value.length === 0 && required) throw new Error(`grep ${path} requires at least one string`);
197
- if (value.some((item) => typeof item !== "string")) {
198
- throw new Error(`grep ${path} must be an array of strings`);
199
- }
200
- }
201
-
202
- function assertOptionalStringArray(value: Record<string, unknown>, key: string, path: string): void {
203
- assertStringArray(value[key], path, false);
204
- }
205
-
206
- function assertOptionalBoolean(value: Record<string, unknown>, key: string, path: string): void {
207
- if (value[key] !== undefined && typeof value[key] !== "boolean") throw new Error(`grep ${path} must be a boolean`);
208
- }
209
-
210
- function assertOptionalNumber(value: Record<string, unknown>, key: string, path: string): void {
211
- if (value[key] !== undefined && (typeof value[key] !== "number" || !Number.isFinite(value[key]))) {
212
- throw new Error(`grep ${path} must be a finite number`);
213
- }
214
- }
215
-
216
- function normalizedGlob(pattern: string): string {
217
- return toSlashPath(stripLeadingAt(pattern));
218
- }
219
-
220
- function pushNoiseExcludes(args: string[]): void {
221
- for (const name of NOISE_PATH_NAMES) {
222
- args.push("--glob", `!${name}`);
223
- args.push("--glob", `!${name}/**`);
224
- args.push("--glob", `!**/${name}`);
225
- args.push("--glob", `!**/${name}/**`);
226
- }
227
- }
228
-
229
- function buildRgArgs(query: GrepQuery, searchPaths: readonly string[]): string[] {
230
- const args = ["--json", "--line-number", "--color=never", "--no-heading", "--no-require-git"];
231
- if (query.regex !== true) args.push("--fixed-strings");
232
- if (query.case === "insensitive") args.push("--ignore-case");
233
- else if (query.case === undefined || query.case === "smart") args.push("--smart-case");
234
- if (query.word === true) args.push("--word-regexp");
235
- if (query.hidden === true) args.push("--hidden");
236
- if (query.noIgnore === true) args.push("--no-ignore");
237
- for (const pattern of query.include ?? []) args.push("--glob", normalizedGlob(pattern));
238
- for (const pattern of query.exclude ?? []) args.push("--glob", `!${normalizedGlob(pattern)}`);
239
- if (query.noIgnore !== true) pushNoiseExcludes(args);
240
- for (const pattern of query.patterns) args.push("-e", pattern);
241
- args.push("--", ...searchPaths);
242
- return args;
243
- }
244
-
245
- async function resolveSearchPaths(cwd: string, query: GrepQuery): Promise<string[]> {
246
- const inputs = query.paths && query.paths.length > 0 ? query.paths : ["."];
247
- const paths: string[] = [];
248
- for (const input of inputs) {
249
- const absolutePath = resolveExplorePath(cwd, input);
250
- await lstat(absolutePath).catch((error: unknown) => {
251
- throw pathResolutionError(error, input);
252
- });
253
- paths.push(absolutePath);
254
- }
255
- return dedupeSearchPaths(paths);
256
- }
257
-
258
- function dedupeSearchPaths(paths: readonly string[]): string[] {
259
- const deduped: string[] = [];
260
- for (const path of [...new Set(paths)].sort((a, b) => a.length - b.length || a.localeCompare(b))) {
261
- if (deduped.some((kept) => isWithinPath(kept, path))) continue;
262
- deduped.push(path);
263
- }
264
- return deduped;
265
- }
266
-
267
- function textField(value: unknown): string | undefined {
268
- return isRecord(value) && typeof value.text === "string" ? value.text : undefined;
269
- }
270
-
271
- function byteOffsetToStringIndex(text: string, byteOffset: number): number {
272
- return Buffer.from(text).subarray(0, byteOffset).toString().length;
273
- }
274
-
275
- function sanitizeLineText(text: string): string {
276
- return text.replace(/\r?\n$/, "").replace(/\r$/, "");
277
- }
278
-
279
- function matchRange(lineText: string, data: Record<string, unknown>): { index: number; length: number } {
280
- const submatches = Array.isArray(data.submatches) ? data.submatches : [];
281
- let first: { start: number; end: number } | undefined;
282
- for (const submatch of submatches) {
283
- if (!isRecord(submatch) || typeof submatch.start !== "number" || typeof submatch.end !== "number") continue;
284
- if (!first || submatch.start < first.start) first = { start: submatch.start, end: submatch.end };
285
- }
286
- if (!first) return { index: 0, length: 1 };
287
- const index = byteOffsetToStringIndex(lineText, first.start);
288
- const end = byteOffsetToStringIndex(lineText, first.end);
289
- return { index, length: Math.max(1, end - index) };
290
- }
291
-
292
- function parseRgMatch(line: string, cwd: string): RgMatch | undefined {
293
- let parsed: unknown;
294
- try {
295
- parsed = JSON.parse(line);
296
- } catch {
297
- return undefined;
298
- }
299
- if (!isRecord(parsed) || parsed.type !== "match" || !isRecord(parsed.data)) return undefined;
300
- const data = parsed.data;
301
- const pathText = textField(data.path);
302
- const lineText = textField(data.lines);
303
- if (!pathText || lineText === undefined || typeof data.line_number !== "number") return undefined;
304
- const text = sanitizeLineText(lineText);
305
- const absolutePath = resolve(cwd, pathText);
306
- const range = matchRange(text, data);
307
- return {
308
- absolutePath,
309
- displayPath: formatPathForDisplay(absolutePath, cwd),
310
- lineNumber: data.line_number,
311
- text,
312
- matchIndex: range.index,
313
- matchLength: range.length,
314
- };
315
- }
316
-
317
- function errorCode(error: Error): unknown {
318
- return "code" in error ? error.code : undefined;
319
- }
320
-
321
- async function runRipgrep(
322
- cwd: string,
323
- args: readonly string[],
324
- signal: AbortSignal | undefined,
325
- onMatch: (match: RgMatch) => boolean,
326
- ): Promise<void> {
327
- await new Promise<void>((resolvePromise, reject) => {
328
- if (signal?.aborted) {
329
- reject(new Error("Operation aborted"));
330
- return;
331
- }
332
-
333
- const child = spawn("rg", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
334
- if (!child.stdout || !child.stderr) {
335
- reject(new Error("Failed to run ripgrep: stdout/stderr unavailable"));
336
- return;
337
- }
338
-
339
- const lines = createInterface({ input: child.stdout });
340
- let stderr = "";
341
- let settled = false;
342
- let aborted = false;
343
- let killedByLimit = false;
344
-
345
- const settle = (done: () => void): void => {
346
- if (settled) return;
347
- settled = true;
348
- done();
349
- };
350
- const cleanup = (): void => {
351
- lines.close();
352
- signal?.removeEventListener("abort", onAbort);
353
- };
354
- const stopChild = (): void => {
355
- if (!child.killed) {
356
- killedByLimit = true;
357
- child.kill();
358
- }
359
- };
360
- const onAbort = (): void => {
361
- aborted = true;
362
- if (!child.killed) child.kill();
363
- };
364
-
365
- signal?.addEventListener("abort", onAbort, { once: true });
366
- child.stderr.on("data", (chunk: Buffer) => {
367
- const remaining = STDERR_LIMIT - stderr.length;
368
- if (remaining > 0) stderr += chunk.toString("utf8").slice(0, remaining);
369
- });
370
- lines.on("line", (line) => {
371
- const match = parseRgMatch(line, cwd);
372
- if (!match) return;
373
- if (!onMatch(match)) stopChild();
374
- });
375
- child.on("error", (error: Error) => {
376
- cleanup();
377
- const message =
378
- errorCode(error) === "ENOENT" ? "ripgrep (rg) is not available" : `Failed to run ripgrep: ${error.message}`;
379
- settle(() => reject(new Error(message)));
380
- });
381
- child.on("close", (code) => {
382
- cleanup();
383
- if (aborted) {
384
- settle(() => reject(new Error("Operation aborted")));
385
- return;
386
- }
387
- if (!killedByLimit && code !== 0 && code !== 1) {
388
- const message = stderr.trim() || `ripgrep exited with code ${code ?? "unknown"}`;
389
- settle(() => reject(new Error(message)));
390
- return;
391
- }
392
- settle(resolvePromise);
393
- });
394
- });
395
- }
396
-
397
- async function countMatches(cwd: string, query: GrepQuery, signal: AbortSignal | undefined): Promise<FileMatchCount[]> {
398
- const searchPaths = await resolveSearchPaths(cwd, query);
399
- const counts = new Map<string, FileMatchCount>();
400
- await runRipgrep(cwd, buildRgArgs(query, searchPaths), signal, (match) => {
401
- const count = counts.get(match.absolutePath) ?? {
402
- absolutePath: match.absolutePath,
403
- displayPath: match.displayPath,
404
- matchCount: 0,
405
- };
406
- count.matchCount += 1;
407
- counts.set(match.absolutePath, count);
408
- return true;
409
- });
410
- return [...counts.values()].sort((a, b) => a.displayPath.localeCompare(b.displayPath));
411
- }
412
-
413
- function selectFiles(
414
- counts: readonly FileMatchCount[],
415
- limit: number,
416
- maxPerFile: number,
417
- ): {
418
- selected: SelectedFile[];
419
- omittedByLimit: number;
420
- } {
421
- let remaining = limit;
422
- let eligibleMatches = 0;
423
- let displayedMatches = 0;
424
- const selected: SelectedFile[] = [];
425
- for (const count of counts) {
426
- const eligibleInFile = Math.min(count.matchCount, maxPerFile);
427
- eligibleMatches += eligibleInFile;
428
- const take = Math.min(eligibleInFile, remaining);
429
- if (take > 0) {
430
- displayedMatches += take;
431
- remaining -= take;
432
- selected.push({ ...count, take, omittedInFile: Math.max(0, count.matchCount - maxPerFile) });
433
- }
434
- }
435
- return { selected, omittedByLimit: Math.max(0, eligibleMatches - displayedMatches) };
436
- }
437
-
438
- async function collectSelectedMatches(
439
- cwd: string,
440
- query: GrepQuery,
441
- selected: readonly SelectedFile[],
442
- signal: AbortSignal | undefined,
443
- ): Promise<Map<string, RgMatch[]>> {
444
- const selectedByPath = new Map(selected.map((file) => [file.absolutePath, file]));
445
- const matchesByPath = new Map<string, RgMatch[]>();
446
- let remaining = selected.reduce((sum, file) => sum + file.take, 0);
447
- if (remaining === 0) return matchesByPath;
448
- await runRipgrep(
449
- cwd,
450
- buildRgArgs(
451
- query,
452
- selected.map((file) => file.absolutePath),
453
- ),
454
- signal,
455
- (match) => {
456
- const file = selectedByPath.get(match.absolutePath);
457
- if (!file) return true;
458
- const matches = matchesByPath.get(match.absolutePath) ?? [];
459
- if (matches.length >= file.take) return remaining > 0;
460
- matches.push(match);
461
- matchesByPath.set(match.absolutePath, matches);
462
- remaining -= 1;
463
- return remaining > 0;
464
- },
465
- );
466
- return matchesByPath;
467
- }
468
-
469
- async function readLines(absolutePath: string): Promise<string[] | undefined> {
470
- try {
471
- return (await readFile(absolutePath, "utf8")).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
472
- } catch {
473
- return undefined;
474
- }
475
- }
476
-
477
- function buildRenderedLines(
478
- fileLines: readonly string[] | undefined,
479
- matches: readonly RgMatch[],
480
- context: number,
481
- contextOnly: boolean,
482
- ): RenderedLine[] {
483
- const matchByLine = new Map(matches.map((match) => [match.lineNumber, match]));
484
- const wanted = new Map<number, RenderedLine["kind"]>();
485
- for (const match of matches) {
486
- const start = Math.max(1, match.lineNumber - context);
487
- const end = Math.max(start, match.lineNumber + context);
488
- for (let lineNumber = start; lineNumber <= end; lineNumber += 1) {
489
- if (matchByLine.has(lineNumber)) {
490
- if (!contextOnly) wanted.set(lineNumber, "match");
491
- } else if (fileLines?.[lineNumber - 1] !== undefined && !wanted.has(lineNumber)) {
492
- wanted.set(lineNumber, "context");
493
- }
494
- }
495
- }
496
-
497
- return [...wanted.entries()]
498
- .sort(([a], [b]) => a - b)
499
- .map(([lineNumber, kind]) => {
500
- const match = matchByLine.get(lineNumber);
501
- return {
502
- lineNumber,
503
- kind,
504
- text: fileLines?.[lineNumber - 1] ?? match?.text ?? "",
505
- matchIndex: match?.matchIndex,
506
- matchLength: match?.matchLength,
507
- };
508
- });
509
- }
510
-
511
- function truncateLineText(line: RenderedLine, maxLineLength: number): string {
512
- if (line.text.length <= maxLineLength) return line.text;
513
- if (line.kind === "context" || line.matchIndex === undefined) return `${line.text.slice(0, maxLineLength - 1)}…`;
514
- const matchLength = line.matchLength ?? 1;
515
- const side = Math.max(0, Math.floor((maxLineLength - matchLength) / 2));
516
- let start = Math.max(0, line.matchIndex - side);
517
- if (start + maxLineLength > line.text.length) start = Math.max(0, line.text.length - maxLineLength);
518
- const end = Math.min(line.text.length, start + maxLineLength);
519
- return `${start > 0 ? "…" : ""}${line.text.slice(start, end)}${end < line.text.length ? "…" : ""}`;
520
- }
521
-
522
- function renderGrepOutput(output: GrepQueryOutput, maxLineLength: number): string {
523
- const lines: string[] = [];
524
- for (const group of output.groups) {
525
- const lineCount = group.lineCount === undefined ? "" : ` (${group.lineCount} lines)`;
526
- lines.push(`${group.displayPath}${lineCount}`);
527
- for (const line of group.lines) {
528
- lines.push(`${line.lineNumber}${line.kind === "match" ? ":" : "-"} ${truncateLineText(line, maxLineLength)}`);
529
- }
530
- if (group.omittedInFile > 0) {
531
- lines.push(`… omitted ${group.omittedInFile} matches in file (maxPerFile ${output.maxPerFile})`);
532
- }
533
- }
534
- if (output.stoppedAfterLimit) lines.push(`… stopped after ${output.limit} matches (limit ${output.limit})`);
535
- else if (output.omittedByLimit > 0) lines.push(`… omitted ${output.omittedByLimit} matches (limit ${output.limit})`);
536
- return lines.length === 0 ? "No matches" : lines.join("\n");
537
- }
538
-
539
- async function executeGrepQuery(
540
- cwd: string,
541
- query: GrepQuery,
542
- limit: number,
543
- maxPerFile: number,
544
- contextOnly: boolean,
545
- signal: AbortSignal | undefined,
546
- stopAfterLimit: boolean,
547
- ): Promise<GrepQueryOutput> {
548
- if (query.patterns.length === 0) throw new Error("grep query requires at least one pattern");
549
- const context = normalizeNonNegativeInteger(query.context, 0);
550
- if (!stopAfterLimit) return executeQueryWithCounts(cwd, query, limit, maxPerFile, contextOnly, signal, context);
551
-
552
- const searchPaths = await resolveSearchPaths(cwd, query);
553
- const matchesByPath = new Map<string, RgMatch[]>();
554
- const omittedInFileByPath = new Map<string, number>();
555
- let remaining = limit;
556
-
557
- await runRipgrep(cwd, buildRgArgs(query, searchPaths), signal, (match) => {
558
- const matches = matchesByPath.get(match.absolutePath) ?? [];
559
- if (matches.length >= maxPerFile) {
560
- omittedInFileByPath.set(match.absolutePath, (omittedInFileByPath.get(match.absolutePath) ?? 0) + 1);
561
- return true;
562
- }
563
- matches.push(match);
564
- matchesByPath.set(match.absolutePath, matches);
565
- remaining -= 1;
566
- return remaining > 0;
567
- });
568
-
569
- const groups: GrepFileGroup[] = [];
570
- for (const [absolutePath, matches] of matchesByPath) {
571
- const group = await createGrepFileGroup(
572
- absolutePath,
573
- matches[0]?.displayPath ?? absolutePath,
574
- matches,
575
- context,
576
- contextOnly,
577
- omittedInFileByPath.get(absolutePath) ?? 0,
578
- );
579
- if (group) groups.push(group);
580
- }
581
-
582
- return { groups, omittedByLimit: 0, limit, maxPerFile, stoppedAfterLimit: remaining === 0 };
583
- }
584
-
585
- async function executeQueryWithCounts(
586
- cwd: string,
587
- query: GrepQuery,
588
- limit: number,
589
- maxPerFile: number,
590
- contextOnly: boolean,
591
- signal: AbortSignal | undefined,
592
- context: number,
593
- ): Promise<GrepQueryOutput> {
594
- const counts = await countMatches(cwd, query, signal);
595
- const selection = selectFiles(counts, limit, maxPerFile);
596
- const matchesByPath = await collectSelectedMatches(cwd, query, selection.selected, signal);
597
- const groups: GrepFileGroup[] = [];
598
-
599
- for (const file of selection.selected) {
600
- const group = await createGrepFileGroup(
601
- file.absolutePath,
602
- file.displayPath,
603
- matchesByPath.get(file.absolutePath) ?? [],
604
- context,
605
- contextOnly,
606
- file.omittedInFile,
607
- );
608
- if (group) groups.push(group);
609
- }
610
-
611
- return { groups, omittedByLimit: selection.omittedByLimit, limit, maxPerFile, stoppedAfterLimit: false };
612
- }
613
-
614
- async function createGrepFileGroup(
615
- absolutePath: string,
616
- displayPath: string,
617
- matches: readonly RgMatch[],
618
- context: number,
619
- contextOnly: boolean,
620
- omittedInFile: number,
621
- ): Promise<GrepFileGroup | undefined> {
622
- const sortedMatches = [...matches].sort((a, b) => a.lineNumber - b.lineNumber);
623
- if (sortedMatches.length === 0) return undefined;
624
- const fileLines = await readLines(absolutePath);
625
- const renderedLines = buildRenderedLines(fileLines, sortedMatches, context, contextOnly);
626
- if (renderedLines.length === 0) return undefined;
627
- return {
628
- displayPath,
629
- lineCount: fileLines?.length,
630
- lines: renderedLines,
631
- omittedInFile,
632
- };
633
- }
634
-
635
- function renderCallSummary(args: unknown): string {
636
- if (!isRecord(args)) return "";
637
- const queries = Array.isArray(args.queries) ? args.queries : [];
638
- const limit = normalizeCountLimit(typeof args.limit === "number" ? args.limit : undefined, 100);
639
- const maxPerFile = normalizeCountLimit(typeof args.maxPerFile === "number" ? args.maxPerFile : undefined, 8);
640
- const maxLineLength = normalizeMaxLineLength(
641
- typeof args.maxLineLength === "number" ? args.maxLineLength : undefined,
642
- );
643
- if (queries.length !== 1) {
644
- return `${queries.length} queries limit=${limit} maxPerFile=${maxPerFile} maxLineLength=${maxLineLength}`;
645
- }
646
- const query = isRecord(queries[0]) ? queries[0] : undefined;
647
- if (!query) return `invalid query limit=${limit} maxPerFile=${maxPerFile} maxLineLength=${maxLineLength}`;
648
- const patterns = renderStringArray(query.patterns, ",");
649
- const paths = renderStringArray(query.paths, " ", stripLeadingAt) || ".";
650
- const include = renderStringArray(query.include, ",", stripLeadingAt);
651
- const exclude = renderStringArray(query.exclude, ",", stripLeadingAt);
652
- const context = normalizeNonNegativeInteger(typeof query.context === "number" ? query.context : undefined, 0);
653
- const hidden = query.hidden === true || (query.hidden === undefined && args.hidden === true);
654
- const noIgnore = query.noIgnore === true || (query.noIgnore === undefined && args.noIgnore === true);
655
- const flags = [
656
- query.regex === true ? "regex" : "literal",
657
- typeof query.case === "string" ? query.case : "smart",
658
- query.word === true ? "word" : "",
659
- hidden ? "hidden" : "",
660
- noIgnore ? "noIgnore" : "",
661
- args.contextOnly === true ? "contextOnly" : "",
662
- args.stopAfterLimit === true ? "stopAfterLimit" : "",
663
- ]
664
- .filter(Boolean)
665
- .join(" ");
666
- return [
667
- `patterns=${patterns}`,
668
- `paths=${paths}`,
669
- include ? `include=${include}` : "",
670
- exclude ? `exclude=${exclude}` : "",
671
- `context=${context}`,
672
- `limit=${limit}`,
673
- `maxPerFile=${maxPerFile}`,
674
- `maxLineLength=${maxLineLength}`,
675
- flags,
676
- ]
677
- .filter(Boolean)
678
- .join(" ");
679
- }
680
-
681
- function renderStringArray(value: unknown, separator: string, map: (item: string) => string = (item) => item): string {
682
- if (value === undefined) return "";
683
- if (!Array.isArray(value)) return "<invalid-array>";
684
- return value.map((item) => (typeof item === "string" ? map(item) : "<invalid-string>")).join(separator);
685
- }
686
-
687
- function prepareGrepArguments(args: unknown): GrepParams {
688
- assertStructuredParams(args);
689
- const { hidden, noIgnore, ...params } = args;
690
- return {
691
- ...params,
692
- queries: args.queries.map((query) => ({
693
- ...query,
694
- hidden: query.hidden ?? hidden,
695
- noIgnore: query.noIgnore ?? noIgnore,
696
- })),
697
- };
698
- }
699
-
700
- export function createGrepTool(rowState: ToolRowStateStore) {
701
- return defineTool<typeof grepParams, ExploreTextDetails | undefined>({
702
- name: "grep",
703
- label: "grep",
704
- description: "Search file contents with structured queries.",
705
- promptSnippet:
706
- 'Search file contents. patterns, paths, include, and exclude are arrays of strings even for one value, e.g. include: ["*.ts"]. Use top-level hidden/noIgnore to apply them to all queries. Use stopAfterLimit for broad searches when first matches are enough.',
707
- parameters: grepParams,
708
- prepareArguments: prepareGrepArguments,
709
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
710
- params = prepareGrepArguments(params);
711
- if (params.queries.length === 0) throw new Error("grep requires at least one query");
712
- const limit = normalizeCountLimit(params.limit, 100);
713
- const maxPerFile = normalizeCountLimit(params.maxPerFile, 8);
714
- const maxLineLength = normalizeMaxLineLength(params.maxLineLength);
715
- const budgets = divideBudget(limit, params.queries.length);
716
- const parts: string[] = [];
717
- for (let i = 0; i < params.queries.length; i += 1) {
718
- const query = params.queries[i];
719
- if (!query) continue;
720
- const budget = budgets[i] ?? limit;
721
- const output = await executeGrepQuery(
722
- ctx.cwd,
723
- query,
724
- budget,
725
- maxPerFile,
726
- params.contextOnly === true,
727
- signal,
728
- params.stopAfterLimit === true,
729
- );
730
- const rendered = renderGrepOutput(output, maxLineLength);
731
- parts.push(params.queries.length === 1 ? rendered : `query ${i + 1}\n${rendered}`);
732
- }
733
- return createExploreTextResult(parts.join("\n"));
734
- },
735
- renderCall(args, theme, context) {
736
- rowState.watch(context.toolCallId, context.invalidate);
737
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
738
- const title = formatToolRowTitle(rowState, context.toolCallId, "grep", theme);
739
- const summary = renderCallSummary(args);
740
- text.setText(summary ? `${title} ${theme.fg("muted", summary)}` : title);
741
- return text;
742
- },
743
- renderResult(result, _options, _theme, context) {
744
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
745
- text.setText(context.expanded ? expandedExploreText(result) : "");
746
- return text;
747
- },
748
- });
749
- }