package tools import ( "context" "encoding/json" "fmt" "log" "time" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Late Registration ──────────────────────── func RegisterConversationSearch(stores store.Stores) { Register(&conversationSearchTool{stores: stores}) } // ═══════════════════════════════════════════ // conversation_search // ═══════════════════════════════════════════ type conversationSearchTool struct { BaseTool stores store.Stores } func (t *conversationSearchTool) Definition() ToolDef { return ToolDef{ Name: "conversation_search", DisplayName: "Search Chat", Category: "context", Description: "Search earlier messages in this conversation by keyword. " + "Useful when the conversation is long or has been summarized and " + "you need to find specific details from earlier discussion. " + "Returns matching message excerpts with timestamps.", Parameters: JSONSchema(map[string]interface{}{ "query": Prop("string", "Search keywords — use terms likely to appear in the messages"), "max_results": map[string]interface{}{ "type": "integer", "description": "Maximum results to return (1-20, default 5)", }, "role_filter": PropEnum( "Filter by message role (default: all)", "all", "user", "assistant", ), }, []string{"query"}), } } func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Query string `json:"query"` MaxResults int `json:"max_results"` RoleFilter string `json:"role_filter"` } 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.MaxResults <= 0 || args.MaxResults > 20 { args.MaxResults = 5 } // Build query — full-text search scoped to this channel // Uses on-the-fly to_tsvector (no index needed — channel-scoped is bounded) roleClause := "" queryArgs := []interface{}{execCtx.ChannelID, args.Query, args.MaxResults} if args.RoleFilter == "user" || args.RoleFilter == "assistant" { roleClause = "AND m.role = $4" queryArgs = append(queryArgs, args.RoleFilter) } rows, err := database.DB.QueryContext(ctx, fmt.Sprintf(` SELECT m.id, m.role, ts_headline('english', m.content, plainto_tsquery('english', $2), 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline, ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank, m.created_at FROM messages m WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.role IN ('user', 'assistant') AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2) %s ORDER BY rank DESC, m.created_at DESC LIMIT $3 `, roleClause), queryArgs...) if err != nil { return "", fmt.Errorf("search failed: %w", err) } defer rows.Close() type searchResult struct { MessageID string `json:"message_id"` Role string `json:"role"` Excerpt string `json:"excerpt"` Rank float64 `json:"rank"` Timestamp string `json:"timestamp"` } results := make([]searchResult, 0) for rows.Next() { var r searchResult var rank float64 var createdAt time.Time if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &rank, &createdAt); err != nil { continue } r.Rank = rank r.Timestamp = createdAt.Format(time.RFC3339) results = append(results, r) } log.Printf("🔍 conversation_search: %q → %d results in channel %s", args.Query, len(results), execCtx.ChannelID) out, _ := json.Marshal(map[string]interface{}{ "results": results, "query": args.Query, "total": len(results), "channel_id": execCtx.ChannelID, }) return string(out), nil }