| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/historycatalog" |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/retrieval" |
| 12 | ) |
| 13 | |
| 14 | func historySearchRootFilter(a *App, req HistorySearchRequest) []string { |
| 15 | rootFilter := []string{} |
| 16 | for _, root := range historyCatalogRoots(a.sessionCatalogTargets()) { |
| 17 | if req.Scope == "project" && (root.Scope != "project" || !sameProjectRoot(root.WorkspaceRoot, req.WorkspaceRoot)) { |
| 18 | continue |
| 19 | } |
| 20 | if req.Scope == "global" && root.Scope == "project" { |
| 21 | continue |
| 22 | } |
| 23 | rootFilter = append(rootFilter, root.Path) |
| 24 | } |
| 25 | return rootFilter |
| 26 | } |
| 27 | |
| 28 | func historyStatusMatches(status string, open, current bool) bool { |
| 29 | switch strings.TrimSpace(status) { |
| 30 | case "open": |
| 31 | // Match HistoryPanel: open-but-not-current. |
| 32 | return open && !current |
| 33 | case "current": |
| 34 | return current |
| 35 | default: |
| 36 | return true |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func (a *App) historyHitFromCandidate( |
| 41 | req HistorySearchRequest, |
| 42 | candidate historycatalog.Candidate, |
| 43 | active string, |
| 44 | overlays map[string]catalogRuntimeOverlay, |
| 45 | queryTerms []string, |
| 46 | loaded map[string][]provider.Message, |
| 47 | recoveryChecked map[string]bool, |
| 48 | recoveryCovered map[string]bool, |
| 49 | catalog *historycatalog.Catalog, |
| 50 | ) (HistorySearchHit, bool) { |
| 51 | if !recoveryChecked[candidate.SessionPath] { |
| 52 | recoveryChecked[candidate.SessionPath] = true |
| 53 | if sessions := a.sessionCatalog.Load(); sessions != nil { |
| 54 | if record, ok, err := sessions.GetSession(a.bootContext(), candidate.SessionPath); err == nil && ok { |
| 55 | recoveryCovered[candidate.SessionPath] = record.RecoveryCopy |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | if recoveryCovered[candidate.SessionPath] { |
| 60 | return HistorySearchHit{}, false |
| 61 | } |
| 62 | overlay := overlays[sessionRuntimeKey(candidate.SessionPath)] |
| 63 | current := candidate.SessionPath == active |
| 64 | if !historyStatusMatches(req.Status, overlay.open, current) { |
| 65 | return HistorySearchHit{}, false |
| 66 | } |
| 67 | if !historyTimeMatches(candidate.LastActivityAt, req.TimeFilter) { |
| 68 | return HistorySearchHit{}, false |
| 69 | } |
| 70 | messages, ok := loaded[candidate.SessionPath] |
| 71 | if !ok { |
| 72 | if identity, known, identityErr := agent.SessionContentIdentity(candidate.SessionPath); identityErr == nil && known && |
| 73 | candidate.ContentDigest != "" && identity.DigestHex != candidate.ContentDigest { |
| 74 | catalog.EnqueueExisting(context.Background(), candidate.SessionPath) |
| 75 | return HistorySearchHit{}, false |
| 76 | } |
| 77 | session, loadErr := agent.LoadSession(candidate.SessionPath) |
| 78 | if loadErr != nil { |
| 79 | return HistorySearchHit{}, false |
| 80 | } |
| 81 | messages = session.Snapshot() |
| 82 | loaded[candidate.SessionPath] = messages |
| 83 | } |
| 84 | text, ok := desktopHistoryText(messages, candidate) |
| 85 | if !ok { |
| 86 | catalog.EnqueueExisting(context.Background(), candidate.SessionPath) |
| 87 | return HistorySearchHit{}, false |
| 88 | } |
| 89 | return HistorySearchHit{ |
| 90 | SessionPath: candidate.SessionPath, |
| 91 | SessionID: strings.TrimSuffix(filepath.Base(candidate.SessionPath), filepath.Ext(candidate.SessionPath)), |
| 92 | Source: candidate.Source, MessageIndex: candidate.MessageIndex, Role: candidate.Role, |
| 93 | Kind: candidate.Kind, ToolName: candidate.ToolName, |
| 94 | Snippet: retrieval.MakeSnippet(text, req.Query, queryTerms, 240), Score: candidate.Score, |
| 95 | SessionTitle: candidate.SessionTitle, TopicTitle: candidate.TopicTitle, WorkspaceRoot: candidate.WorkspaceRoot, |
| 96 | LastActivityAt: candidate.LastActivityAt, Open: overlay.open, Running: overlay.running, Current: current, |
| 97 | }, true |
| 98 | } |
| 99 | |
| 100 | func (a *App) collectHistorySearchItems( |
| 101 | req HistorySearchRequest, |
| 102 | catalog *historycatalog.Catalog, |
| 103 | kinds, rootFilter []string, |
| 104 | after *historycatalog.SearchCursor, |
| 105 | limit int, |
| 106 | revision uint64, |
| 107 | ) ([]HistorySearchHit, string, string) { |
| 108 | const batchLimit = 200 |
| 109 | need := limit + 1 |
| 110 | _, overlays := a.catalogRuntimeOverlays() |
| 111 | active := a.activeSessionPath(a.activeSessionDir()) |
| 112 | queryTerms, _ := retrieval.QueryTerms(req.Query) |
| 113 | loaded := map[string][]provider.Message{} |
| 114 | recoveryChecked := map[string]bool{} |
| 115 | recoveryCovered := map[string]bool{} |
| 116 | items := []HistorySearchHit{} |
| 117 | var cursorCandidate historycatalog.Candidate |
| 118 | for len(items) < need { |
| 119 | result, searchErr := catalog.Search(a.bootContext(), historycatalog.SearchRequest{ |
| 120 | Query: req.Query, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, |
| 121 | Kinds: kinds, ToolName: req.ToolName, Limit: batchLimit, Roots: rootFilter, After: after, |
| 122 | }) |
| 123 | if searchErr != nil { |
| 124 | return items, "", searchErr.Error() |
| 125 | } |
| 126 | if len(result.Items) == 0 { |
| 127 | break |
| 128 | } |
| 129 | for _, candidate := range result.Items { |
| 130 | hit, ok := a.historyHitFromCandidate(req, candidate, active, overlays, queryTerms, loaded, recoveryChecked, recoveryCovered, catalog) |
| 131 | if !ok { |
| 132 | continue |
| 133 | } |
| 134 | items = append(items, hit) |
| 135 | if len(items) == limit { |
| 136 | cursorCandidate = candidate |
| 137 | } |
| 138 | if len(items) == need { |
| 139 | break |
| 140 | } |
| 141 | } |
| 142 | if len(items) == need { |
| 143 | break |
| 144 | } |
| 145 | last := result.Items[len(result.Items)-1] |
| 146 | after = &historycatalog.SearchCursor{Rank: last.Rank, SessionPath: last.SessionPath, MessageIndex: last.MessageIndex, |
| 147 | PartIndex: last.PartIndex, RowID: last.RowID} |
| 148 | if len(result.Items) < batchLimit { |
| 149 | break |
| 150 | } |
| 151 | } |
| 152 | nextCursor := "" |
| 153 | if len(items) > limit { |
| 154 | items = items[:limit] |
| 155 | nextCursor = encodeHistoryCursor(historySearchCursor{Revision: revision, Rank: cursorCandidate.Rank, Score: cursorCandidate.Score, |
| 156 | Path: cursorCandidate.SessionPath, Message: cursorCandidate.MessageIndex, Part: cursorCandidate.PartIndex, RowID: cursorCandidate.RowID}) |
| 157 | } |
| 158 | return items, nextCursor, "" |
| 159 | } |
| 160 |