| 1 | package historycatalog |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/retrieval" |
| 9 | ) |
| 10 | |
| 11 | func (c *Catalog) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { |
| 12 | out := SearchResult{Items: []Candidate{}, Revision: c.revision.Load(), Partial: c.Status().Pending > 0} |
| 13 | err := c.searchCandidates(ctx, req, false, func(item Candidate) error { |
| 14 | out.Items = append(out.Items, item) |
| 15 | return nil |
| 16 | }) |
| 17 | return out, err |
| 18 | } |
| 19 | |
| 20 | func (c *Catalog) searchCandidates(ctx context.Context, req SearchRequest, captureAll bool, visit func(Candidate) error) error { |
| 21 | query, args, err := historySearchQuery(req, captureAll) |
| 22 | if err != nil { |
| 23 | return err |
| 24 | } |
| 25 | rows, err := c.searchDB(ctx).QueryContext(ctx, query, args...) |
| 26 | if err != nil { |
| 27 | return err |
| 28 | } |
| 29 | defer rows.Close() |
| 30 | for rows.Next() { |
| 31 | var item Candidate |
| 32 | if err := rows.Scan(&item.RowID, &item.SessionPath, &item.Root, &item.Source, &item.Scope, &item.WorkspaceRoot, &item.ContentDigest, |
| 33 | &item.MessageIndex, &item.PartIndex, &item.Role, &item.Kind, &item.ToolName, &item.Rank, |
| 34 | &item.SessionTitle, &item.TopicTitle, &item.LastActivityAt); err != nil { |
| 35 | return err |
| 36 | } |
| 37 | if !catalogPathWithin(item.SessionPath, item.Root) { |
| 38 | continue |
| 39 | } |
| 40 | if item.Rank < 0 { |
| 41 | item.Score = -item.Rank |
| 42 | } else { |
| 43 | item.Score = 1 / (1 + item.Rank) |
| 44 | } |
| 45 | if err := visit(item); err != nil { |
| 46 | return err |
| 47 | } |
| 48 | } |
| 49 | return rows.Err() |
| 50 | } |
| 51 | |
| 52 | func historySearchQuery(req SearchRequest, captureAll bool) (string, []any, error) { |
| 53 | terms, err := retrieval.QueryTerms(req.Query) |
| 54 | if err != nil { |
| 55 | return "", nil, err |
| 56 | } |
| 57 | limit := req.Limit |
| 58 | if limit <= 0 { |
| 59 | limit = DefaultLimit |
| 60 | } |
| 61 | if limit > MaxLimit { |
| 62 | limit = MaxLimit |
| 63 | } |
| 64 | if captureAll { |
| 65 | limit = 2147483647 |
| 66 | } |
| 67 | match := make([]string, 0, len(terms)) |
| 68 | for _, term := range terms { |
| 69 | match = append(match, `"`+strings.ReplaceAll(term, `"`, `""`)+`"`) |
| 70 | } |
| 71 | where := []string{`history_fts MATCH ?`, `s.health='ok'`, `s.missing_since=0`} |
| 72 | args := []any{strings.Join(match, " OR ")} |
| 73 | appendHistorySearchFilters(req, &where, &args) |
| 74 | base := `SELECT d.id AS id,d.source_path AS source_path,s.root AS root,s.source AS source,s.scope AS scope, |
| 75 | s.workspace_root AS workspace_root,s.content_digest AS content_digest,d.message_index AS message_index, |
| 76 | d.part_index AS part_index,d.role AS role,d.kind AS kind,d.tool_name AS tool_name,bm25(history_fts) AS rank, |
| 77 | s.custom_title AS custom_title,s.topic_title AS topic_title,s.last_activity_at AS last_activity_at |
| 78 | FROM history_fts JOIN history_documents d ON d.id=history_fts.rowid JOIN history_sources s ON s.path=d.source_path |
| 79 | WHERE ` + strings.Join(where, ` AND `) |
| 80 | query := base + ` ORDER BY bm25(history_fts),d.source_path,d.message_index,d.part_index,d.id LIMIT ?` |
| 81 | if after := req.After; after != nil { |
| 82 | query = `WITH ranked AS MATERIALIZED (` + base + `) |
| 83 | SELECT * FROM ranked WHERE rank>? OR (rank=? AND source_path>?) OR |
| 84 | (rank=? AND source_path=? AND message_index>?) OR |
| 85 | (rank=? AND source_path=? AND message_index=? AND part_index>?) OR |
| 86 | (rank=? AND source_path=? AND message_index=? AND part_index=? AND id>?) |
| 87 | ORDER BY rank,source_path,message_index,part_index,id LIMIT ?` |
| 88 | args = append(args, after.Rank, after.Rank, after.SessionPath, |
| 89 | after.Rank, after.SessionPath, after.MessageIndex, |
| 90 | after.Rank, after.SessionPath, after.MessageIndex, after.PartIndex, |
| 91 | after.Rank, after.SessionPath, after.MessageIndex, after.PartIndex, after.RowID) |
| 92 | } |
| 93 | return query, append(args, limit), nil |
| 94 | } |
| 95 | |
| 96 | func appendHistorySearchFilters(req SearchRequest, where *[]string, args *[]any) { |
| 97 | if req.Scope == "project" { |
| 98 | *where = append(*where, `s.scope='project'`, `s.workspace_root=?`) |
| 99 | *args = append(*args, strings.TrimSpace(req.WorkspaceRoot)) |
| 100 | } |
| 101 | if path := strings.TrimSpace(req.SessionPath); path != "" { |
| 102 | *where = append(*where, `d.source_path=?`) |
| 103 | *args = append(*args, filepath.Clean(path)) |
| 104 | } |
| 105 | if len(req.Kinds) > 0 { |
| 106 | placeholders := make([]string, len(req.Kinds)) |
| 107 | for i, kind := range req.Kinds { |
| 108 | placeholders[i] = "?" |
| 109 | *args = append(*args, kind) |
| 110 | } |
| 111 | *where = append(*where, `d.kind IN (`+strings.Join(placeholders, ",")+`)`) |
| 112 | } |
| 113 | if tool := strings.TrimSpace(req.ToolName); tool != "" { |
| 114 | *where = append(*where, `d.tool_name=?`) |
| 115 | *args = append(*args, tool) |
| 116 | } |
| 117 | if len(req.Roots) > 0 { |
| 118 | placeholders := make([]string, 0, len(req.Roots)) |
| 119 | for _, root := range req.Roots { |
| 120 | if strings.TrimSpace(root) == "" { |
| 121 | continue |
| 122 | } |
| 123 | placeholders = append(placeholders, "?") |
| 124 | *args = append(*args, filepath.Clean(root)) |
| 125 | } |
| 126 | if len(placeholders) > 0 { |
| 127 | *where = append(*where, `s.root IN (`+strings.Join(placeholders, ",")+`)`) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func catalogPathWithin(path, root string) bool { |
| 133 | absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path))) |
| 134 | if err != nil { |
| 135 | return false |
| 136 | } |
| 137 | absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root))) |
| 138 | if err != nil { |
| 139 | return false |
| 140 | } |
| 141 | rel, err := filepath.Rel(absRoot, absPath) |
| 142 | return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) |
| 143 | } |
| 144 |