Commit b772cf62ec91

Vincent Demeester <vincent@sbr.pm>
2025-12-10 13:32:10
feat(claude-hooks): Add automatic session summaries with statistics
- Replace optional save prompt with mandatory summary generation - Provide rich context via session statistics for better summaries - Enable parsing of tool outputs to track duration and file changes Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 4bd2c70
Changed files (3)
tools
claude-hooks
cmd
save-session
session-stats
tools/claude-hooks/cmd/save-session/main.go
@@ -32,18 +32,44 @@ func main() {
 		fmt.Fprintf(os.Stderr, "[save-session] Warning: Could not send notification: %v\n", err)
 	}
 
-	// Output prompt for Claude to save the session
+	// Get session statistics
+	statsCmd := exec.Command("claude-hooks-session-stats")
+	statsCmd.Env = os.Environ()
+	statsOutput, err := statsCmd.Output()
+	if err != nil {
+		// Continue without stats if tool fails
+		fmt.Fprintf(os.Stderr, "[save-session] Warning: Could not get session stats: %v\n", err)
+	}
+
+	// Output directive for Claude to save the session
 	// This will be shown in the conversation
 	fmt.Println("")
 	fmt.Println("---")
 	fmt.Println("")
-	fmt.Println("**Session ending**. Would you like me to save a summary of this session to your history?")
+	fmt.Println("# Automatic Session Summary")
 	fmt.Println("")
-	fmt.Println("I can create a session entry in `~/.config/claude/history/sessions/` documenting:")
-	fmt.Println("- What was accomplished")
-	fmt.Println("- Decisions made")
-	fmt.Println("- Next steps")
-	fmt.Println("- Related notes")
+	fmt.Println("**IMPORTANT**: Please create a session summary and save it to the history directory.")
+	fmt.Println("")
+
+	// Show statistics if available
+	if len(statsOutput) > 0 {
+		fmt.Print(string(statsOutput))
+	}
+
+	fmt.Println("## Instructions")
+	fmt.Println("")
+	fmt.Println("Create a brief summary (2-4 paragraphs) of this session documenting:")
+	fmt.Println("- Primary tasks accomplished")
+	fmt.Println("- Key decisions or solutions")
+	fmt.Println("- Files/systems modified")
+	fmt.Println("- Any remaining work or next steps")
+	fmt.Println("")
+	fmt.Println("Save the summary using the Write tool to:")
+	fmt.Println("`~/.config/claude/history/sessions/<YYYY-MM>/<YYYY-MM-DD>_<brief-slug>.md`")
+	fmt.Println("")
+	fmt.Println("Use the format: `YYYY-MM-DD_<2-4 word slug describing the session>.md`")
+	fmt.Println("")
+	fmt.Println("Example: `~/.config/claude/history/sessions/2025-12/2025-12-10_notification-filtering-arr-completion.md`")
 	fmt.Println("")
 
 	os.Exit(0)
