feat(find): grouped output by directory and clamped limit to 200
- Changed output format to group results under `# /` headers to reduce token usage for shared path prefixes. - Clamped the `limit` parameter to 1-200 (default 200) instead of the previous 1000. - Updated tests to assert against raw file lists instead of parsed text output.
This commit is contained in:
@@ -5,17 +5,18 @@ Finds files and directories using fast pattern matching that works with any code
|
||||
- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
|
||||
- `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
|
||||
- `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
|
||||
- `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
|
||||
- `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
|
||||
- You SHOULD perform multiple searches in parallel when potentially useful
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
Matching file and directory paths sorted by modification time (most recent first). Directories are suffixed with `/`. Truncated at 1000 entries or 50KB (configurable via `limit`).
|
||||
Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
|
||||
</output>
|
||||
|
||||
<examples>
|
||||
# Find files
|
||||
`{"paths": ["src/**/*.ts"], "limit": 1000}`
|
||||
`{"paths": ["src/**/*.ts"]}`
|
||||
# Multiple targets — separate array elements
|
||||
`{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
|
||||
# Find gitignored files like .env
|
||||
|
||||
@@ -39,14 +39,15 @@ const findSchema = z
|
||||
paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
|
||||
hidden: z.boolean().default(true).describe("include hidden files").optional(),
|
||||
gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
|
||||
limit: z.number().default(1000).describe("max results").optional(),
|
||||
limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
|
||||
timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type FindToolInput = z.infer<typeof findSchema>;
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 200;
|
||||
const DEFAULT_GLOB_TIMEOUT_MS = 5000;
|
||||
const MIN_GLOB_TIMEOUT_MS = 500;
|
||||
const MAX_GLOB_TIMEOUT_MS = 60_000;
|
||||
@@ -78,6 +79,37 @@ export function validateFindPathInputs(paths: readonly string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group find matches by their directory so the model doesn't pay repeated
|
||||
* tokens for shared path prefixes. Preserves the input order: groups appear in
|
||||
* the order their first member was emitted (mtime-desc for native glob), and
|
||||
* within a group entries keep their relative order.
|
||||
*/
|
||||
export function formatFindGroupedOutput(paths: readonly string[]): string {
|
||||
if (paths.length === 0) return "";
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const entry of paths) {
|
||||
const hasTrailingSlash = entry.endsWith("/");
|
||||
const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
const dir = slash === -1 ? "" : trimmed.slice(0, slash);
|
||||
const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
||||
const label = hasTrailingSlash ? `${base}/` : base;
|
||||
const list = groups.get(dir);
|
||||
if (list) list.push(label);
|
||||
else groups.set(dir, [label]);
|
||||
}
|
||||
const sections: string[] = [];
|
||||
for (const [dir, entries] of groups) {
|
||||
if (dir === "") {
|
||||
sections.push(entries.join("\n"));
|
||||
} else {
|
||||
sections.push(`# ${dir}/\n${entries.join("\n")}`);
|
||||
}
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export interface FindToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
resultLimitReached?: number;
|
||||
@@ -195,11 +227,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
||||
if (searchPath === "/") {
|
||||
throw new ToolError("Searching from root directory '/' is not allowed");
|
||||
}
|
||||
const rawLimit = limit ?? DEFAULT_LIMIT;
|
||||
const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
|
||||
if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
|
||||
const requestedLimit = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
|
||||
throw new ToolError("Limit must be a positive number");
|
||||
}
|
||||
const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
|
||||
const includeHidden = hidden ?? true;
|
||||
const useGitignore = gitignore ?? true;
|
||||
const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
|
||||
@@ -241,7 +273,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
||||
const listLimit = applyListLimit(files, { limit: effectiveLimit });
|
||||
const limited = listLimit.items;
|
||||
const limitMeta = listLimit.meta;
|
||||
const baseOutput = limited.join("\n");
|
||||
const baseOutput = formatFindGroupedOutput(limited);
|
||||
const trailingNotes: string[] = [];
|
||||
if (notice) trailingNotes.push(notice);
|
||||
if (missingPathsNote) trailingNotes.push(missingPathsNote);
|
||||
|
||||
@@ -1683,13 +1683,9 @@ function b() {
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(outputLines).toContain("visible.txt");
|
||||
expect(outputLines).toContain(".secret/hidden.txt");
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
expect(files).toContain("visible.txt");
|
||||
expect(files).toContain(".secret/hidden.txt");
|
||||
});
|
||||
|
||||
it("should respect .gitignore", async () => {
|
||||
@@ -1727,12 +1723,7 @@ function b() {
|
||||
paths: [`${testDir}/**/auth-actions.spec.ts`],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(outputLines).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
|
||||
expect(result.details?.files).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
|
||||
});
|
||||
|
||||
it("should render nested glob results relative to the session cwd", async () => {
|
||||
@@ -1744,12 +1735,7 @@ function b() {
|
||||
paths: ["apps/daemon/src/**/daemon-telemetry.ts"],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(outputLines).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
|
||||
expect(result.details?.files).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
|
||||
});
|
||||
|
||||
it("should not double-prefix multi-pattern results under a shared base", async () => {
|
||||
@@ -1764,13 +1750,8 @@ function b() {
|
||||
paths: ["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
expect(outputLines).toEqual(["apps/client/src/client.ts", "apps/daemon/src/daemon.ts"]);
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
expect(files).toEqual(["apps/client/src/client.ts", "apps/daemon/src/daemon.ts"]);
|
||||
});
|
||||
|
||||
it("should not disable gitignore after an ignored broad hidden-file search finds no matches", async () => {
|
||||
@@ -1803,13 +1784,8 @@ function b() {
|
||||
paths: [`${testDir}/pkg/**/*`],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
expect(outputLines).toEqual(["pkg/file.txt", "pkg/nested/", "pkg/nested/deep.txt"]);
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
expect(files).toEqual(["pkg/file.txt", "pkg/nested/", "pkg/nested/deep.txt"]);
|
||||
});
|
||||
|
||||
it("should match a directory by glob and emit it with trailing slash", async () => {
|
||||
@@ -1821,13 +1797,8 @@ function b() {
|
||||
paths: [`${testDir}/**/tests`],
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
expect(outputLines).toEqual(["alpha/tests/", "beta/tests/"]);
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
expect(files).toEqual(["alpha/tests/", "beta/tests/"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
### Removed
|
||||
|
||||
- Removed the `A-B!` / `A!` deletion operator. Use `A-B:` with the desired payload (or empty payload to blank the range) instead.
|
||||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [15.5.4] - 2026-05-27
|
||||
|
||||
Reference in New Issue
Block a user