fedora-csb-system-manager
1package main
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11 "time"
12
13 "github.com/vdemeester/home/tools/claude-hooks/internal/paths"
14)
15
16// CaptureEntry represents a log entry from tool-outputs JSONL
17type CaptureEntry struct {
18 Timestamp string `json:"timestamp"`
19 Tool string `json:"tool"`
20 Input map[string]interface{} `json:"input"`
21 Output map[string]interface{} `json:"output"`
22 Session string `json:"session"`
23}
24
25// SessionStats holds aggregated statistics
26type SessionStats struct {
27 ToolCounts map[string]int
28 FilesModified []string
29 FilesRead []string
30 CommandsRun []string
31 StartTime time.Time
32 EndTime time.Time
33 Duration time.Duration
34}
35
36func main() {
37 conversationID := os.Getenv("CLAUDE_CONVERSATION_ID")
38
39 // Get today's tool output file
40 now := time.Now()
41 today := now.Format("2006-01-02")
42 yearMonth := now.Format("2006-01")
43
44 toolOutputFile := filepath.Join(
45 paths.HistoryDir(),
46 "tool-outputs",
47 yearMonth,
48 fmt.Sprintf("%s_tool-outputs.jsonl", today),
49 )
50
51 stats := &SessionStats{
52 ToolCounts: make(map[string]int),
53 FilesModified: []string{},
54 FilesRead: []string{},
55 CommandsRun: []string{},
56 }
57
58 // Read and parse JSONL
59 f, err := os.Open(toolOutputFile)
60 if err != nil {
61 // Silent failure - file might not exist for new sessions
62 fmt.Fprintln(os.Stderr, "[session-stats] No tool output file found")
63 os.Exit(0)
64 }
65 defer f.Close()
66
67 scanner := bufio.NewScanner(f)
68 // Increase buffer size for large lines
69 buf := make([]byte, 0, 64*1024)
70 scanner.Buffer(buf, 1024*1024)
71
72 firstEntry := true
73 for scanner.Scan() {
74 var entry CaptureEntry
75 if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
76 continue // Skip malformed lines
77 }
78
79 // Only count entries from this session if we have a conversation ID
80 if conversationID != "" && entry.Session != "" && entry.Session != conversationID {
81 continue
82 }
83
84 stats.ToolCounts[entry.Tool]++
85
86 // Track timestamps
87 if t, err := time.Parse(time.RFC3339, entry.Timestamp); err == nil {
88 if firstEntry {
89 stats.StartTime = t
90 firstEntry = false
91 }
92 stats.EndTime = t
93 }
94
95 // Extract file paths and commands based on tool type
96 switch entry.Tool {
97 case "Edit", "Write":
98 if path, ok := entry.Input["file_path"].(string); ok {
99 if !contains(stats.FilesModified, path) {
100 stats.FilesModified = append(stats.FilesModified, path)
101 }
102 }
103 case "Read":
104 if path, ok := entry.Input["file_path"].(string); ok {
105 if !contains(stats.FilesRead, path) {
106 stats.FilesRead = append(stats.FilesRead, path)
107 }
108 }
109 case "Bash":
110 if cmd, ok := entry.Input["command"].(string); ok {
111 // Only include interesting commands (not trivial ones)
112 if isInterestingCommand(cmd) {
113 // Truncate long commands
114 if len(cmd) > 80 {
115 cmd = cmd[:77] + "..."
116 }
117 stats.CommandsRun = append(stats.CommandsRun, cmd)
118 }
119 }
120 }
121 }
122
123 if err := scanner.Err(); err != nil {
124 fmt.Fprintf(os.Stderr, "[session-stats] Error reading file: %v\n", err)
125 }
126
127 // Calculate duration
128 if !stats.StartTime.IsZero() && !stats.EndTime.IsZero() {
129 stats.Duration = stats.EndTime.Sub(stats.StartTime)
130 }
131
132 // Output statistics
133 printStats(stats)
134}
135
136func contains(slice []string, item string) bool {
137 for _, s := range slice {
138 if s == item {
139 return true
140 }
141 }
142 return false
143}
144
145func isInterestingCommand(cmd string) bool {
146 // Skip trivial commands
147 boring := []string{
148 "ls ", "pwd", "echo ", "cat ", "head ", "tail ",
149 "git status", "git diff", "git log",
150 }
151
152 cmdLower := strings.ToLower(cmd)
153 for _, b := range boring {
154 if strings.HasPrefix(cmdLower, b) {
155 return false
156 }
157 }
158
159 return true
160}
161
162func printStats(stats *SessionStats) {
163 fmt.Println("## Session Statistics")
164 fmt.Println()
165
166 // Duration
167 if stats.Duration > 0 {
168 fmt.Printf("**Duration:** %s\n", stats.Duration.Round(time.Second))
169 fmt.Println()
170 }
171
172 // Tool usage
173 if len(stats.ToolCounts) > 0 {
174 fmt.Println("**Tools used:**")
175
176 // Sort tools by count
177 type toolCount struct {
178 tool string
179 count int
180 }
181 var tools []toolCount
182 for tool, count := range stats.ToolCounts {
183 tools = append(tools, toolCount{tool, count})
184 }
185 sort.Slice(tools, func(i, j int) bool {
186 return tools[i].count > tools[j].count
187 })
188
189 for _, tc := range tools {
190 fmt.Printf("- %s: %d\n", tc.tool, tc.count)
191 }
192 fmt.Println()
193 }
194
195 // Files modified
196 if len(stats.FilesModified) > 0 {
197 fmt.Println("**Files modified:**")
198 for _, f := range stats.FilesModified {
199 // Shorten home directory paths
200 f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
201 fmt.Printf("- %s\n", f)
202 }
203 fmt.Println()
204 }
205
206 // Notable commands (limit to 10)
207 if len(stats.CommandsRun) > 0 {
208 fmt.Println("**Commands executed:**")
209 limit := len(stats.CommandsRun)
210 if limit > 10 {
211 limit = 10
212 }
213 for i := 0; i < limit; i++ {
214 fmt.Printf("- `%s`\n", stats.CommandsRun[i])
215 }
216 if len(stats.CommandsRun) > 10 {
217 fmt.Printf("- ... and %d more\n", len(stats.CommandsRun)-10)
218 }
219 fmt.Println()
220 }
221
222 // File reads (only if not too many)
223 if len(stats.FilesRead) > 0 && len(stats.FilesRead) <= 15 {
224 fmt.Println("**Files read:**")
225 for _, f := range stats.FilesRead {
226 f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
227 fmt.Printf("- %s\n", f)
228 }
229 fmt.Println()
230 }
231}