flake-update-20260201
  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}