fix: improve HTML escaping and modernise sort/search helpers
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 37s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 08:30:01 +00:00
parent 724d122fbe
commit ad28c85c89
4 changed files with 73 additions and 20 deletions

View file

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

View file

@ -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
<div class="timeline" id="timeline">
`)
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, ` </div>
</div>
`)
i++
}
fmt.Fprint(f, `</div>

View file

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

View file

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