245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agentu/pkg/llm"
|
|
)
|
|
|
|
const defaultOutlineMaxFiles = 200
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// ProjectOutlineTool — gitignore-aware file tree + symbol outline
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
type ProjectOutlineTool struct {
|
|
workingDir string
|
|
}
|
|
|
|
func NewProjectOutlineTool(workingDir string) *ProjectOutlineTool {
|
|
return &ProjectOutlineTool{workingDir: workingDir}
|
|
}
|
|
|
|
func (t *ProjectOutlineTool) Definition() llm.Tool {
|
|
return llm.Tool{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "project_outline",
|
|
Description: "Get the project file tree and code symbol outline in one call. Respects .gitignore. Use this first to understand project structure before reading individual files.",
|
|
Parameters: JSONSchema(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Subdirectory to outline. Defaults to the project root."},
|
|
"max_files": {"type": "integer", "description": "Maximum files to include in the outline. Defaults to 200."}
|
|
},
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type outlineArgs struct {
|
|
Path string `json:"path"`
|
|
MaxFiles int `json:"max_files"`
|
|
}
|
|
|
|
func (t *ProjectOutlineTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args outlineArgs
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
|
|
root, err := resolvePath(t.workingDir, args.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
limit := args.MaxFiles
|
|
if limit <= 0 {
|
|
limit = defaultOutlineMaxFiles
|
|
}
|
|
|
|
// Parse .gitignore files from the root upward.
|
|
ignorer := newGitignoreMatcher(root)
|
|
|
|
var out strings.Builder
|
|
out.WriteString(root)
|
|
out.WriteString("\n")
|
|
|
|
count := 0
|
|
truncated := false
|
|
|
|
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
// Skip always-ignored directories.
|
|
if d.IsDir() {
|
|
name := d.Name()
|
|
// Always skip VCS and common build-artifact directories.
|
|
switch name {
|
|
case ".git", ".hg", ".svn", "vendor", "node_modules",
|
|
"dist", "build", "__pycache__", "target":
|
|
return filepath.SkipDir
|
|
}
|
|
// Skip hidden directories other than .github and similar.
|
|
if strings.HasPrefix(name, ".") && name != ".github" && name != ".vscode" && name != ".devcontainer" {
|
|
return filepath.SkipDir
|
|
}
|
|
}
|
|
|
|
rel, _ := filepath.Rel(root, path)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
|
|
// Check gitignore.
|
|
if ignorer.shouldIgnore(rel, d.IsDir()) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if count >= limit {
|
|
truncated = true
|
|
return filepath.SkipAll
|
|
}
|
|
|
|
// Build indented tree entry.
|
|
depth := strings.Count(rel, string(filepath.Separator))
|
|
indent := strings.Repeat(" ", depth)
|
|
name := d.Name()
|
|
|
|
if d.IsDir() {
|
|
out.WriteString(fmt.Sprintf("%s%s/\n", indent, name))
|
|
return nil
|
|
}
|
|
|
|
count++
|
|
out.WriteString(indent + name)
|
|
|
|
// Extract symbols for source files.
|
|
ext := filepath.Ext(name)
|
|
if sourceFileExtensions[ext] {
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr == nil && len(data) <= 256*1024 {
|
|
symbols, symErr := extractSymbols(path, data)
|
|
if symErr == nil && len(symbols) > 0 {
|
|
out.WriteString(" — ")
|
|
out.WriteString(compactSymbolSummary(symbols))
|
|
}
|
|
}
|
|
}
|
|
|
|
out.WriteString("\n")
|
|
return nil
|
|
})
|
|
|
|
if err != nil && !errors.Is(err, filepath.SkipAll) {
|
|
return "", err
|
|
}
|
|
|
|
if truncated {
|
|
out.WriteString(fmt.Sprintf("\n... (%d files shown, more available; use file_list or code_symbols for details)\n", count))
|
|
}
|
|
|
|
return FormatResult(out.String()), nil
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Compact symbol summary for inline display in the file tree
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
func compactSymbolSummary(symbols []symbolInfo) string {
|
|
var parts []string
|
|
|
|
// Group by kind.
|
|
funcs := []string{}
|
|
methods := []string{}
|
|
types := []string{}
|
|
|
|
for _, s := range symbols {
|
|
switch s.Kind {
|
|
case "function":
|
|
funcs = append(funcs, s.Name)
|
|
case "method":
|
|
methods = append(methods, s.Name)
|
|
case "class", "interface", "struct", "enum", "trait", "alias", "func", "type", "impl":
|
|
types = append(types, s.Name)
|
|
}
|
|
// Skip imports and package for compact view.
|
|
}
|
|
|
|
if len(funcs) > 0 {
|
|
parts = append(parts, fmt.Sprintf("fn: %s", strings.Join(funcs, ", ")))
|
|
}
|
|
if len(methods) > 0 {
|
|
parts = append(parts, fmt.Sprintf("method: %s", strings.Join(methods, ", ")))
|
|
}
|
|
if len(types) > 0 {
|
|
parts = append(parts, fmt.Sprintf("type: %s", strings.Join(types, ", ")))
|
|
}
|
|
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Simple .gitignore matcher
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
type gitignoreMatcher struct {
|
|
patterns []string
|
|
}
|
|
|
|
func newGitignoreMatcher(root string) *gitignoreMatcher {
|
|
m := &gitignoreMatcher{}
|
|
// Walk up from root, collecting .gitignore patterns.
|
|
for dir := root; dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) {
|
|
giPath := filepath.Join(dir, ".gitignore")
|
|
data, err := os.ReadFile(giPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
// Remove leading / for anchored patterns; we handle all as relative.
|
|
line = strings.TrimPrefix(line, "/")
|
|
// Remove trailing / for directory-only patterns.
|
|
line = strings.TrimSuffix(line, "/")
|
|
m.patterns = append(m.patterns, line)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *gitignoreMatcher) shouldIgnore(path string, isDir bool) bool {
|
|
name := filepath.Base(path)
|
|
for _, pat := range m.patterns {
|
|
// Simple match against full relative path or just the basename.
|
|
matched, _ := filepath.Match(pat, path)
|
|
if matched {
|
|
return true
|
|
}
|
|
matched, _ = filepath.Match(pat, name)
|
|
if matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|