Commit b772cf62ec91
Changed files (3)
tools
claude-hooks
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 = {