|
| 1 | +// Package cmdhistory implements a structured command history store using SQLite. |
| 2 | +// Inspired by atuin, it records every Bash tool call with rich context |
| 3 | +// (exit code, duration, working directory, git branch, session) and provides |
| 4 | +// powerful FTS5-backed search and recall. |
| 5 | +package cmdhistory |
| 6 | + |
| 7 | +import ( |
| 8 | + "crypto/rand" |
| 9 | + "database/sql" |
| 10 | + "fmt" |
| 11 | + "strings" |
| 12 | + "time" |
| 13 | + |
| 14 | + _ "modernc.org/sqlite" |
| 15 | +) |
| 16 | + |
| 17 | +// Entry represents a single command execution with context. |
| 18 | +type Entry struct { |
| 19 | + ID string |
| 20 | + Command string |
| 21 | + ExitCode int |
| 22 | + Duration time.Duration |
| 23 | + CWD string |
| 24 | + GitBranch string |
| 25 | + SessionID string |
| 26 | + CreatedAt time.Time |
| 27 | +} |
| 28 | + |
| 29 | +// SearchOpts controls filtering for the Search method. |
| 30 | +type SearchOpts struct { |
| 31 | + Limit int |
| 32 | + ExitCode *int // filter by exit code (nil = any) |
| 33 | + CWD string // filter by working directory |
| 34 | + SessionID string // filter by session |
| 35 | + Since time.Time // only entries after this time |
| 36 | +} |
| 37 | + |
| 38 | +// CommandCount pairs a command string with its execution count. |
| 39 | +type CommandCount struct { |
| 40 | + Command string |
| 41 | + Count int |
| 42 | +} |
| 43 | + |
| 44 | +// DirCount pairs a directory path with its execution count. |
| 45 | +type DirCount struct { |
| 46 | + Dir string |
| 47 | + Count int |
| 48 | +} |
| 49 | + |
| 50 | +// HistoryStats holds aggregate usage statistics. |
| 51 | +type HistoryStats struct { |
| 52 | + TotalCommands int |
| 53 | + UniqueCommands int |
| 54 | + SuccessRate float64 |
| 55 | + TopCommands []CommandCount |
| 56 | + TopDirectories []DirCount |
| 57 | +} |
| 58 | + |
| 59 | +// Store provides access to the command history database. |
| 60 | +type Store struct { |
| 61 | + db *sql.DB |
| 62 | +} |
| 63 | + |
| 64 | +// New opens or creates the command history database at the given path. |
| 65 | +func New(dbPath string) (*Store, error) { |
| 66 | + db, err := sql.Open("sqlite", dbPath) |
| 67 | + if err != nil { |
| 68 | + return nil, fmt.Errorf("open database: %w", err) |
| 69 | + } |
| 70 | + |
| 71 | + if err := createSchema(db); err != nil { |
| 72 | + db.Close() |
| 73 | + return nil, fmt.Errorf("create schema: %w", err) |
| 74 | + } |
| 75 | + |
| 76 | + return &Store{db: db}, nil |
| 77 | +} |
| 78 | + |
| 79 | +// Record saves a command execution with its context. |
| 80 | +func (s *Store) Record(entry Entry) error { |
| 81 | + if entry.ID == "" { |
| 82 | + entry.ID = generateID() |
| 83 | + } |
| 84 | + if entry.CreatedAt.IsZero() { |
| 85 | + entry.CreatedAt = time.Now().UTC() |
| 86 | + } |
| 87 | + |
| 88 | + _, err := s.db.Exec( |
| 89 | + `INSERT INTO entries (id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at) |
| 90 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, |
| 91 | + entry.ID, |
| 92 | + entry.Command, |
| 93 | + entry.ExitCode, |
| 94 | + entry.Duration.Milliseconds(), |
| 95 | + entry.CWD, |
| 96 | + entry.GitBranch, |
| 97 | + entry.SessionID, |
| 98 | + entry.CreatedAt.Format(time.RFC3339Nano), |
| 99 | + ) |
| 100 | + if err != nil { |
| 101 | + return fmt.Errorf("insert entry: %w", err) |
| 102 | + } |
| 103 | + return nil |
| 104 | +} |
| 105 | + |
| 106 | +// Search finds commands matching the query using FTS5 full-text search. |
| 107 | +// The query is passed through to the FTS5 MATCH operator, so callers can use |
| 108 | +// FTS5 query syntax (e.g. prefix queries with *, boolean AND/OR/NOT). |
| 109 | +// For simple substring searches, each word is automatically turned into a prefix token. |
| 110 | +func (s *Store) Search(query string, opts SearchOpts) ([]Entry, error) { |
| 111 | + if opts.Limit <= 0 { |
| 112 | + opts.Limit = 50 |
| 113 | + } |
| 114 | + |
| 115 | + // Build FTS5 match expression: split on whitespace and add * for prefix matching. |
| 116 | + words := strings.Fields(query) |
| 117 | + if len(words) == 0 { |
| 118 | + return nil, nil |
| 119 | + } |
| 120 | + for i, w := range words { |
| 121 | + // Only add wildcard if the word doesn't already contain FTS5 operators. |
| 122 | + if !strings.ContainsAny(w, "*\"{}") { |
| 123 | + words[i] = w + "*" |
| 124 | + } |
| 125 | + } |
| 126 | + ftsQuery := strings.Join(words, " ") |
| 127 | + |
| 128 | + var clauses []string |
| 129 | + var args []interface{} |
| 130 | + |
| 131 | + clauses = append(clauses, "entries_fts MATCH ?") |
| 132 | + args = append(args, ftsQuery) |
| 133 | + |
| 134 | + if opts.ExitCode != nil { |
| 135 | + clauses = append(clauses, "e.exit_code = ?") |
| 136 | + args = append(args, *opts.ExitCode) |
| 137 | + } |
| 138 | + if opts.CWD != "" { |
| 139 | + clauses = append(clauses, "e.cwd = ?") |
| 140 | + args = append(args, opts.CWD) |
| 141 | + } |
| 142 | + if opts.SessionID != "" { |
| 143 | + clauses = append(clauses, "e.session_id = ?") |
| 144 | + args = append(args, opts.SessionID) |
| 145 | + } |
| 146 | + if !opts.Since.IsZero() { |
| 147 | + clauses = append(clauses, "e.created_at >= ?") |
| 148 | + args = append(args, opts.Since.Format(time.RFC3339Nano)) |
| 149 | + } |
| 150 | + |
| 151 | + where := strings.Join(clauses, " AND ") |
| 152 | + args = append(args, opts.Limit) |
| 153 | + |
| 154 | + q := fmt.Sprintf( |
| 155 | + `SELECT e.id, e.command, e.exit_code, e.duration_ms, e.cwd, e.git_branch, e.session_id, e.created_at |
| 156 | + FROM entries e |
| 157 | + JOIN entries_fts ON entries_fts.rowid = e.rowid |
| 158 | + WHERE %s |
| 159 | + ORDER BY e.created_at DESC |
| 160 | + LIMIT ?`, where, |
| 161 | + ) |
| 162 | + |
| 163 | + rows, err := s.db.Query(q, args...) |
| 164 | + if err != nil { |
| 165 | + return nil, fmt.Errorf("search query: %w", err) |
| 166 | + } |
| 167 | + defer rows.Close() |
| 168 | + |
| 169 | + return scanEntries(rows) |
| 170 | +} |
| 171 | + |
| 172 | +// Recent returns the N most recent commands, ordered newest-first. |
| 173 | +func (s *Store) Recent(n int) ([]Entry, error) { |
| 174 | + if n <= 0 { |
| 175 | + n = 20 |
| 176 | + } |
| 177 | + |
| 178 | + rows, err := s.db.Query( |
| 179 | + `SELECT id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at |
| 180 | + FROM entries |
| 181 | + ORDER BY created_at DESC |
| 182 | + LIMIT ?`, n, |
| 183 | + ) |
| 184 | + if err != nil { |
| 185 | + return nil, fmt.Errorf("recent query: %w", err) |
| 186 | + } |
| 187 | + defer rows.Close() |
| 188 | + |
| 189 | + return scanEntries(rows) |
| 190 | +} |
| 191 | + |
| 192 | +// SearchByDir returns commands executed in a specific directory, newest-first. |
| 193 | +func (s *Store) SearchByDir(dir string, limit int) ([]Entry, error) { |
| 194 | + if limit <= 0 { |
| 195 | + limit = 50 |
| 196 | + } |
| 197 | + |
| 198 | + rows, err := s.db.Query( |
| 199 | + `SELECT id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at |
| 200 | + FROM entries |
| 201 | + WHERE cwd = ? |
| 202 | + ORDER BY created_at DESC |
| 203 | + LIMIT ?`, dir, limit, |
| 204 | + ) |
| 205 | + if err != nil { |
| 206 | + return nil, fmt.Errorf("search by dir: %w", err) |
| 207 | + } |
| 208 | + defer rows.Close() |
| 209 | + |
| 210 | + return scanEntries(rows) |
| 211 | +} |
| 212 | + |
| 213 | +// Stats returns aggregate usage statistics computed in SQL for efficiency. |
| 214 | +func (s *Store) Stats() (*HistoryStats, error) { |
| 215 | + stats := &HistoryStats{} |
| 216 | + |
| 217 | + // Total and unique command counts, plus success rate. |
| 218 | + err := s.db.QueryRow(` |
| 219 | + SELECT |
| 220 | + COUNT(*) AS total, |
| 221 | + COUNT(DISTINCT command) AS uniq, |
| 222 | + COALESCE( |
| 223 | + CAST(SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) AS REAL) / NULLIF(COUNT(*), 0), |
| 224 | + 0 |
| 225 | + ) AS success_rate |
| 226 | + FROM entries |
| 227 | + `).Scan(&stats.TotalCommands, &stats.UniqueCommands, &stats.SuccessRate) |
| 228 | + if err != nil { |
| 229 | + return nil, fmt.Errorf("stats totals: %w", err) |
| 230 | + } |
| 231 | + |
| 232 | + // Top 10 commands by frequency. |
| 233 | + cmdRows, err := s.db.Query(` |
| 234 | + SELECT command, COUNT(*) AS cnt |
| 235 | + FROM entries |
| 236 | + GROUP BY command |
| 237 | + ORDER BY cnt DESC |
| 238 | + LIMIT 10 |
| 239 | + `) |
| 240 | + if err != nil { |
| 241 | + return nil, fmt.Errorf("stats top commands: %w", err) |
| 242 | + } |
| 243 | + defer cmdRows.Close() |
| 244 | + |
| 245 | + for cmdRows.Next() { |
| 246 | + var cc CommandCount |
| 247 | + if err := cmdRows.Scan(&cc.Command, &cc.Count); err != nil { |
| 248 | + return nil, fmt.Errorf("scan command count: %w", err) |
| 249 | + } |
| 250 | + stats.TopCommands = append(stats.TopCommands, cc) |
| 251 | + } |
| 252 | + if err := cmdRows.Err(); err != nil { |
| 253 | + return nil, err |
| 254 | + } |
| 255 | + |
| 256 | + // Top 10 directories by frequency. |
| 257 | + dirRows, err := s.db.Query(` |
| 258 | + SELECT cwd, COUNT(*) AS cnt |
| 259 | + FROM entries |
| 260 | + WHERE cwd != '' |
| 261 | + GROUP BY cwd |
| 262 | + ORDER BY cnt DESC |
| 263 | + LIMIT 10 |
| 264 | + `) |
| 265 | + if err != nil { |
| 266 | + return nil, fmt.Errorf("stats top dirs: %w", err) |
| 267 | + } |
| 268 | + defer dirRows.Close() |
| 269 | + |
| 270 | + for dirRows.Next() { |
| 271 | + var dc DirCount |
| 272 | + if err := dirRows.Scan(&dc.Dir, &dc.Count); err != nil { |
| 273 | + return nil, fmt.Errorf("scan dir count: %w", err) |
| 274 | + } |
| 275 | + stats.TopDirectories = append(stats.TopDirectories, dc) |
| 276 | + } |
| 277 | + if err := dirRows.Err(); err != nil { |
| 278 | + return nil, err |
| 279 | + } |
| 280 | + |
| 281 | + return stats, nil |
| 282 | +} |
| 283 | + |
| 284 | +// Close closes the underlying database connection. |
| 285 | +func (s *Store) Close() error { |
| 286 | + return s.db.Close() |
| 287 | +} |
| 288 | + |
| 289 | +// --- internal helpers --- |
| 290 | + |
| 291 | +func scanEntries(rows *sql.Rows) ([]Entry, error) { |
| 292 | + var entries []Entry |
| 293 | + for rows.Next() { |
| 294 | + var ( |
| 295 | + e Entry |
| 296 | + durMS int64 |
| 297 | + createdStr string |
| 298 | + ) |
| 299 | + err := rows.Scan( |
| 300 | + &e.ID, &e.Command, &e.ExitCode, &durMS, |
| 301 | + &e.CWD, &e.GitBranch, &e.SessionID, &createdStr, |
| 302 | + ) |
| 303 | + if err != nil { |
| 304 | + return nil, fmt.Errorf("scan entry: %w", err) |
| 305 | + } |
| 306 | + e.Duration = time.Duration(durMS) * time.Millisecond |
| 307 | + e.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdStr) |
| 308 | + entries = append(entries, e) |
| 309 | + } |
| 310 | + return entries, rows.Err() |
| 311 | +} |
| 312 | + |
| 313 | +// generateID produces a short 8-character hex ID from crypto/rand. |
| 314 | +func generateID() string { |
| 315 | + b := make([]byte, 4) |
| 316 | + rand.Read(b) |
| 317 | + return fmt.Sprintf("%x", b) |
| 318 | +} |
0 commit comments