| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/history" |
| 13 | "reasonix/internal/historycatalog" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/sessioncatalog" |
| 16 | ) |
| 17 | |
| 18 | type HistorySessionPageRequest struct { |
| 19 | Scope string `json:"scope"` |
| 20 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 21 | Status string `json:"status"` |
| 22 | TimeFilter string `json:"timeFilter"` |
| 23 | Query string `json:"query"` |
| 24 | Cursor string `json:"cursor"` |
| 25 | Limit int `json:"limit"` |
| 26 | } |
| 27 | |
| 28 | type HistorySessionPage struct { |
| 29 | Items []SessionMeta `json:"items"` |
| 30 | NextCursor string `json:"nextCursor"` |
| 31 | Revision uint64 `json:"revision"` |
| 32 | Partial bool `json:"partial"` |
| 33 | StaleCursor bool `json:"staleCursor"` |
| 34 | } |
| 35 | |
| 36 | type HistorySearchRequest struct { |
| 37 | Query string `json:"query"` |
| 38 | Scope string `json:"scope"` |
| 39 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 40 | Status string `json:"status"` |
| 41 | TimeFilter string `json:"timeFilter"` |
| 42 | Kinds []string `json:"kinds"` |
| 43 | ToolName string `json:"toolName"` |
| 44 | Cursor string `json:"cursor"` |
| 45 | Limit int `json:"limit"` |
| 46 | } |
| 47 | |
| 48 | type HistorySearchHit struct { |
| 49 | SessionPath string `json:"sessionPath"` |
| 50 | SessionID string `json:"sessionId"` |
| 51 | Source string `json:"source"` |
| 52 | MessageIndex int `json:"messageIndex"` |
| 53 | Role string `json:"role"` |
| 54 | Kind string `json:"kind"` |
| 55 | ToolName string `json:"toolName,omitempty"` |
| 56 | Snippet string `json:"snippet"` |
| 57 | Score float64 `json:"score"` |
| 58 | SessionTitle string `json:"sessionTitle,omitempty"` |
| 59 | TopicTitle string `json:"topicTitle,omitempty"` |
| 60 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 61 | LastActivityAt int64 `json:"lastActivityAt"` |
| 62 | Open bool `json:"open"` |
| 63 | Running bool `json:"running"` |
| 64 | Current bool `json:"current"` |
| 65 | } |
| 66 | |
| 67 | type HistoryIndexStatus = historycatalog.Status |
| 68 | |
| 69 | type HistoryIndexChangedV1 struct { |
| 70 | Revision uint64 `json:"revision"` |
| 71 | Indexed int64 `json:"indexed"` |
| 72 | Total int64 `json:"total"` |
| 73 | Pending int64 `json:"pending"` |
| 74 | Roots []string `json:"roots"` |
| 75 | Reason string `json:"reason"` |
| 76 | } |
| 77 | |
| 78 | func (a *App) registerHistoryIndexEvents() { |
| 79 | history.RegisterCatalogObserver(func(status historycatalog.Status, roots []string, reason string) { |
| 80 | if roots == nil { |
| 81 | roots = []string{} |
| 82 | } |
| 83 | a.emitRuntimeEvent("history-index:changed-v1", HistoryIndexChangedV1{Revision: status.Revision, |
| 84 | Indexed: status.Indexed, Total: status.Total, Pending: status.Pending, Roots: roots, Reason: reason}) |
| 85 | }) |
| 86 | } |
| 87 | |
| 88 | type HistorySearchPage struct { |
| 89 | Items []HistorySearchHit `json:"items"` |
| 90 | NextCursor string `json:"nextCursor"` |
| 91 | Revision uint64 `json:"revision"` |
| 92 | Partial bool `json:"partial"` |
| 93 | StaleCursor bool `json:"staleCursor"` |
| 94 | Status HistoryIndexStatus `json:"status"` |
| 95 | } |
| 96 | |
| 97 | type HistorySearchContextRequest struct { |
| 98 | SessionPath string `json:"sessionPath"` |
| 99 | MessageIndex int `json:"messageIndex"` |
| 100 | Before int `json:"before"` |
| 101 | After int `json:"after"` |
| 102 | } |
| 103 | |
| 104 | type HistorySearchContextLine struct { |
| 105 | Index int `json:"index"` |
| 106 | Role string `json:"role"` |
| 107 | Text string `json:"text"` |
| 108 | } |
| 109 | |
| 110 | type historySearchCursor struct { |
| 111 | Revision uint64 `json:"r"` |
| 112 | Rank float64 `json:"b"` |
| 113 | Score float64 `json:"s"` |
| 114 | Path string `json:"p"` |
| 115 | Message int `json:"m"` |
| 116 | Part int `json:"a"` |
| 117 | RowID int64 `json:"i"` |
| 118 | } |
| 119 | |
| 120 | func historyCatalogRoots(targets []sessioncatalog.DirectoryTarget) []historycatalog.Root { |
| 121 | roots := make([]historycatalog.Root, 0, len(targets)*2+1) |
| 122 | for _, target := range targets { |
| 123 | source := target.Scope |
| 124 | if source == "" { |
| 125 | source = "global" |
| 126 | } |
| 127 | root := historycatalog.Root{Path: target.Path, Source: source, Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot} |
| 128 | roots = append(roots, root) |
| 129 | root.Path = filepath.Join(target.Path, "subagents") |
| 130 | root.Subagents = true |
| 131 | roots = append(roots, root) |
| 132 | } |
| 133 | roots = append(roots, historycatalog.Root{Path: config.ArchiveDir(), Source: "archive", Scope: "global", Archive: true}) |
| 134 | return roots |
| 135 | } |
| 136 | |
| 137 | func sessionMetaFromCatalog(record sessioncatalog.SessionRecord, current, open bool) SessionMeta { |
| 138 | title := strings.TrimSpace(record.CustomTitle) |
| 139 | preview := record.Preview |
| 140 | if strings.TrimSpace(preview) == "" && record.TurnsState == sessioncatalog.TurnsUnknown { |
| 141 | preview = "History is being indexed — " + filepath.Base(record.Path) |
| 142 | } |
| 143 | recovered := record.Recovered || strings.TrimSpace(record.RecoveryDigest) != "" || isAutomaticRecoverySessionPath(record.Path) |
| 144 | // RecoveryCopy comes from the catalog projection, which re-proves coverage |
| 145 | // from real content at index time. History uses it for the dedicated |
| 146 | // recovery-copy group and safe bulk cleanup entry points. |
| 147 | return SessionMeta{Path: record.Path, Preview: preview, Title: title, Turns: record.Turns, |
| 148 | TurnsState: string(record.TurnsState), CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt, |
| 149 | ModTime: record.LastActivityAt, Current: current, Open: open, Scope: record.Scope, |
| 150 | WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID, TopicTitle: record.TopicTitle, |
| 151 | Recovered: recovered, RecoveryCopy: record.RecoveryCopy, |
| 152 | RecoveryGroupID: record.RecoveryGroupID, RecoveryRole: record.RecoveryRole, |
| 153 | RecoveryCanonical: record.RecoveryCanonical} |
| 154 | } |
| 155 | |
| 156 | func (a *App) ListHistorySessions(req HistorySessionPageRequest) HistorySessionPage { |
| 157 | out := HistorySessionPage{Items: []SessionMeta{}} |
| 158 | catalog := a.sessionCatalog.Load() |
| 159 | if catalog == nil { |
| 160 | out.Partial = true |
| 161 | return out |
| 162 | } |
| 163 | limit := req.Limit |
| 164 | if limit <= 0 { |
| 165 | limit = 50 |
| 166 | } |
| 167 | if limit > 200 { |
| 168 | limit = 200 |
| 169 | } |
| 170 | statusFilter := strings.TrimSpace(req.Status) |
| 171 | needRuntimeFilter := statusFilter == "open" || statusFilter == "current" |
| 172 | _, overlays := a.catalogRuntimeOverlays() |
| 173 | active := a.activeSessionPath(a.activeSessionDir()) |
| 174 | cursor := req.Cursor |
| 175 | for { |
| 176 | page, err := catalog.ListSessions(a.bootContext(), sessioncatalog.SessionPageRequest{ |
| 177 | Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: cursor, Limit: limit, |
| 178 | Query: req.Query, TimeFilter: req.TimeFilter, |
| 179 | }) |
| 180 | if err != nil { |
| 181 | out.Partial = true |
| 182 | return out |
| 183 | } |
| 184 | out.Revision = page.Revision |
| 185 | if page.StaleCursor { |
| 186 | out.StaleCursor = true |
| 187 | return out |
| 188 | } |
| 189 | for i, record := range page.Items { |
| 190 | overlay := overlays[sessionRuntimeKey(record.Path)] |
| 191 | // Match frontend HistoryPanel: "open" means open-but-not-current. |
| 192 | if statusFilter == "open" && (!overlay.open || record.Path == active) { |
| 193 | continue |
| 194 | } |
| 195 | if statusFilter == "current" && record.Path != active { |
| 196 | continue |
| 197 | } |
| 198 | out.Items = append(out.Items, sessionMetaFromCatalog(record, record.Path == active, overlay.open)) |
| 199 | if len(out.Items) == limit { |
| 200 | if i+1 < len(page.Items) || page.NextCursor != "" { |
| 201 | out.NextCursor = sessioncatalog.CursorAfter(page.Revision, record.LastActivityAt, record.Path) |
| 202 | } |
| 203 | status := catalog.Status() |
| 204 | out.Partial = status.State != sessioncatalog.StateReady || status.Indexed < status.Total |
| 205 | return out |
| 206 | } |
| 207 | } |
| 208 | if !needRuntimeFilter { |
| 209 | // No post-filter: a single catalog page is the whole result page. |
| 210 | out.NextCursor = page.NextCursor |
| 211 | break |
| 212 | } |
| 213 | if page.NextCursor == "" { |
| 214 | out.NextCursor = "" |
| 215 | break |
| 216 | } |
| 217 | cursor = page.NextCursor |
| 218 | } |
| 219 | status := catalog.Status() |
| 220 | out.Partial = status.State != sessioncatalog.StateReady || status.Indexed < status.Total |
| 221 | return out |
| 222 | } |
| 223 | |
| 224 | func (a *App) GetHistoryIndexStatus() HistoryIndexStatus { |
| 225 | if catalog := history.SharedCatalog(); catalog != nil { |
| 226 | return catalog.Status() |
| 227 | } |
| 228 | return HistoryIndexStatus{State: "opening", Pending: 1, Mode: "memory"} |
| 229 | } |
| 230 | |
| 231 | func decodeHistoryCursor(encoded string) (*historySearchCursor, error) { |
| 232 | if strings.TrimSpace(encoded) == "" { |
| 233 | return nil, nil |
| 234 | } |
| 235 | b, err := base64.RawURLEncoding.DecodeString(encoded) |
| 236 | if err != nil { |
| 237 | return nil, err |
| 238 | } |
| 239 | var cursor historySearchCursor |
| 240 | if err := json.Unmarshal(b, &cursor); err != nil || cursor.Path == "" { |
| 241 | return nil, errors.New("invalid history search cursor") |
| 242 | } |
| 243 | return &cursor, nil |
| 244 | } |
| 245 | |
| 246 | func encodeHistoryCursor(cursor historySearchCursor) string { |
| 247 | b, _ := json.Marshal(cursor) |
| 248 | return base64.RawURLEncoding.EncodeToString(b) |
| 249 | } |
| 250 | |
| 251 | func desktopHistoryText(messages []provider.Message, candidate historycatalog.Candidate) (string, bool) { |
| 252 | if candidate.MessageIndex < 0 || candidate.MessageIndex >= len(messages) { |
| 253 | return "", false |
| 254 | } |
| 255 | message := messages[candidate.MessageIndex] |
| 256 | switch candidate.Kind { |
| 257 | case "user_text", "assistant_text": |
| 258 | return message.Content, true |
| 259 | case "tool_input": |
| 260 | if candidate.PartIndex < 0 || candidate.PartIndex >= len(message.ToolCalls) { |
| 261 | return "", false |
| 262 | } |
| 263 | call := message.ToolCalls[candidate.PartIndex] |
| 264 | return strings.TrimSpace(call.Name + " " + call.Arguments), true |
| 265 | case "tool_error", "tool_output": |
| 266 | return strings.TrimSpace(message.Name + " " + message.Content), true |
| 267 | default: |
| 268 | return "", false |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func (a *App) SearchHistoryContent(req HistorySearchRequest) HistorySearchPage { |
| 273 | status := a.GetHistoryIndexStatus() |
| 274 | out := HistorySearchPage{Items: []HistorySearchHit{}, Status: status, Revision: status.Revision, |
| 275 | Partial: status.State != "ready" || status.Pending > 0 || (status.Total > 0 && status.Indexed < status.Total)} |
| 276 | catalog := history.SharedCatalog() |
| 277 | if catalog == nil || strings.TrimSpace(req.Query) == "" { |
| 278 | return out |
| 279 | } |
| 280 | cursor, err := decodeHistoryCursor(req.Cursor) |
| 281 | if err != nil { |
| 282 | return out |
| 283 | } |
| 284 | if cursor != nil && cursor.Revision != status.Revision { |
| 285 | out.StaleCursor = true |
| 286 | return out |
| 287 | } |
| 288 | limit := req.Limit |
| 289 | if limit <= 0 { |
| 290 | limit = 50 |
| 291 | } |
| 292 | if limit > 200 { |
| 293 | limit = 200 |
| 294 | } |
| 295 | kinds := req.Kinds |
| 296 | if len(kinds) == 0 { |
| 297 | kinds = []string{"user_text", "assistant_text", "tool_input", "tool_error"} |
| 298 | } |
| 299 | var after *historycatalog.SearchCursor |
| 300 | if cursor != nil { |
| 301 | after = &historycatalog.SearchCursor{Rank: cursor.Rank, SessionPath: cursor.Path, MessageIndex: cursor.Message, |
| 302 | PartIndex: cursor.Part, RowID: cursor.RowID} |
| 303 | } |
| 304 | // Keep pulling FTS candidates until the filtered page is full. |
| 305 | items, nextCursor, lastErr := a.collectHistorySearchItems(req, catalog, kinds, historySearchRootFilter(a, req), after, limit, status.Revision) |
| 306 | if lastErr != "" { |
| 307 | out.Status.LastError = lastErr |
| 308 | return out |
| 309 | } |
| 310 | out.Items, out.NextCursor = items, nextCursor |
| 311 | return out |
| 312 | } |
| 313 | |
| 314 | func historyTimeMatches(timestamp int64, filter string) bool { |
| 315 | if strings.TrimSpace(filter) == "" || filter == "all" { |
| 316 | return true |
| 317 | } |
| 318 | now := time.Now() |
| 319 | startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) |
| 320 | value := time.UnixMilli(timestamp) |
| 321 | switch filter { |
| 322 | case "today": |
| 323 | return !value.Before(startToday) |
| 324 | case "yesterday": |
| 325 | return !value.Before(startToday.AddDate(0, 0, -1)) && value.Before(startToday) |
| 326 | case "older": |
| 327 | return value.Before(startToday.AddDate(0, 0, -1)) |
| 328 | default: |
| 329 | return true |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | func (a *App) GetHistorySearchContext(req HistorySearchContextRequest) []HistorySearchContextLine { |
| 334 | var options history.Options |
| 335 | bestLength := -1 |
| 336 | for _, root := range historyCatalogRoots(a.sessionCatalogTargets()) { |
| 337 | if !desktopHistoryPathWithin(req.SessionPath, root.Path) || len(root.Path) <= bestLength { |
| 338 | continue |
| 339 | } |
| 340 | bestLength = len(root.Path) |
| 341 | options = history.Options{} |
| 342 | switch { |
| 343 | case root.Archive: |
| 344 | options.ArchiveDir = root.Path |
| 345 | case root.Subagents: |
| 346 | options.SessionDir = filepath.Dir(root.Path) |
| 347 | default: |
| 348 | options.SessionDir = root.Path |
| 349 | } |
| 350 | } |
| 351 | if bestLength < 0 { |
| 352 | return []HistorySearchContextLine{} |
| 353 | } |
| 354 | searcher := history.NewSearcher(options) |
| 355 | lines, err := searcher.Around(a.bootContext(), history.AroundRequest{SessionPath: req.SessionPath, MessageIndex: req.MessageIndex, Before: req.Before, After: req.After}) |
| 356 | if err != nil { |
| 357 | return []HistorySearchContextLine{} |
| 358 | } |
| 359 | out := make([]HistorySearchContextLine, 0, len(lines)) |
| 360 | for _, line := range lines { |
| 361 | role := "" |
| 362 | if fields := strings.Fields(line.Text); len(fields) > 1 { |
| 363 | role = strings.TrimSuffix(fields[1], "]") |
| 364 | } |
| 365 | out = append(out, HistorySearchContextLine{Index: line.Index, Role: role, Text: line.Text}) |
| 366 | } |
| 367 | return out |
| 368 | } |
| 369 | |
| 370 | func desktopHistoryPathWithin(path, root string) bool { |
| 371 | absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path))) |
| 372 | if err != nil { |
| 373 | return false |
| 374 | } |
| 375 | absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root))) |
| 376 | if err != nil { |
| 377 | return false |
| 378 | } |
| 379 | rel, err := filepath.Rel(absRoot, absPath) |
| 380 | return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) |
| 381 | } |
| 382 | |
| 383 | func (a *App) RebuildHistoryIndex() error { |
| 384 | if a == nil || a.shuttingDown.Load() { |
| 385 | return errors.New("application is shutting down") |
| 386 | } |
| 387 | return history.RebuildSharedCatalog(a.bootContext(), historyCatalogRoots(a.sessionCatalogTargets())) |
| 388 | } |
| 389 |