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 { 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 }