Vectrum
Sign In
157 lines · 4.6 KB · Go
package tools

import (
	"context"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/bmatcuk/doublestar/v4"

	"codey/internal/statedir"
)

// 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,
	statedir.Dir: true, statedir.LegacyDir: 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)
}