|
|
@@ -1,78 +1,92 @@
|
|
|
import z from "zod"
|
|
|
import path from "path"
|
|
|
+import { Effect, Option } from "effect"
|
|
|
+import * as Stream from "effect/Stream"
|
|
|
import { Tool } from "./tool"
|
|
|
-import { Filesystem } from "../util/filesystem"
|
|
|
import DESCRIPTION from "./glob.txt"
|
|
|
import { Ripgrep } from "../file/ripgrep"
|
|
|
import { Instance } from "../project/instance"
|
|
|
-import { assertExternalDirectory } from "./external-directory"
|
|
|
+import { assertExternalDirectoryEffect } from "./external-directory"
|
|
|
+import { AppFileSystem } from "../filesystem"
|
|
|
|
|
|
-export const GlobTool = Tool.define("glob", {
|
|
|
- description: DESCRIPTION,
|
|
|
- parameters: z.object({
|
|
|
- pattern: z.string().describe("The glob pattern to match files against"),
|
|
|
- path: z
|
|
|
- .string()
|
|
|
- .optional()
|
|
|
- .describe(
|
|
|
- `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
|
|
- ),
|
|
|
- }),
|
|
|
- async execute(params, ctx) {
|
|
|
- await ctx.ask({
|
|
|
- permission: "glob",
|
|
|
- patterns: [params.pattern],
|
|
|
- always: ["*"],
|
|
|
- metadata: {
|
|
|
- pattern: params.pattern,
|
|
|
- path: params.path,
|
|
|
- },
|
|
|
- })
|
|
|
+export const GlobTool = Tool.defineEffect(
|
|
|
+ "glob",
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const rg = yield* Ripgrep.Service
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
|
|
|
- let search = params.path ?? Instance.directory
|
|
|
- search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
|
|
- await assertExternalDirectory(ctx, search, { kind: "directory" })
|
|
|
+ return {
|
|
|
+ description: DESCRIPTION,
|
|
|
+ parameters: z.object({
|
|
|
+ pattern: z.string().describe("The glob pattern to match files against"),
|
|
|
+ path: z
|
|
|
+ .string()
|
|
|
+ .optional()
|
|
|
+ .describe(
|
|
|
+ `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ ctx.ask({
|
|
|
+ permission: "glob",
|
|
|
+ patterns: [params.pattern],
|
|
|
+ always: ["*"],
|
|
|
+ metadata: {
|
|
|
+ pattern: params.pattern,
|
|
|
+ path: params.path,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
|
|
|
- const limit = 100
|
|
|
- const files = []
|
|
|
- let truncated = false
|
|
|
- for await (const file of Ripgrep.files({
|
|
|
- cwd: search,
|
|
|
- glob: [params.pattern],
|
|
|
- signal: ctx.abort,
|
|
|
- })) {
|
|
|
- if (files.length >= limit) {
|
|
|
- truncated = true
|
|
|
- break
|
|
|
- }
|
|
|
- const full = path.resolve(search, file)
|
|
|
- const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
|
|
|
- files.push({
|
|
|
- path: full,
|
|
|
- mtime: stats,
|
|
|
- })
|
|
|
- }
|
|
|
- files.sort((a, b) => b.mtime - a.mtime)
|
|
|
+ let search = params.path ?? Instance.directory
|
|
|
+ search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
|
|
+ yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
|
|
|
|
|
- const output = []
|
|
|
- if (files.length === 0) output.push("No files found")
|
|
|
- if (files.length > 0) {
|
|
|
- output.push(...files.map((f) => f.path))
|
|
|
- if (truncated) {
|
|
|
- output.push("")
|
|
|
- output.push(
|
|
|
- `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
+ const limit = 100
|
|
|
+ let truncated = false
|
|
|
+ const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
|
|
|
+ Stream.mapEffect((file) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const full = path.resolve(search, file)
|
|
|
+ const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
|
+ const mtime = info?.mtime.pipe(Option.map((d) => d.getTime()), Option.getOrElse(() => 0)) ?? 0
|
|
|
+ return { path: full, mtime }
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ Stream.take(limit + 1),
|
|
|
+ Stream.runCollect,
|
|
|
+ Effect.map((chunk) => [...chunk]),
|
|
|
+ )
|
|
|
|
|
|
- return {
|
|
|
- title: path.relative(Instance.worktree, search),
|
|
|
- metadata: {
|
|
|
- count: files.length,
|
|
|
- truncated,
|
|
|
- },
|
|
|
- output: output.join("\n"),
|
|
|
+ if (files.length > limit) {
|
|
|
+ truncated = true
|
|
|
+ files.length = limit
|
|
|
+ }
|
|
|
+ files.sort((a, b) => b.mtime - a.mtime)
|
|
|
+
|
|
|
+ const output = []
|
|
|
+ if (files.length === 0) output.push("No files found")
|
|
|
+ if (files.length > 0) {
|
|
|
+ output.push(...files.map((f) => f.path))
|
|
|
+ if (truncated) {
|
|
|
+ output.push("")
|
|
|
+ output.push(
|
|
|
+ `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: path.relative(Instance.worktree, search),
|
|
|
+ metadata: {
|
|
|
+ count: files.length,
|
|
|
+ truncated,
|
|
|
+ },
|
|
|
+ output: output.join("\n"),
|
|
|
+ }
|
|
|
+ }).pipe(Effect.orDie, Effect.runPromise),
|
|
|
}
|
|
|
- },
|
|
|
-})
|
|
|
+ }),
|
|
|
+)
|