This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/tools/workspace_search.go
2026-03-08 16:54:17 +00:00

152 lines
4.2 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── workspace_search ────────────────────────
type workspaceSearchTool struct {
workspaceToolBase
stores store.Stores
embedder *knowledge.Embedder
}
func (t *workspaceSearchTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_search",
DisplayName: "Workspace Search",
Description: "Semantic search across workspace files. Finds code, documentation, and text content relevant to a natural language query. Returns matching file paths, content snippets, relevance scores, and approximate line numbers.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"query": Prop("string", "Natural language search query (e.g. 'database connection pooling', 'error handling middleware')"),
"top_k": map[string]interface{}{
"type": "integer",
"description": "Maximum number of results to return (default 10, max 20)",
"default": 10,
},
"file_pattern": Prop("string", "Optional glob pattern to filter results by file path (e.g. '*.go', 'src/*.ts'). Applied post-search."),
}, []string{"query"}),
}
}
func (t *workspaceSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Query string `json:"query"`
TopK int `json:"top_k"`
FilePattern string `json:"file_pattern"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Query == "" {
return "", fmt.Errorf("query is required")
}
if args.TopK <= 0 {
args.TopK = 10
}
if args.TopK > 20 {
args.TopK = 20
}
// Resolve workspace
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
if !w.IndexingEnabled {
return "", fmt.Errorf("workspace indexing is disabled for this workspace")
}
// Embed the query
embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, nil, []string{args.Query})
if err != nil {
return "", fmt.Errorf("failed to embed query: %w", err)
}
if len(embedResult.Vectors) == 0 {
return "", fmt.Errorf("embedding returned no vectors")
}
queryVec := embedResult.Vectors[0]
// Fetch more if we'll post-filter by glob
searchLimit := args.TopK
if args.FilePattern != "" {
searchLimit = args.TopK * 3
if searchLimit > 50 {
searchLimit = 50
}
}
results, err := t.stores.Workspaces.SimilaritySearch(ctx, w.ID, queryVec, 0.3, searchLimit)
if err != nil {
return "", fmt.Errorf("search failed: %w", err)
}
// Apply glob filter if specified
if args.FilePattern != "" {
results = filterByGlob(results, args.FilePattern)
}
// Cap at top_k
if len(results) > args.TopK {
results = results[:args.TopK]
}
// Format response
formatted := make([]map[string]interface{}, len(results))
for i, r := range results {
m := map[string]interface{}{
"file_path": r.FilePath,
"content": r.Content,
"score": fmt.Sprintf("%.3f", r.Score),
}
if r.LineHint > 0 {
m["line_hint"] = r.LineHint
}
formatted[i] = m
}
resp := map[string]interface{}{
"query": args.Query,
"count": len(formatted),
"results": formatted,
}
if args.FilePattern != "" {
resp["file_pattern"] = args.FilePattern
}
if len(formatted) == 0 {
resp["message"] = "No matching results found. Try a different query or check that workspace files have been indexed."
}
b, _ := json.Marshal(resp)
return string(b), nil
}
// filterByGlob filters search results by a glob pattern.
func filterByGlob(results []models.WorkspaceChunkResult, pattern string) []models.WorkspaceChunkResult {
var filtered []models.WorkspaceChunkResult
for _, r := range results {
// Try matching against basename
matched, _ := filepath.Match(pattern, filepath.Base(r.FilePath))
if !matched && strings.Contains(pattern, "/") {
// Try full path match if pattern contains directory separator
matched, _ = filepath.Match(pattern, r.FilePath)
}
if matched {
filtered = append(filtered, r)
}
}
return filtered
}