tools/claude-hooks/cmd/session-stats/main.go
@@ -0,0 +1,231 @@
+package main
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/vdemeester/home/tools/claude-hooks/internal/paths"
+)
+
+// CaptureEntry represents a log entry from tool-outputs JSONL
+type CaptureEntry struct {
+	Timestamp string                 `json:"timestamp"`
+	Tool      string                 `json:"tool"`
+	Input     map[string]interface{} `json:"input"`
+	Output    map[string]interface{} `json:"output"`
+	Session   string                 `json:"session"`
+}
+
+// SessionStats holds aggregated statistics
+type SessionStats struct {
+	ToolCounts    map[string]int
+	FilesModified []string
+	FilesRead     []string
+	CommandsRun   []string
+	StartTime     time.Time
+	EndTime       time.Time
+	Duration      time.Duration
+}
+
+func main() {
+	conversationID := os.Getenv("CLAUDE_CONVERSATION_ID")
+
+	// Get today's tool output file
+	now := time.Now()
+	today := now.Format("2006-01-02")
+	yearMonth := now.Format("2006-01")
+
+	toolOutputFile := filepath.Join(
+		paths.HistoryDir(),
+		"tool-outputs",
+		yearMonth,
+		fmt.Sprintf("%s_tool-outputs.jsonl", today),
+	)
+
+	stats := &SessionStats{
+		ToolCounts:    make(map[string]int),
+		FilesModified: []string{},
+		FilesRead:     []string{},
+		CommandsRun:   []string{},
+	}
+
+	// Read and parse JSONL
+	f, err := os.Open(toolOutputFile)
+	if err != nil {
+		// Silent failure - file might not exist for new sessions
+		fmt.Fprintln(os.Stderr, "[session-stats] No tool output file found")
+		os.Exit(0)
+	}
+	defer f.Close()
+
+	scanner := bufio.NewScanner(f)
+	// Increase buffer size for large lines
+	buf := make([]byte, 0, 64*1024)
+	scanner.Buffer(buf, 1024*1024)
+
+	firstEntry := true
+	for scanner.Scan() {
+		var entry CaptureEntry
+		if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
+			continue // Skip malformed lines
+		}
+
+		// Only count entries from this session if we have a conversation ID
+		if conversationID != "" && entry.Session != "" && entry.Session != conversationID {
+			continue
+		}
+
+		stats.ToolCounts[entry.Tool]++
+
+		// Track timestamps
+		if t, err := time.Parse(time.RFC3339, entry.Timestamp); err == nil {
+			if firstEntry {
+				stats.StartTime = t
+				firstEntry = false
+			}
+			stats.EndTime = t
+		}
+
+		// Extract file paths and commands based on tool type
+		switch entry.Tool {
+		case "Edit", "Write":
+			if path, ok := entry.Input["file_path"].(string); ok {
+				if !contains(stats.FilesModified, path) {
+					stats.FilesModified = append(stats.FilesModified, path)
+				}
+			}
+		case "Read":
+			if path, ok := entry.Input["file_path"].(string); ok {
+				if !contains(stats.FilesRead, path) {
+					stats.FilesRead = append(stats.FilesRead, path)
+				}
+			}
+		case "Bash":
+			if cmd, ok := entry.Input["command"].(string); ok {
+				// Only include interesting commands (not trivial ones)
+				if isInterestingCommand(cmd) {
+					// Truncate long commands
+					if len(cmd) > 80 {
+						cmd = cmd[:77] + "..."
+					}
+					stats.CommandsRun = append(stats.CommandsRun, cmd)
+				}
+			}
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		fmt.Fprintf(os.Stderr, "[session-stats] Error reading file: %v\n", err)
+	}
+
+	// Calculate duration
+	if !stats.StartTime.IsZero() && !stats.EndTime.IsZero() {
+		stats.Duration = stats.EndTime.Sub(stats.StartTime)
+	}
+
+	// Output statistics
+	printStats(stats)
+}
+
+func contains(slice []string, item string) bool {
+	for _, s := range slice {
+		if s == item {
+			return true
+		}
+	}
+	return false
+}
+
+func isInterestingCommand(cmd string) bool {
+	// Skip trivial commands
+	boring := []string{
+		"ls ", "pwd", "echo ", "cat ", "head ", "tail ",
+		"git status", "git diff", "git log",
+	}
+
+	cmdLower := strings.ToLower(cmd)
+	for _, b := range boring {
+		if strings.HasPrefix(cmdLower, b) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func printStats(stats *SessionStats) {
+	fmt.Println("## Session Statistics")
+	fmt.Println()
+
+	// Duration
+	if stats.Duration > 0 {
+		fmt.Printf("**Duration:** %s\n", stats.Duration.Round(time.Second))
+		fmt.Println()
+	}
+
+	// Tool usage
+	if len(stats.ToolCounts) > 0 {
+		fmt.Println("**Tools used:**")
+
+		// Sort tools by count
+		type toolCount struct {
+			tool  string
+			count int
+		}
+		var tools []toolCount
+		for tool, count := range stats.ToolCounts {
+			tools = append(tools, toolCount{tool, count})
+		}
+		sort.Slice(tools, func(i, j int) bool {
+			return tools[i].count > tools[j].count
+		})
+
+		for _, tc := range tools {
+			fmt.Printf("- %s: %d\n", tc.tool, tc.count)
+		}
+		fmt.Println()
+	}
+
+	// Files modified
+	if len(stats.FilesModified) > 0 {
+		fmt.Println("**Files modified:**")
+		for _, f := range stats.FilesModified {
+			// Shorten home directory paths
+			f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
+			fmt.Printf("- %s\n", f)
+		}
+		fmt.Println()
+	}
+
+	// Notable commands (limit to 10)
+	if len(stats.CommandsRun) > 0 {
+		fmt.Println("**Commands executed:**")
+		limit := len(stats.CommandsRun)
+		if limit > 10 {
+			limit = 10
+		}
+		for i := 0; i < limit; i++ {
+			fmt.Printf("- `%s`\n", stats.CommandsRun[i])
+		}
+		if len(stats.CommandsRun) > 10 {
+			fmt.Printf("- ... and %d more\n", len(stats.CommandsRun)-10)
+		}
+		fmt.Println()
+	}
+
+	// File reads (only if not too many)
+	if len(stats.FilesRead) > 0 && len(stats.FilesRead) <= 15 {
+		fmt.Println("**Files read:**")
+		for _, f := range stats.FilesRead {
+			f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
+			fmt.Printf("- %s\n", f)
+		}
+		fmt.Println()
+	}
+}
tools/claude-hooks/default.nix
@@ -16,6 +16,7 @@ buildGoModule {
     "cmd/initialize-session"
     "cmd/validate-docs"
     "cmd/save-session"
+    "cmd/session-stats"
   ];
 
   # Rename binaries to have consistent prefix
@@ -24,6 +25,7 @@ buildGoModule {
     mv $out/bin/initialize-session $out/bin/claude-hooks-initialize-session
     mv $out/bin/validate-docs $out/bin/claude-hooks-validate-docs
     mv $out/bin/save-session $out/bin/claude-hooks-save-session
+    mv $out/bin/session-stats $out/bin/claude-hooks-session-stats
   '';
 
   meta = {