diff --git a/analytics.go b/analytics.go index 73c62db..5192e58 100644 --- a/analytics.go +++ b/analytics.go @@ -3,21 +3,22 @@ package session import ( "fmt" - "sort" + "maps" + "slices" "strings" "time" ) // SessionAnalytics holds computed metrics for a parsed session. type SessionAnalytics struct { - Duration time.Duration - ActiveTime time.Duration - EventCount int - ToolCounts map[string]int - ErrorCounts map[string]int - SuccessRate float64 - AvgLatency map[string]time.Duration - MaxLatency map[string]time.Duration + Duration time.Duration + ActiveTime time.Duration + EventCount int + ToolCounts map[string]int + ErrorCounts map[string]int + SuccessRate float64 + AvgLatency map[string]time.Duration + MaxLatency map[string]time.Duration EstimatedInputTokens int EstimatedOutputTokens int } @@ -117,13 +118,7 @@ func FormatAnalytics(a *SessionAnalytics) string { b.WriteString(" " + strings.Repeat("-", 48) + "\n") // Sort tools for deterministic output - tools := make([]string, 0, len(a.ToolCounts)) - for t := range a.ToolCounts { - tools = append(tools, t) - } - sort.Strings(tools) - - for _, tool := range tools { + for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) { errors := a.ErrorCounts[tool] avg := a.AvgLatency[tool] max := a.MaxLatency[tool] diff --git a/analytics_test.go b/analytics_test.go index e1ae95c..90a872e 100644 --- a/analytics_test.go +++ b/analytics_test.go @@ -74,8 +74,8 @@ func TestAnalyse_MixedToolsWithErrors_Good(t *testing.T) { EndTime: time.Date(2026, 2, 20, 10, 5, 0, 0, time.UTC), Events: []Event{ { - Type: "user", - Input: "Please help", + Type: "user", + Input: "Please help", }, { Type: "tool_use", @@ -204,12 +204,12 @@ func TestAnalyse_TokenEstimation_Good(t *testing.T) { Input: strings.Repeat("a", 400), // 100 tokens }, { - Type: "tool_use", - Tool: "Bash", - Input: strings.Repeat("b", 80), // 20 tokens - Output: strings.Repeat("c", 200), // 50 tokens + Type: "tool_use", + Tool: "Bash", + Input: strings.Repeat("b", 80), // 20 tokens + Output: strings.Repeat("c", 200), // 50 tokens Duration: time.Second, - Success: true, + Success: true, }, { Type: "assistant", diff --git a/go.sum b/go.sum index 309ca2e..5a10c39 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,23 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/parser.go b/parser.go index 4998e82..b575685 100644 --- a/parser.go +++ b/parser.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "io" + "iter" + "maps" "os" "path/filepath" - "sort" + "slices" "strings" "time" ) @@ -38,6 +40,11 @@ type Session struct { Events []Event } +// EventsSeq returns an iterator over the session's events. +func (s *Session) EventsSeq() iter.Seq[Event] { + return slices.Values(s.Events) +} + // rawEntry is the top-level structure of a Claude Code JSONL line. type rawEntry struct { Type string `json:"type"` @@ -112,68 +119,85 @@ type ParseStats struct { // ListSessions returns all sessions found in the Claude projects directory. func ListSessions(projectsDir string) ([]Session, error) { - matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl")) - if err != nil { - return nil, fmt.Errorf("glob sessions: %w", err) - } + return slices.Collect(ListSessionsSeq(projectsDir)), nil +} - var sessions []Session - for _, path := range matches { - base := filepath.Base(path) - id := strings.TrimSuffix(base, ".jsonl") - - info, err := os.Stat(path) +// ListSessionsSeq returns an iterator over all sessions found in the Claude projects directory. +func ListSessionsSeq(projectsDir string) iter.Seq[Session] { + return func(yield func(Session) bool) { + matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl")) if err != nil { - continue + return } - s := Session{ - ID: id, - Path: path, - } + var sessions []Session + for _, path := range matches { + base := filepath.Base(path) + id := strings.TrimSuffix(base, ".jsonl") - // Quick scan for first and last timestamps - f, err := os.Open(path) - if err != nil { - continue - } - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - var firstTS, lastTS string - for scanner.Scan() { - var entry rawEntry - if json.Unmarshal(scanner.Bytes(), &entry) != nil { + info, err := os.Stat(path) + if err != nil { continue } - if entry.Timestamp == "" { + + s := Session{ + ID: id, + Path: path, + } + + // Quick scan for first and last timestamps + f, err := os.Open(path) + if err != nil { continue } - if firstTS == "" { - firstTS = entry.Timestamp + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + var firstTS, lastTS string + for scanner.Scan() { + var entry rawEntry + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if entry.Timestamp == "" { + continue + } + if firstTS == "" { + firstTS = entry.Timestamp + } + lastTS = entry.Timestamp } - lastTS = entry.Timestamp - } - f.Close() + f.Close() - if firstTS != "" { - s.StartTime, _ = time.Parse(time.RFC3339Nano, firstTS) - } - if lastTS != "" { - s.EndTime, _ = time.Parse(time.RFC3339Nano, lastTS) - } - if s.StartTime.IsZero() { - s.StartTime = info.ModTime() + if firstTS != "" { + s.StartTime, _ = time.Parse(time.RFC3339Nano, firstTS) + } + if lastTS != "" { + s.EndTime, _ = time.Parse(time.RFC3339Nano, lastTS) + } + if s.StartTime.IsZero() { + s.StartTime = info.ModTime() + } + + sessions = append(sessions, s) } - sessions = append(sessions, s) + slices.SortFunc(sessions, func(i, j Session) int { + if i.StartTime.After(j.StartTime) { + return -1 + } + if i.StartTime.Before(j.StartTime) { + return 1 + } + return 0 + }) + + for _, s := range sessions { + if !yield(s) { + return + } + } } - - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].StartTime.After(sessions[j].StartTime) - }) - - return sessions, nil } // ParseTranscript reads a JSONL session file and returns structured events. @@ -419,11 +443,7 @@ func extractToolInput(toolName string, raw json.RawMessage) string { // Fallback: show raw JSON keys var m map[string]any if json.Unmarshal(raw, &m) == nil { - var parts []string - for k := range m { - parts = append(parts, k) - } - sort.Strings(parts) + parts := slices.Sorted(maps.Keys(m)) return strings.Join(parts, ", ") } diff --git a/parser_test.go b/parser_test.go index 1601f7a..2f5c68d 100644 --- a/parser_test.go +++ b/parser_test.go @@ -479,6 +479,23 @@ func TestParseTranscript_TextTruncation_Good(t *testing.T) { assert.True(t, strings.HasSuffix(sess.Events[0].Input, "..."), "truncated text should end with ...") } +func TestSession_EventsSeq_Good(t *testing.T) { + sess := &Session{ + Events: []Event{ + {Type: "user", Input: "one"}, + {Type: "assistant", Input: "two"}, + {Type: "tool_use", Tool: "Bash", Input: "three"}, + }, + } + + var events []Event + for e := range sess.EventsSeq() { + events = append(events, e) + } + + assert.Equal(t, sess.Events, events) +} + func TestParseTranscript_MixedContentBlocks_Good(t *testing.T) { // Assistant message with both text and tool_use in the same message dir := t.TempDir() @@ -624,6 +641,26 @@ func TestListSessions_NonJSONLIgnored_Good(t *testing.T) { assert.Equal(t, "real-session", sessions[0].ID) } +func TestListSessionsSeq_MultipleSorted_Good(t *testing.T) { + dir := t.TempDir() + + // Create three sessions with different timestamps. + writeJSONL(t, dir, "old.jsonl", userTextEntry(ts(0), "old")) + writeJSONL(t, dir, "mid.jsonl", userTextEntry(ts(100), "mid")) + writeJSONL(t, dir, "new.jsonl", userTextEntry(ts(200), "new")) + + var sessions []Session + for s := range ListSessionsSeq(dir) { + sessions = append(sessions, s) + } + + require.Len(t, sessions, 3) + // Should be sorted newest first + assert.Equal(t, "new", sessions[0].ID) + assert.Equal(t, "mid", sessions[1].ID) + assert.Equal(t, "old", sessions[2].ID) +} + func TestListSessions_MalformedJSONLStillListed_Bad(t *testing.T) { dir := t.TempDir() diff --git a/search.go b/search.go index 69dc9c3..629b4e8 100644 --- a/search.go +++ b/search.go @@ -1,7 +1,9 @@ package session import ( + "iter" "path/filepath" + "slices" "strings" "time" ) @@ -16,39 +18,46 @@ type SearchResult struct { // Search finds events matching the query across all sessions in the directory. func Search(projectsDir, query string) ([]SearchResult, error) { - matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl")) - if err != nil { - return nil, err - } + return slices.Collect(SearchSeq(projectsDir, query)), nil +} - var results []SearchResult - query = strings.ToLower(query) - - for _, path := range matches { - sess, _, err := ParseTranscript(path) +// SearchSeq returns an iterator over search results matching the query across all sessions. +func SearchSeq(projectsDir, query string) iter.Seq[SearchResult] { + return func(yield func(SearchResult) bool) { + matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl")) if err != nil { - continue + return } - for _, evt := range sess.Events { - if evt.Type != "tool_use" { + query = strings.ToLower(query) + + for _, path := range matches { + sess, _, err := ParseTranscript(path) + if err != nil { continue } - text := strings.ToLower(evt.Input + " " + evt.Output) - if strings.Contains(text, query) { - matchCtx := evt.Input - if matchCtx == "" { - matchCtx = truncate(evt.Output, 120) + + for _, evt := range sess.Events { + if evt.Type != "tool_use" { + continue + } + text := strings.ToLower(evt.Input + " " + evt.Output) + if strings.Contains(text, query) { + matchCtx := evt.Input + if matchCtx == "" { + matchCtx = truncate(evt.Output, 120) + } + res := SearchResult{ + SessionID: sess.ID, + Timestamp: evt.Timestamp, + Tool: evt.Tool, + Match: matchCtx, + } + if !yield(res) { + return + } } - results = append(results, SearchResult{ - SessionID: sess.ID, - Timestamp: evt.Timestamp, - Tool: evt.Tool, - Match: matchCtx, - }) } } } - - return results, nil } diff --git a/search_test.go b/search_test.go index 4fc620d..c9ab533 100644 --- a/search_test.go +++ b/search_test.go @@ -50,6 +50,25 @@ func TestSearch_SingleMatch_Good(t *testing.T) { assert.Contains(t, results[0].Match, "go test") } +func TestSearchSeq_SingleMatch_Good(t *testing.T) { + dir := t.TempDir() + writeJSONL(t, dir, "session.jsonl", + toolUseEntry(ts(0), "Bash", "tool-1", map[string]any{ + "command": "go test ./...", + }), + toolResultEntry(ts(1), "tool-1", "PASS ok mypackage 0.5s", false), + ) + + var results []SearchResult + for r := range SearchSeq(dir, "go test") { + results = append(results, r) + } + + require.Len(t, results, 1) + assert.Equal(t, "session", results[0].SessionID) + assert.Equal(t, "Bash", results[0].Tool) +} + func TestSearch_MultipleMatches_Good(t *testing.T) { dir := t.TempDir() writeJSONL(t, dir, "session1.jsonl",