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 @@ -170,6 +170,7 @@ func optionalTools() []tool.Tool {
tool.McpAuthTool{},
tool.DiagnosticsTool{},
tool.CodeSearchTool{},
tool.CodeMatchTool{},
tool.CoreMemoryAppendTool{},
tool.CoreMemoryReplaceTool{},
tool.CoreMemoryRethinkTool{},
Expand Down
45 changes: 45 additions & 0 deletions internal/engine/code_index_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package engine

import (
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
"github.com/GrayCodeAI/hawk/internal/intelligence/repomap"
)

// yaadCodeIndexer adapts *memory.YaadBridge to repomap.CodeIndexer. The two
// packages define identical CodeSearchResult shapes under different names, so
// the adapter only has to convert the SearchCode return type; every other
// method forwards directly.
type yaadCodeIndexer struct {
bridge *memory.YaadBridge
}

func (a *yaadCodeIndexer) IndexCodeChunk(path, content, symbol, lang string, start, end, tokens int, hash string) error {
return a.bridge.IndexCodeChunk(path, content, symbol, lang, start, end, tokens, hash)
}

func (a *yaadCodeIndexer) SearchCode(query string, limit int) ([]repomap.CodeSearchResult, error) {
results, err := a.bridge.SearchCode(query, limit)
if err != nil {
return nil, err
}
out := make([]repomap.CodeSearchResult, 0, len(results))
for _, r := range results {
out = append(out, repomap.CodeSearchResult{
Path: r.Path, StartLine: r.StartLine, EndLine: r.EndLine,
Content: r.Content, Symbol: r.Symbol, Score: r.Score,
})
}
return out, nil
}

func (a *yaadCodeIndexer) GetFileHash(path string) (string, error) {
return a.bridge.GetFileHash(path)
}

func (a *yaadCodeIndexer) ClearFileChunks(path string) error {
return a.bridge.ClearFileChunks(path)
}

func (a *yaadCodeIndexer) ListIndexedPaths() ([]string, error) {
return a.bridge.ListIndexedPaths()
}
1 change: 1 addition & 0 deletions internal/engine/safety/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ var toolPolicies = map[string]ToolPolicy{
"Outline": {Name: "Outline", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"SmartRead": {Name: "SmartRead", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"GitHistory": {Name: "GitHistory", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, 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 @@ -315,6 +315,8 @@ func canonicalToolName(name string) string {
return "WebFetch"
case "web_search", "websearch":
return "WebSearch"
case "code_match", "codematch", "match_code":
return "CodeMatch"
case "tool_health", "toolhealth", "tools_health":
return "ToolHealth"
case "project_verify", "projectverify", "verify_project":
Expand Down
58 changes: 47 additions & 11 deletions internal/engine/tool_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/engine/diff"
"github.com/GrayCodeAI/hawk/internal/hooks"
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
"github.com/GrayCodeAI/hawk/internal/intelligence/repomap"
"github.com/GrayCodeAI/hawk/internal/observability/metrics"
"github.com/GrayCodeAI/hawk/internal/observability/oteltrace"
"github.com/GrayCodeAI/hawk/internal/prompts"
Expand Down Expand Up @@ -478,17 +479,52 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid
AskUserFn: s.deps.askUser,
CommitMessageChatFn: commitChat,
YaadBridge: yaad,
SpecSlugGet: func() string { return s.deps.permissions.SpecSlug() },
SpecSlugSet: func(slug string) { s.deps.permissions.SetSpecSlug(slug) },
AllowedDirectories: s.deps.permissions.AllowedDirs(),
SandboxMode: sbMode,
BackgroundManager: s.EnsureBackgroundManager(),
ReadOnlyBash: s.ReadOnlyBash(),
WorkingDir: s.WorkingDir(),
AvailableTools: available,
Registry: s.registry,
AutoCommit: s.AutoCommit(),
TaskExecutor: s.deps.taskExec,
// Semantic code search backed by the yaad code-chunk index. Wiring the
// closures here makes CodeSearchTool functional in production (the
// interface was declared but never bound). Refresh rebuilds only
// added/changed files via content-hash staleness.
CodeSearchFn: func(cctx context.Context, query string, limit int) ([]tool.CodeSearchResult, error) {
if yaad == nil {
return nil, fmt.Errorf("code search unavailable: no memory bridge")
}
results, err := yaad.SearchCode(query, limit)
if err != nil {
return nil, err
}
out := make([]tool.CodeSearchResult, 0, len(results))
for _, r := range results {
out = append(out, tool.CodeSearchResult{
Path: r.Path, StartLine: r.StartLine, EndLine: r.EndLine,
Content: r.Content, Symbol: r.Symbol, Language: tool.LanguageForFile(r.Path), Score: r.Score,
})
}
return out, nil
},
RefreshCodeIndexFn: func(cctx context.Context) error {
if yaad == nil {
return fmt.Errorf("code index refresh unavailable: no memory bridge")
}
dir := s.WorkingDir()
if dir == "" {
return fmt.Errorf("code index refresh unavailable: no working directory")
}
if err := yaad.InitCodeIndex(); err != nil {
return err
}
_, _, _, err := repomap.IncrementalReindex(dir, nil, &yaadCodeIndexer{yaad})
return err
},
SpecSlugGet: func() string { return s.deps.permissions.SpecSlug() },
SpecSlugSet: func(slug string) { s.deps.permissions.SetSpecSlug(slug) },
AllowedDirectories: s.deps.permissions.AllowedDirs(),
SandboxMode: sbMode,
BackgroundManager: s.EnsureBackgroundManager(),
ReadOnlyBash: s.ReadOnlyBash(),
WorkingDir: s.WorkingDir(),
AvailableTools: available,
Registry: s.registry,
AutoCommit: s.AutoCommit(),
TaskExecutor: s.deps.taskExec,
})
// Bridge session sandbox policy onto the context so Bash/PowerShell
// WrapCommand actually applies. Path guards already read ToolContext.SandboxMode;
Expand Down
118 changes: 118 additions & 0 deletions internal/tool/code_match.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package tool

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)

// CodeMatchTool performs structural, AST-level code search using tree-sitter
// query patterns — the hawk counterpart to cocoindex-code's by-example
// structural grep. Patterns are tree-sitter query S-expressions over the
// language grammar, e.g. for Go:
//
// (function_declaration name: (identifier) @name) @fn
//
// Captures (@name) surface per match so callers extract identifiers without
// regex fragility. Unlike Grep (textual), matches are syntax-aware: comments
// and strings cannot produce false positives.
type CodeMatchTool struct{}

func (CodeMatchTool) Name() string { return "CodeMatch" }
func (CodeMatchTool) RiskLevel() string { return "low" }
func (CodeMatchTool) Aliases() []string { return []string{"code_match", "match_code"} }

func (CodeMatchTool) Description() string {
return `Structural code search with tree-sitter query patterns. Matches the AST, not text: comments/strings cannot false-positive. Pattern is a tree-sitter query S-expression; use @captures to extract parts. Examples - Go functions: "(function_declaration name: (identifier) @name) @fn" | Go calls of one function: "(call_expression function: (identifier) @callee) @call" | Python defs: "(function_definition name: (identifier) @name) @fn". Language auto-detects per file; restrict with language=go|python|typescript|tsx.`
}

func (CodeMatchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"pattern": map[string]interface{}{
"type": "string",
"description": "Tree-sitter query pattern (S-expression). Captures (@name) are returned per match.",
},
"path": map[string]interface{}{
"type": "string",
"description": "Project directory (default: session working directory).",
},
"language": map[string]interface{}{
"type": "string",
"enum": []string{"go", "python", "typescript", "tsx"},
"description": "Restrict to one language (default: all supported).",
},
"limit": map[string]interface{}{
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Maximum matches total (default 30).",
},
},
"required": []string{"pattern"},
}
}

// codeMatchHit is one structural match.
type codeMatchHit struct {
File string `json:"file"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Captures []string `json:"captures,omitempty"`
Snippet string `json:"snippet"`
}

// codeMatchLanguages maps file extensions to supported grammar names.
var codeMatchExtLang = map[string]string{
".go": "go", ".py": "python",
".ts": "typescript", ".tsx": "tsx", ".mts": "typescript", ".cts": "typescript",
}

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

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)
}
}
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("resolve project path: %w", err)
}
if err := validatePathAllowed(ctx, absRoot); err != nil {
return "", err
}
if params.Limit <= 0 {
params.Limit = 30
}
if params.Limit > 200 {
params.Limit = 200
}

return runCodeMatch(ctx, absRoot, params.Pattern, params.Language, params.Limit)
}
Loading
Loading