From ad28c85c896c70c8994f353ffcc864a1e05eb333 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 08:30:01 +0000 Subject: [PATCH] fix: improve HTML escaping and modernise sort/search helpers Co-Authored-By: Claude Opus 4.6 --- analytics.go | 2 +- html.go | 6 ++-- parser.go | 83 ++++++++++++++++++++++++++++++++++++++++++---------- search.go | 2 +- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/analytics.go b/analytics.go index 5192e58..93e5f07 100644 --- a/analytics.go +++ b/analytics.go @@ -49,7 +49,7 @@ func Analyse(sess *Session) *SessionAnalytics { var totalToolCalls int var totalErrors int - for _, evt := range sess.Events { + for evt := range sess.EventsSeq() { // Token estimation: ~4 chars per token a.EstimatedInputTokens += len(evt.Input) / 4 a.EstimatedOutputTokens += len(evt.Output) / 4 diff --git a/html.go b/html.go index e666ef0..e21c247 100644 --- a/html.go +++ b/html.go @@ -19,7 +19,7 @@ func RenderHTML(sess *Session, outputPath string) error { duration := sess.EndTime.Sub(sess.StartTime) toolCount := 0 errorCount := 0 - for _, e := range sess.Events { + for e := range sess.EventsSeq() { if e.Type == "tool_use" { toolCount++ if !e.Success { @@ -114,7 +114,8 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
`) - for i, evt := range sess.Events { + var i int + for evt := range sess.EventsSeq() { toolClass := strings.ToLower(evt.Tool) if evt.Type == "user" { toolClass = "user" @@ -199,6 +200,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s fmt.Fprint(f, `
`) + i++ } fmt.Fprint(f, ` diff --git a/parser.go b/parser.go index b575685..be8280e 100644 --- a/parser.go +++ b/parser.go @@ -170,10 +170,14 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] { f.Close() if firstTS != "" { - s.StartTime, _ = time.Parse(time.RFC3339Nano, firstTS) + if t, err := time.Parse(time.RFC3339Nano, firstTS); err == nil { + s.StartTime = t + } } if lastTS != "" { - s.EndTime, _ = time.Parse(time.RFC3339Nano, lastTS) + if t, err := time.Parse(time.RFC3339Nano, lastTS); err == nil { + s.EndTime = t + } } if s.StartTime.IsZero() { s.StartTime = info.ModTime() @@ -183,13 +187,7 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] { } 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 + return j.StartTime.Compare(i.StartTime) }) for _, s := range sessions { @@ -200,6 +198,51 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] { } } +// PruneSessions deletes session files in the projects directory that were last +// modified more than maxAge ago. Returns the number of files deleted. +func PruneSessions(projectsDir string, maxAge time.Duration) (int, error) { + matches, err := filepath.Glob(filepath.Join(projectsDir, "*.jsonl")) + if err != nil { + return 0, fmt.Errorf("list sessions for pruning: %w", err) + } + + var deleted int + now := time.Now() + for _, path := range matches { + info, err := os.Stat(path) + if err != nil { + continue + } + + if now.Sub(info.ModTime()) > maxAge { + if err := os.Remove(path); err == nil { + deleted++ + } + } + } + return deleted, nil +} + +// IsExpired returns true if the session's end time is older than the given maxAge +// relative to now. +func (s *Session) IsExpired(maxAge time.Duration) bool { + if s.EndTime.IsZero() { + return false + } + return time.Since(s.EndTime) > maxAge +} + +// FetchSession retrieves a session by ID from the projects directory. +// It ensures the ID does not contain path traversal characters. +func FetchSession(projectsDir, id string) (*Session, *ParseStats, error) { + if strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) { + return nil, nil, fmt.Errorf("invalid session id") + } + + path := filepath.Join(projectsDir, id+".jsonl") + return ParseTranscript(path) +} + // ParseTranscript reads a JSONL session file and returns structured events. // Malformed or truncated lines are skipped; diagnostics are reported in ParseStats. func ParseTranscript(path string) (*Session, *ParseStats, error) { @@ -276,7 +319,11 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) { continue } - ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp) + if err != nil { + stats.Warnings = append(stats.Warnings, fmt.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err)) + continue + } if sess.StartTime.IsZero() && !ts.IsZero() { sess.StartTime = ts @@ -288,12 +335,14 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) { switch entry.Type { case "assistant": var msg rawMessage - if json.Unmarshal(entry.Message, &msg) != nil { + if err := json.Unmarshal(entry.Message, &msg); err != nil { + stats.Warnings = append(stats.Warnings, fmt.Sprintf("line %d: failed to unmarshal assistant message: %v", lineNum, err)) continue } - for _, raw := range msg.Content { + for i, raw := range msg.Content { var block contentBlock - if json.Unmarshal(raw, &block) != nil { + if err := json.Unmarshal(raw, &block); err != nil { + stats.Warnings = append(stats.Warnings, fmt.Sprintf("line %d block %d: failed to unmarshal content: %v", lineNum, i, err)) continue } @@ -319,12 +368,14 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) { case "user": var msg rawMessage - if json.Unmarshal(entry.Message, &msg) != nil { + if err := json.Unmarshal(entry.Message, &msg); err != nil { + stats.Warnings = append(stats.Warnings, fmt.Sprintf("line %d: failed to unmarshal user message: %v", lineNum, err)) continue } - for _, raw := range msg.Content { + for i, raw := range msg.Content { var block contentBlock - if json.Unmarshal(raw, &block) != nil { + if err := json.Unmarshal(raw, &block); err != nil { + stats.Warnings = append(stats.Warnings, fmt.Sprintf("line %d block %d: failed to unmarshal content: %v", lineNum, i, err)) continue } diff --git a/search.go b/search.go index 629b4e8..403428a 100644 --- a/search.go +++ b/search.go @@ -37,7 +37,7 @@ func SearchSeq(projectsDir, query string) iter.Seq[SearchResult] { continue } - for _, evt := range sess.Events { + for evt := range sess.EventsSeq() { if evt.Type != "tool_use" { continue }