| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/history" |
| 12 | "reasonix/internal/historycatalog" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/sessioncatalog" |
| 15 | ) |
| 16 | |
| 17 | type HistorySessionPageRequest struct { |
| 18 | Scope string `json:"scope"` |
| 19 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 20 | Status string `json:"status"` |
| 21 | TimeFilter string `json:"timeFilter"` |
| 22 | Query string `json:"query"` |
| 23 | Cursor string `json:"cursor"` |
| 24 | Limit int `json:"limit"` |
| 25 | } |
| 26 | |
| 27 | type HistorySessionPage struct { |
| 28 | SnapshotID string `json:"snapshotId,omitempty"` |
| 29 | SnapshotExpiresAt int64 `json:"snapshotExpiresAt,omitempty"` |
| 30 | ReadError *ReadError `json:"readError,omitempty"` |
| 31 | Items []SessionMeta `json:"items"` |
| 32 | NextCursor string `json:"nextCursor"` |
| 33 | Revision uint64 `json:"revision"` |
| 34 | Partial bool `json:"partial"` |
| 35 | StaleCursor bool `json:"staleCursor"` |
| 36 | } |
| 37 | |
| 38 | type HistorySearchRequest struct { |
| 39 | Query string `json:"query"` |
| 40 | Scope string `json:"scope"` |
| 41 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 42 | Status string `json:"status"` |
| 43 | TimeFilter string `json:"timeFilter"` |
| 44 | Kinds []string `json:"kinds"` |
| 45 | ToolName string `json:"toolName"` |
| 46 | Cursor string `json:"cursor"` |
| 47 | Limit int `json:"limit"` |
| 48 | } |
| 49 | |
| 50 | type HistorySearchHit struct { |
| 51 | PartIndex int `json:"partIndex,omitempty"` |
| 52 | ContentDigest string `json:"contentDigest,omitempty"` |
| 53 | SessionPath string `json:"sessionPath"` |
| 54 | SessionID string `json:"sessionId"` |
| 55 | Source string `json:"source"` |
| 56 | MessageIndex int `json:"messageIndex"` |
| 57 | Role string `json:"role"` |
| 58 | Kind string `json:"kind"` |
| 59 | ToolName string `json:"toolName,omitempty"` |
| 60 | Snippet string `json:"snippet"` |
| 61 | Score float64 `json:"score"` |
| 62 | SessionTitle string `json:"sessionTitle,omitempty"` |
| 63 | TopicTitle string `json:"topicTitle,omitempty"` |
| 64 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 65 | LastActivityAt int64 `json:"lastActivityAt"` |
| 66 | Open bool `json:"open"` |
| 67 | Running bool `json:"running"` |
| 68 | Current bool `json:"current"` |
| 69 | } |
| 70 | |
| 71 | type HistoryIndexStatus = historycatalog.Status |
| 72 | |
| 73 | type HistoryIndexChangedV1 struct { |
| 74 | Revision uint64 `json:"revision"` |
| 75 | Indexed int64 `json:"indexed"` |
| 76 | Total int64 `json:"total"` |
| 77 | Pending int64 `json:"pending"` |
| 78 | Roots []string `json:"roots"` |
| 79 | Reason string `json:"reason"` |
| 80 | } |
| 81 | |
| 82 | func (a *App) registerHistoryIndexEvents() { |
| 83 | history.RegisterCatalogObserver(func(status historycatalog.Status, roots []string, reason string) { |
| 84 | if roots == nil { |
| 85 | roots = []string{} |
| 86 | } |
| 87 | a.emitRuntimeEvent("history-index:changed-v1", HistoryIndexChangedV1{Revision: status.Revision, |
| 88 | Indexed: status.Indexed, Total: status.Total, Pending: status.Pending, Roots: roots, Reason: reason}) |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | type HistorySearchPage struct { |
| 93 | SnapshotID string `json:"snapshotId,omitempty"` |
| 94 | SnapshotExpiresAt int64 `json:"snapshotExpiresAt,omitempty"` |
| 95 | ReadError *ReadError `json:"readError,omitempty"` |
| 96 | Items []HistorySearchHit `json:"items"` |
| 97 | NextCursor string `json:"nextCursor"` |
| 98 | Revision uint64 `json:"revision"` |
| 99 | Partial bool `json:"partial"` |
| 100 | StaleCursor bool `json:"staleCursor"` |
| 101 | Status HistoryIndexStatus `json:"status"` |
| 102 | } |
| 103 | |
| 104 | type HistorySearchContextRequest struct { |
| 105 | ContentDigest string `json:"contentDigest,omitempty"` |
| 106 | SessionPath string `json:"sessionPath"` |
| 107 | MessageIndex int `json:"messageIndex"` |
| 108 | Before int `json:"before"` |
| 109 | After int `json:"after"` |
| 110 | } |
| 111 | |
| 112 | type HistorySearchContextLine struct { |
| 113 | Index int `json:"index"` |
| 114 | Role string `json:"role"` |
| 115 | Text string `json:"text"` |
| 116 | } |
| 117 | |
| 118 | func historyCatalogRoots(targets []sessioncatalog.DirectoryTarget) []historycatalog.Root { |
| 119 | roots := make([]historycatalog.Root, 0, len(targets)*2+1) |
| 120 | for _, target := range targets { |
| 121 | source := target.Scope |
| 122 | if source == "" { |
| 123 | source = "global" |
| 124 | } |
| 125 | root := historycatalog.Root{Path: target.Path, Source: source, Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot} |
| 126 | roots = append(roots, root) |
| 127 | root.Path = filepath.Join(target.Path, "subagents") |
| 128 | root.Subagents = true |
| 129 | roots = append(roots, root) |
| 130 | } |
| 131 | roots = append(roots, historycatalog.Root{Path: config.ArchiveDir(), Source: "archive", Scope: "global", Archive: true}) |
| 132 | return roots |
| 133 | } |
| 134 | |
| 135 | func sessionMetaFromCatalog(record sessioncatalog.SessionRecord, current, open bool) SessionMeta { |
| 136 | title := strings.TrimSpace(record.CustomTitle) |
| 137 | preview := record.Preview |
| 138 | if strings.TrimSpace(preview) == "" && record.TurnsState == sessioncatalog.TurnsUnknown { |
| 139 | preview = "History is being indexed — " + filepath.Base(record.Path) |
| 140 | } |
| 141 | recovered := record.Recovered || strings.TrimSpace(record.RecoveryDigest) != "" || isAutomaticRecoverySessionPath(record.Path) |
| 142 | // RecoveryCopy comes from the catalog projection, which re-proves coverage |
| 143 | // from real content at index time. History uses it for the dedicated |
| 144 | // recovery-copy group and safe bulk cleanup entry points. |
| 145 | return SessionMeta{Path: record.Path, Preview: preview, Title: title, Turns: record.Turns, |
| 146 | TurnsState: string(record.TurnsState), CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt, |
| 147 | ModTime: record.LastActivityAt, Current: current, Open: open, Scope: record.Scope, |
| 148 | WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID, TopicTitle: record.TopicTitle, |
| 149 | Recovered: recovered, RecoveryCopy: record.RecoveryCopy, |
| 150 | RecoveryGroupID: record.RecoveryGroupID, RecoveryRole: record.RecoveryRole, |
| 151 | RecoveryCanonical: record.RecoveryCanonical} |
| 152 | } |
| 153 | |
| 154 | func (a *App) GetHistoryIndexStatus() HistoryIndexStatus { |
| 155 | if catalog := history.SharedCatalog(); catalog != nil { |
| 156 | return catalog.Status() |
| 157 | } |
| 158 | return HistoryIndexStatus{State: "opening", Pending: 1, Mode: "memory"} |
| 159 | } |
| 160 | |
| 161 | func desktopHistoryText(messages []provider.Message, candidate historycatalog.Candidate) (string, bool) { |
| 162 | if candidate.MessageIndex < 0 || candidate.MessageIndex >= len(messages) { |
| 163 | return "", false |
| 164 | } |
| 165 | message := messages[candidate.MessageIndex] |
| 166 | switch candidate.Kind { |
| 167 | case "user_text", "assistant_text": |
| 168 | return message.Content, true |
| 169 | case "tool_input": |
| 170 | if candidate.PartIndex < 0 || candidate.PartIndex >= len(message.ToolCalls) { |
| 171 | return "", false |
| 172 | } |
| 173 | call := message.ToolCalls[candidate.PartIndex] |
| 174 | return strings.TrimSpace(call.Name + " " + call.Arguments), true |
| 175 | case "tool_error", "tool_output": |
| 176 | return strings.TrimSpace(message.Name + " " + message.Content), true |
| 177 | default: |
| 178 | return "", false |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func historyTimeMatchesAt(timestamp int64, filter string, now time.Time) bool { |
| 183 | if strings.TrimSpace(filter) == "" || filter == "all" { |
| 184 | return true |
| 185 | } |
| 186 | startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) |
| 187 | value := time.UnixMilli(timestamp) |
| 188 | switch filter { |
| 189 | case "today": |
| 190 | return !value.Before(startToday) |
| 191 | case "yesterday": |
| 192 | return !value.Before(startToday.AddDate(0, 0, -1)) && value.Before(startToday) |
| 193 | case "older": |
| 194 | return value.Before(startToday.AddDate(0, 0, -1)) |
| 195 | default: |
| 196 | return true |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func (a *App) GetHistorySearchContext(req HistorySearchContextRequest) []HistorySearchContextLine { |
| 201 | var options history.Options |
| 202 | bestLength := -1 |
| 203 | for _, root := range historyCatalogRoots(a.sessionCatalogTargets()) { |
| 204 | if !desktopHistoryPathWithin(req.SessionPath, root.Path) || len(root.Path) <= bestLength { |
| 205 | continue |
| 206 | } |
| 207 | bestLength = len(root.Path) |
| 208 | options = history.Options{} |
| 209 | switch { |
| 210 | case root.Archive: |
| 211 | options.ArchiveDir = root.Path |
| 212 | case root.Subagents: |
| 213 | options.SessionDir = filepath.Dir(root.Path) |
| 214 | default: |
| 215 | options.SessionDir = root.Path |
| 216 | } |
| 217 | } |
| 218 | if bestLength < 0 { |
| 219 | return []HistorySearchContextLine{} |
| 220 | } |
| 221 | if req.ContentDigest != "" { |
| 222 | messages, state, intact, err := agent.LoadSessionDisplayMessages(req.SessionPath) |
| 223 | if err != nil || !intact || state.DigestHex != req.ContentDigest { |
| 224 | return []HistorySearchContextLine{} |
| 225 | } |
| 226 | out := []HistorySearchContextLine{} |
| 227 | for i := max(0, req.MessageIndex-min(max(req.Before, 0), 20)); i < min(len(messages), req.MessageIndex+min(max(req.After, 0), 20)+1); i++ { |
| 228 | out = append(out, HistorySearchContextLine{Index: i, Role: string(messages[i].Role), Text: messages[i].Content}) |
| 229 | } |
| 230 | return out |
| 231 | } |
| 232 | searcher := history.NewSearcher(options) |
| 233 | lines, err := searcher.Around(a.bootContext(), history.AroundRequest{SessionPath: req.SessionPath, MessageIndex: req.MessageIndex, Before: req.Before, After: req.After}) |
| 234 | if err != nil { |
| 235 | return []HistorySearchContextLine{} |
| 236 | } |
| 237 | out := make([]HistorySearchContextLine, 0, len(lines)) |
| 238 | for _, line := range lines { |
| 239 | role := "" |
| 240 | if fields := strings.Fields(line.Text); len(fields) > 1 { |
| 241 | role = strings.TrimSuffix(fields[1], "]") |
| 242 | } |
| 243 | out = append(out, HistorySearchContextLine{Index: line.Index, Role: role, Text: line.Text}) |
| 244 | } |
| 245 | return out |
| 246 | } |
| 247 | |
| 248 | func desktopHistoryPathWithin(path, root string) bool { |
| 249 | absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path))) |
| 250 | if err != nil { |
| 251 | return false |
| 252 | } |
| 253 | absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root))) |
| 254 | if err != nil { |
| 255 | return false |
| 256 | } |
| 257 | rel, err := filepath.Rel(absRoot, absPath) |
| 258 | return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) |
| 259 | } |
| 260 | |
| 261 | func (a *App) RebuildHistoryIndex() error { |
| 262 | if a == nil || a.shuttingDown.Load() { |
| 263 | return errors.New("application is shutting down") |
| 264 | } |
| 265 | return history.RebuildSharedCatalog(a.bootContext(), historyCatalogRoots(a.sessionCatalogTargets())) |
| 266 | } |
| 267 |