feat: modernise to Go 1.26 iterators and stdlib helpers
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 1m58s

Add ListSessionsSeq, EventsSeq, SearchSeq iterators for streaming.
Use slices.SortFunc, slices.Sorted(maps.Keys()), slices.Collect
in ListSessions, Search, FormatAnalytics, extractToolInput.

Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-23 05:25:33 +00:00
parent 049df373de
commit 89a431c1b9
7 changed files with 194 additions and 102 deletions

View file

@ -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]

View file

@ -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",

12
go.sum
View file

@ -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=

128
parser.go
View file

@ -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, ", ")
}

View file

@ -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()

View file

@ -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
}

View file

@ -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",