feat: modernise to Go 1.26 iterators and stdlib helpers
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:
parent
049df373de
commit
89a431c1b9
7 changed files with 194 additions and 102 deletions
27
analytics.go
27
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]
|
||||
|
|
|
|||
|
|
@ -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
12
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=
|
||||
|
|
|
|||
128
parser.go
128
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, ", ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
59
search.go
59
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue