|
| 1 | +import { Effect, Layer, Schema } from "effect"; |
| 2 | +import type { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts"; |
| 3 | +import { SandboxIO } from "../../../sandbox/io.ts"; |
| 4 | +import { quoteArgv } from "../../../sandbox/shell/shell.ts"; |
| 5 | +import { Binary } from "../../../tool/binary.ts"; |
| 6 | +import { ToolPath } from "../../../tool/path.ts"; |
| 7 | +import * as Tool from "../../../tool/tool.ts"; |
| 8 | +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../../tool/truncate.ts"; |
| 9 | +import { posix } from "../../../util/posix.ts"; |
| 10 | +import { define } from "../../plugin.ts"; |
| 11 | + |
| 12 | +/** |
| 13 | + * The built-in find plugin: fd in the mounted sandbox. A virtual mount has no fd, |
| 14 | + * and the tool says so rather than emulating it. |
| 15 | + */ |
| 16 | + |
| 17 | +const FindParams = Schema.Struct({ |
| 18 | + pattern: Schema.String.annotate({ |
| 19 | + description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", |
| 20 | + }), |
| 21 | + path: Schema.optional( |
| 22 | + Schema.String.annotate({ description: "Directory to search in (default: current directory)" }), |
| 23 | + ), |
| 24 | + limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of results (default: 1000)" })), |
| 25 | +}); |
| 26 | + |
| 27 | +const FindDetails = Schema.Struct({ |
| 28 | + truncation: Schema.optional(Schema.Unknown), |
| 29 | + resultLimitReached: Schema.optional(Schema.Finite), |
| 30 | +}); |
| 31 | + |
| 32 | +const FindSuccess = Schema.Struct({ text: Schema.String, details: Schema.optional(FindDetails) }); |
| 33 | + |
| 34 | +class FindFailed extends Schema.TaggedError<FindFailed>()("FindFailed", { message: Schema.String }) {} |
| 35 | + |
| 36 | +const DEFAULT_LIMIT = 1000; |
| 37 | +const FD_ERROR = "[fd error]:"; |
| 38 | + |
| 39 | +export const findDef = Tool.define({ |
| 40 | + name: "find", |
| 41 | + label: "find", |
| 42 | + description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, |
| 43 | + promptSnippet: "Find files by glob pattern (respects .gitignore)", |
| 44 | + parameters: FindParams, |
| 45 | + constrainedSampling: { type: "json_schema", strict: "prefer" }, |
| 46 | + success: FindSuccess, |
| 47 | + failure: FindFailed, |
| 48 | + encodeContent: (success) => [{ type: "text", text: success.text }], |
| 49 | + encodeFailureContent: (failure) => [{ type: "text", text: failure.message }], |
| 50 | + encodeDetails: (success) => success.details, |
| 51 | +}); |
| 52 | + |
| 53 | +const fail = (message: string) => Effect.fail(new FindFailed({ message })); |
| 54 | + |
| 55 | +/** Relative to the search root, keeping a directory's trailing slash. */ |
| 56 | +const relativize = (result: string, searchPath: string) => { |
| 57 | + const relative = posix.isAbsolute(result) ? posix.relative(searchPath, result) : result; |
| 58 | + return result.endsWith("/") && !relative.endsWith("/") ? `${relative}/` : relative; |
| 59 | +}; |
| 60 | + |
| 61 | +const insideGitRepo = (fs: SandboxFileSystem.Interface, searchPath: string) => |
| 62 | + Effect.gen(function* () { |
| 63 | + for (let current = searchPath; ; current = posix.dirname(current)) { |
| 64 | + if (yield* fs.exists(posix.join(current, ".git")).pipe(Effect.orElseSucceed(() => false))) return true; |
| 65 | + if (posix.dirname(current) === current) return false; |
| 66 | + } |
| 67 | + }); |
| 68 | + |
| 69 | +export const findHandler: Tool.Handler< |
| 70 | + typeof FindParams, |
| 71 | + typeof FindSuccess, |
| 72 | + typeof FindFailed, |
| 73 | + SandboxIO.FileSystem | SandboxIO.Shell | SandboxIO.Current | Binary.HostBin |
| 74 | +> = (params) => |
| 75 | + Effect.gen(function* () { |
| 76 | + const fs = yield* SandboxIO.FileSystem; |
| 77 | + const shell = yield* SandboxIO.Shell; |
| 78 | + const sandbox = yield* SandboxIO.Current; |
| 79 | + const searchPath = posix.resolve(sandbox.cwd, ToolPath.normalize(params.path || ".")); |
| 80 | + const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT)); |
| 81 | + if (!(yield* fs.exists(searchPath).pipe(Effect.orElseSucceed(() => false)))) |
| 82 | + return yield* fail(`Path not found: ${searchPath}`); |
| 83 | + |
| 84 | + // fd honours .gitignore outside a repo only when told to. Inside one, its own |
| 85 | + // git awareness stops a parent's rules at a nested repo. |
| 86 | + const repo = yield* insideGitRepo(fs, searchPath); |
| 87 | + const argv = ["--glob", "--color=never", "--hidden", ...(repo ? [] : ["--no-require-git"])]; |
| 88 | + argv.push("--max-results", String(limit)); |
| 89 | + // fd matches a glob against the basename unless `--full-path`, which matches the |
| 90 | + // absolute path, so a pattern with a directory in it needs a leading `**/`. |
| 91 | + let pattern = params.pattern; |
| 92 | + if (pattern.includes("/")) { |
| 93 | + argv.push("--full-path"); |
| 94 | + if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`; |
| 95 | + } |
| 96 | + argv.push("--", pattern, searchPath); |
| 97 | + |
| 98 | + const run = yield* Binary.withCommand("fd", sandbox, shell, fs, (command) => |
| 99 | + shell.exec(quoteArgv([command, ...argv])), |
| 100 | + ); |
| 101 | + if ("_tag" in run) return yield* fail(run.message); |
| 102 | + |
| 103 | + // Some backends merge stderr into stdout; fd marks every error line, so they are told apart. |
| 104 | + const output = run.stdout |
| 105 | + .split("\n") |
| 106 | + .map((line) => line.replace(/\r$/, "").trim()) |
| 107 | + .filter((line) => line.length > 0); |
| 108 | + const lines = output.filter((line) => !line.startsWith(FD_ERROR)); |
| 109 | + const errors = [run.stderr.trim(), ...output.filter((line) => line.startsWith(FD_ERROR))] |
| 110 | + .filter((text) => text.length > 0) |
| 111 | + .join("\n"); |
| 112 | + if (run.exitCode !== 0 && lines.length === 0) return yield* fail(errors || `fd exited with code ${run.exitCode}`); |
| 113 | + if (lines.length === 0) return { text: "No files found matching pattern" }; |
| 114 | + |
| 115 | + // fd reports in thread order; sorting keeps the result repeatable. |
| 116 | + const results = lines.map((line) => relativize(line, searchPath)).sort(); |
| 117 | + const resultLimitReached = results.length >= limit; |
| 118 | + const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER }); |
| 119 | + const notices: Array<string> = []; |
| 120 | + const details: { truncation?: unknown; resultLimitReached?: number } = {}; |
| 121 | + if (resultLimitReached) { |
| 122 | + notices.push(`${limit} results limit reached. Use limit=${limit * 2} for more, or refine pattern`); |
| 123 | + details.resultLimitReached = limit; |
| 124 | + } |
| 125 | + if (truncation.truncated) { |
| 126 | + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); |
| 127 | + details.truncation = truncation; |
| 128 | + } |
| 129 | + const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content; |
| 130 | + return Object.keys(details).length > 0 ? { text, details } : { text }; |
| 131 | + }).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error))); |
| 132 | + |
| 133 | +/** The find tool: definition + handler, wired the testable (def/exec split) way. */ |
| 134 | +export const findTool = Tool.implement(findDef, findHandler); |
| 135 | + |
| 136 | +export const findPlugin = define({ |
| 137 | + id: "codework.tool.find", |
| 138 | + kind: "tool", |
| 139 | + optIn: true, |
| 140 | + setup: Effect.fn("FindPlugin.setup")(function* (ctx) { |
| 141 | + const mounted = Layer.mergeAll( |
| 142 | + Layer.succeed(SandboxIO.FileSystem, yield* SandboxIO.FileSystem), |
| 143 | + Layer.succeed(SandboxIO.Shell, yield* SandboxIO.Shell), |
| 144 | + Layer.succeed(SandboxIO.Current, yield* SandboxIO.Current), |
| 145 | + Layer.succeed(Binary.HostBin, ctx.paths.bin), |
| 146 | + ); |
| 147 | + ctx.plugin.tools.add(Tool.provide(findTool, mounted)); |
| 148 | + }), |
| 149 | +}); |
0 commit comments