Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ func optionalTools() []tool.Tool {
tool.DiagnosticsTool{},
tool.CodeSearchTool{},
tool.CodeMatchTool{},
tool.FuzzyFindTool{},
tool.ToolsetTool{},
tool.CoreMemoryAppendTool{},
tool.CoreMemoryReplaceTool{},
Expand Down
1 change: 1 addition & 0 deletions internal/engine/safety/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var toolPolicies = map[string]ToolPolicy{
"SmartRead": {Name: "SmartRead", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"FuzzyFind": {Name: "FuzzyFind", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Toolset": {Name: "Toolset", Capabilities: nil, DefaultRisk: RiskLow},
"CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/safety/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ func canonicalToolName(name string) string {
return "WebSearch"
case "code_match", "codematch", "match_code":
return "CodeMatch"
case "fuzzy_find", "fuzzyfind", "ffind":
return "FuzzyFind"
case "toolset":
return "Toolset"
case "tool_health", "toolhealth", "tools_health":
Expand Down
163 changes: 163 additions & 0 deletions internal/fuzzyfind/fuzzyfind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Package fuzzyfind implements a standalone fuzzy file finder with graceful
// degradation, adopted from grok-build's xai-fuzzy-file-search: results are
// scored by substring match quality (exact basename > basename prefix >
// basename contains > all-segments > path contains > camel-case abbreviation)
// with a path-length tiebreak (shorter = more specific match).
package fuzzyfind

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)

// Match is one scored file path.
type Match struct {
Path string `json:"path"`
Score int `json:"score"`
}

// Finder searches a directory tree for files matching a query.
type Finder struct {
root string
maxFiles int
}

// New creates a Finder rooted at dir. Unlike the grok-build original (which
// probes goroutine spawnability for three-tier degradation), Go goroutines
// are always available so the mode is always full; kept simple by design.
func New(dir string) (*Finder, error) {
info, err := os.Stat(dir)
if err != nil {
return nil, fmt.Errorf("fuzzyfind: stat root: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("fuzzyfind: not a directory: %s", dir)
}
return &Finder{root: dir, maxFiles: 50_000}, nil
}

var skipDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true,
".hawk": true, "__pycache__": true, "dist": true,
"target": true, ".next": true, "build": true,
}

// Search walks the tree and returns top-k paths matching query, sorted by
// descending score then ascending path length.
func (f *Finder) Search(query string, k int) []Match {
if k <= 0 {
k = 20
}
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return nil // empty query returns nothing (browse mode is caller-side)
}
var paths []string
_ = filepath.WalkDir(f.root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := d.Name()
if name == ".git" || skipDirs[name] {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(f.root, path)
if relErr != nil {
return nil
}
if len(paths) >= f.maxFiles {
return filepath.SkipAll
}
paths = append(paths, filepath.ToSlash(rel))
return nil
})

var out []Match
for _, p := range paths {
sc := score(p, query)
if sc > 0 {
out = append(out, Match{Path: p, Score: sc})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return len(out[i].Path) < len(out[j].Path)
})
if len(out) > k {
out = out[:k]
}
return out
}

// score computes relevance for a lowered path against a lowered query.
func score(path, query string) int {
low := strings.ToLower(path)
base := low[strings.LastIndexByte(low, '/')+1:]
switch {
case base == query:
return 1000
case strings.HasPrefix(base, query):
return 800
case strings.Contains(base, query):
return 600
case containsAllSegments(low, query):
return 400
case strings.Contains(low, query):
return 200
case matchAbbrev(path, query):
return 100
default:
return 0
}
}

// containsAllSegments requires every space-separated query term somewhere in
// the path; only meaningful for multi-word queries.
func containsAllSegments(low, query string) bool {
fields := strings.Fields(query)
if len(fields) < 2 {
return false
}
for _, t := range fields {
if !strings.Contains(low, t) {
return false
}
}
return true
}

// matchAbbrev scores camelCase / snake_case abbreviations where each rune of
// the query maps to the start of a successive word in the basename.
func matchAbbrev(path, query string) bool {
base := path[strings.LastIndexByte(path, '/')+1:]
var words []string
start := 0
for i := 0; i < len(base); i++ {
c := base[i]
if i > 0 && (c == '_' || c == '-' || c == '.' ||
(c >= 'A' && c <= 'Z' && base[i-1] >= 'a' && base[i-1] <= 'z')) {
words = append(words, strings.ToLower(base[start:i]))
start = i
}
}
words = append(words, strings.ToLower(base[start:]))

q := strings.ToLower(query)
qi := 0
for _, w := range words {
for _, r := range w {
if qi < len(q) && rune(q[qi]) == r {
qi++
}
}
}
return qi == len(q)
}
108 changes: 108 additions & 0 deletions internal/fuzzyfind/fuzzyfind_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package fuzzyfind

import (
"os"
"path/filepath"
"strings"
"testing"
)

func makeTree(t *testing.T, files ...string) string {
t.Helper()
root := t.TempDir()
for _, f := range files {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}

func TestExactBasenameHighestScore(t *testing.T) {
root := makeTree(
t,
"src/config.go",
"src/config_test.go",
"internal/app/config_loader.go",
"docs/readme.md",
)
f, _ := New(root)
matches := f.Search("config.go", 10)
if len(matches) == 0 || matches[0].Path != "src/config.go" {
t.Fatalf("top = %+v", matches)
}
if matches[0].Score <= matches[1].Score {
t.Fatalf("exact basename should outscore partial: %+v", matches[:2])
}
}

func TestSubstringAndMultiWordQuery(t *testing.T) {
root := makeTree(
t,
"internal/engine/cache_gate.go",
"internal/engine/compact.go",
"web/gate.json",
)
f, _ := New(root)
matches := f.Search("cache gate", 5)
if len(matches) == 0 || matches[0].Path != "internal/engine/cache_gate.go" {
t.Fatalf("multi-word top = %+v", matches)
}
}

func TestEmptyQueryReturnsNothing(t *testing.T) {
root := makeTree(t, "a.txt")
f, _ := New(root)
if got := f.Search("", 10); len(got) != 0 {
t.Fatal("empty query should return nothing")
}
}

func TestSkipDirsExcluded(t *testing.T) {
root := makeTree(t, "vendor/lib.go", "src/main.go")
f, _ := New(root)
matches := f.Search("lib", 20)
for _, m := range matches {
if filepath.HasPrefix(m.Path, "vendor") {
t.Fatal("vendor leaked into results")
}
}
}

func TestLimitK(t *testing.T) {
files := make([]string, 30)
for i := range files {
files[i] = filepath.Join("dir", stringsRepeat("f", i+1)+".go")
}
root := makeTree(t, files...)
f, _ := New(root)
matches := f.Search("f", 3)
if len(matches) != 3 {
t.Fatalf("k limit = %d, want 3", len(matches))
}
}

func TestNewNotDirectory(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
os.WriteFile(p, []byte("x"), 0o644)
if _, err := New(p); err == nil {
t.Fatal("expected error for non-dir root")
}
}

func TestMatchAbbrevCamelCase(t *testing.T) {
if !matchAbbrev("internal/engine/CachePlanner.go", "CP") {
t.Fatal("camel-case abbreviation not matched")
}
if matchAbbrev("internal/engine/compact.go", "XYZ") {
t.Fatal("false positive abbreviation match")
}
}

func stringsRepeat(s string, n int) string {
return strings.Repeat(s, n)
}
99 changes: 99 additions & 0 deletions internal/tool/fuzzy_find_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package tool

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/GrayCodeAI/hawk/internal/fuzzyfind"
)

// FuzzyFindTool searches a project tree for files matching a fuzzy query,
// returning ranked results (exact basename > basename prefix > contains >
// multi-word > camel-case abbreviation). Complements Glob (exact patterns)
// and Grep (exact text) when the agent knows roughly what it's looking for
// but not the exact path.
type FuzzyFindTool struct{}

func (FuzzyFindTool) Name() string { return "FuzzyFind" }
func (FuzzyFindTool) RiskLevel() string { return "low" }
func (FuzzyFindTool) Aliases() []string { return []string{"fuzzy_find", "ffind"} }
func (FuzzyFindTool) Description() string {
return "Fuzzy-search a project for files by name. Handles partial names, camel-case abbreviations (CP→CachePlanner), and multi-word queries (cache gate→cache_gate.go). Returns ranked paths."
}

func (FuzzyFindTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Fuzzy query: full or partial file name, camel-case abbreviation, or space-separated terms.",
},
"path": map[string]interface{}{
"type": "string",
"description": "Project directory (default: session working directory).",
},
"limit": map[string]interface{}{
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum results (default 20).",
},
},
"required": []string{"query"},
}
}

func (FuzzyFindTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
var params struct {
Query string `json:"query"`
Path string `json:"path"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(input, &params); err != nil {
return "", fmt.Errorf("invalid input: %w", err)
}
if strings.TrimSpace(params.Query) == "" {
return "", fmt.Errorf("query is required")
}

root := params.Path
if root == "" {
if tc := GetToolContext(ctx); tc != nil && tc.WorkingDir != "" {
root = tc.WorkingDir
} else {
var err error
root, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve working directory: %w", err)
}
}
}
if err := validatePathAllowed(ctx, root); err != nil {
return "", err
}
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}

finder, err := fuzzyfind.New(root)
if err != nil {
return "", err
}
matches := finder.Search(params.Query, params.Limit)
if len(matches) == 0 {
return fmt.Sprintf("No files matching %q.", params.Query), nil
}
out, _ := json.MarshalIndent(map[string]interface{}{
"query": params.Query,
"matches": len(matches),
"results": matches,
}, "", " ")
return string(out), nil
}
Loading
Loading