package tools import ( "context" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "github.com/bmatcuk/doublestar/v4" ) // maxResults caps how many paths or matches a search tool returns. The count of // what was suppressed goes back too, so the model knows to narrow the pattern // rather than assume it has seen everything. const maxResults = 100 // skipDirs are never descended into. They are where a repo keeps things the // agent did not write and should not be reading by accident. var skipDirs = map[string]bool{ ".git": true, ".hg": true, ".svn": true, "node_modules": true, "vendor": true, "target": true, ".venv": true, "venv": true, "__pycache__": true, ".harness": true, ".next": true, ".cache": true, } // GlobArgs is the glob input. type GlobArgs struct { Pattern string `json:"pattern" jsonschema:"description=Glob pattern. ** matches across directories; a pattern with no slash is matched against the file name at any depth"` Path string `json:"path,omitempty" jsonschema:"description=Directory to search under; defaults to the working root"` } // NewGlob builds the glob tool. extra names additional directories that may be // searched but never written, such as a skill's own directory. func NewGlob(root string, extra ...string) Tool { return New("glob", strings.TrimSpace(` Find files by name. Returns paths relative to the working root, most recently modified first. "*.go" matches Go files at any depth; "internal/**/*_test.go" matches within a subtree. Use this instead of guessing where a file lives.`), func(ctx context.Context, in GlobArgs) (string, error) { if in.Pattern == "" { return "", fmt.Errorf("pattern is required") } base, err := jailWith(root, extra, resolveBase(root, in.Path)) if err != nil { return "", err } if info, err := os.Stat(base); err != nil || !info.IsDir() { return "", fmt.Errorf("no such directory: %s", displayPath(root, base)) } type hit struct { path string mod int64 } var hits []hit var scanned int err = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil // unreadable entries are skipped, not fatal } if ctx.Err() != nil { return ctx.Err() } if d.IsDir() { if p != base && shouldSkip(d.Name(), in.Pattern) { return fs.SkipDir } return nil } scanned++ rel, relErr := filepath.Rel(base, p) if relErr != nil { return nil } if !matchPath(in.Pattern, rel) { return nil } var mod int64 if info, statErr := d.Info(); statErr == nil { mod = info.ModTime().UnixNano() } hits = append(hits, hit{path: displayPath(root, p), mod: mod}) return nil }) if err != nil { return "", err } if len(hits) == 0 { return fmt.Sprintf("No files match %q under %s (%d files scanned).", in.Pattern, displayPath(root, base), scanned), nil } // Most recently modified first: in a repo being worked on, that is // usually the file the task is about. sort.Slice(hits, func(i, j int) bool { if hits[i].mod != hits[j].mod { return hits[i].mod > hits[j].mod } return hits[i].path < hits[j].path }) var b strings.Builder shown := hits if len(shown) > maxResults { shown = shown[:maxResults] } for _, h := range shown { b.WriteString(h.path) b.WriteByte('\n') } if len(hits) > len(shown) { fmt.Fprintf(&b, "\n(%d of %d shown; narrow the pattern to see the rest)", len(shown), len(hits)) } return b.String(), nil }) } // matchPath applies the pattern to the relative path, and — when the pattern // names no directory — also to the bare file name, so "*.go" finds Go files at // any depth rather than only at the top level. func matchPath(pattern, rel string) bool { rel = filepath.ToSlash(rel) if ok, err := doublestar.Match(pattern, rel); err == nil && ok { return true } if !strings.Contains(pattern, "/") { if ok, err := doublestar.Match(pattern, filepath.Base(rel)); err == nil && ok { return true } } return false } // shouldSkip prunes noise directories, unless the pattern explicitly names one. func shouldSkip(name, pattern string) bool { return skipDirs[name] && !strings.Contains(pattern, name) } // displayPath prefers a path relative to the working root: shorter to read and // shorter in the context window. func displayPath(root, p string) string { if root == "" { return p } rel, err := filepath.Rel(root, p) if err != nil || strings.HasPrefix(rel, "..") { return p } return filepath.ToSlash(rel) }