commit 474b0861e1c92f769f3b1d39cc5d7a58f9957566 Author: Snider Date: Thu Feb 19 16:09:12 2026 +0000 feat: extract go-session from core/go pkg/session Session parsing, timeline generation, HTML/video rendering. Zero external dependencies (stdlib only). Module: forge.lthn.ai/core/go-session Co-Authored-By: Virgil diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0ebd5d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md + +## What This Is + +Session parsing, timeline generation, and HTML/video rendering. Module: `forge.lthn.ai/core/go-session` + +## Commands + +```bash +go test ./... # Run all tests +go test -v -run Name # Run single test +``` + +## Coding Standards + +- UK English +- `go test ./...` must pass before commit +- Conventional commits: `type(scope): description` +- Co-Author: `Co-Authored-By: Virgil ` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61c9768 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module forge.lthn.ai/core/go-session + +go 1.25.5 diff --git a/html.go b/html.go new file mode 100644 index 0000000..e666ef0 --- /dev/null +++ b/html.go @@ -0,0 +1,257 @@ +package session + +import ( + "fmt" + "html" + "os" + "strings" + "time" +) + +// RenderHTML generates a self-contained HTML timeline from a session. +func RenderHTML(sess *Session, outputPath string) error { + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create html: %w", err) + } + defer f.Close() + + duration := sess.EndTime.Sub(sess.StartTime) + toolCount := 0 + errorCount := 0 + for _, e := range sess.Events { + if e.Type == "tool_use" { + toolCount++ + if !e.Success { + errorCount++ + } + } + } + + fmt.Fprintf(f, ` + + + + +Session %s + + + +
+

Session %s

+
+
+ %s + Duration: %s + %d tool calls`, + shortID(sess.ID), shortID(sess.ID), + sess.StartTime.Format("2006-01-02 15:04:05"), + formatDuration(duration), + toolCount) + + if errorCount > 0 { + fmt.Fprintf(f, ` + %d errors`, errorCount) + } + + fmt.Fprintf(f, ` +
+
+ +
+
+`) + + for i, evt := range sess.Events { + toolClass := strings.ToLower(evt.Tool) + if evt.Type == "user" { + toolClass = "user" + } else if evt.Type == "assistant" { + toolClass = "assistant" + } + + errorClass := "" + if !evt.Success && evt.Type == "tool_use" { + errorClass = " error" + } + + statusIcon := "" + if evt.Type == "tool_use" { + if evt.Success { + statusIcon = `` + } else { + statusIcon = `` + } + } + + toolLabel := evt.Tool + if evt.Type == "user" { + toolLabel = "User" + } else if evt.Type == "assistant" { + toolLabel = "Claude" + } + + durStr := "" + if evt.Duration > 0 { + durStr = formatDuration(evt.Duration) + } + + fmt.Fprintf(f, `
+
+ + %s + %s + %s + %s + %s +
+
+`, + errorClass, + evt.Type, + evt.Tool, + html.EscapeString(strings.ToLower(evt.Input+" "+evt.Output)), + i, + i, + evt.Timestamp.Format("15:04:05"), + toolClass, + html.EscapeString(toolLabel), + html.EscapeString(truncate(evt.Input, 120)), + durStr, + statusIcon) + + if evt.Input != "" { + label := "Command" + if evt.Type == "user" { + label = "Message" + } else if evt.Type == "assistant" { + label = "Response" + } else if evt.Tool == "Read" || evt.Tool == "Glob" || evt.Tool == "Grep" { + label = "Target" + } else if evt.Tool == "Edit" || evt.Tool == "Write" { + label = "File" + } + fmt.Fprintf(f, `
%s
%s
+`, label, html.EscapeString(evt.Input)) + } + + if evt.Output != "" { + outClass := "output" + if !evt.Success { + outClass = "output err" + } + fmt.Fprintf(f, `
Output
%s
+`, outClass, html.EscapeString(evt.Output)) + } + + fmt.Fprint(f, `
+
+`) + } + + fmt.Fprint(f, `
+ + + +`) + + return nil +} + +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..6304189 --- /dev/null +++ b/parser.go @@ -0,0 +1,383 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Event represents a single action in a session timeline. +type Event struct { + Timestamp time.Time + Type string // "tool_use", "user", "assistant", "error" + Tool string // "Bash", "Read", "Edit", "Write", "Grep", "Glob", etc. + ToolID string + Input string // Command, file path, or message text + Output string // Result text + Duration time.Duration + Success bool + ErrorMsg string +} + +// Session holds parsed session metadata and events. +type Session struct { + ID string + Path string + StartTime time.Time + EndTime time.Time + Events []Event +} + +// rawEntry is the top-level structure of a Claude Code JSONL line. +type rawEntry struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId"` + Message json.RawMessage `json:"message"` + UserType string `json:"userType"` +} + +type rawMessage struct { + Role string `json:"role"` + Content []json.RawMessage `json:"content"` +} + +type contentBlock struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content interface{} `json:"content,omitempty"` + IsError *bool `json:"is_error,omitempty"` +} + +type bashInput struct { + Command string `json:"command"` + Description string `json:"description"` + Timeout int `json:"timeout"` +} + +type readInput struct { + FilePath string `json:"file_path"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +type editInput struct { + FilePath string `json:"file_path"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +type writeInput struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +type grepInput struct { + Pattern string `json:"pattern"` + Path string `json:"path"` +} + +type globInput struct { + Pattern string `json:"pattern"` + Path string `json:"path"` +} + +type taskInput struct { + Prompt string `json:"prompt"` + Description string `json:"description"` + SubagentType string `json:"subagent_type"` +} + +// 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) + } + + var sessions []Session + for _, path := range matches { + base := filepath.Base(path) + id := strings.TrimSuffix(base, ".jsonl") + + info, err := os.Stat(path) + if err != nil { + continue + } + + s := Session{ + ID: id, + Path: path, + } + + // 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 { + continue + } + if entry.Timestamp == "" { + continue + } + if firstTS == "" { + firstTS = entry.Timestamp + } + lastTS = entry.Timestamp + } + 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() + } + + sessions = append(sessions, s) + } + + 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. +func ParseTranscript(path string) (*Session, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open transcript: %w", err) + } + defer f.Close() + + base := filepath.Base(path) + sess := &Session{ + ID: strings.TrimSuffix(base, ".jsonl"), + Path: path, + } + + // Collect tool_use entries keyed by ID + type toolUse struct { + timestamp time.Time + tool string + input string + } + pendingTools := make(map[string]toolUse) + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024) + + for scanner.Scan() { + var entry rawEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + + if sess.StartTime.IsZero() && !ts.IsZero() { + sess.StartTime = ts + } + if !ts.IsZero() { + sess.EndTime = ts + } + + switch entry.Type { + case "assistant": + var msg rawMessage + if json.Unmarshal(entry.Message, &msg) != nil { + continue + } + for _, raw := range msg.Content { + var block contentBlock + if json.Unmarshal(raw, &block) != nil { + continue + } + + switch block.Type { + case "text": + if text := strings.TrimSpace(block.Text); text != "" { + sess.Events = append(sess.Events, Event{ + Timestamp: ts, + Type: "assistant", + Input: truncate(text, 500), + }) + } + + case "tool_use": + inputStr := extractToolInput(block.Name, block.Input) + pendingTools[block.ID] = toolUse{ + timestamp: ts, + tool: block.Name, + input: inputStr, + } + } + } + + case "user": + var msg rawMessage + if json.Unmarshal(entry.Message, &msg) != nil { + continue + } + for _, raw := range msg.Content { + var block contentBlock + if json.Unmarshal(raw, &block) != nil { + continue + } + + switch block.Type { + case "tool_result": + if tu, ok := pendingTools[block.ToolUseID]; ok { + output := extractResultContent(block.Content) + isError := block.IsError != nil && *block.IsError + evt := Event{ + Timestamp: tu.timestamp, + Type: "tool_use", + Tool: tu.tool, + ToolID: block.ToolUseID, + Input: tu.input, + Output: truncate(output, 2000), + Duration: ts.Sub(tu.timestamp), + Success: !isError, + } + if isError { + evt.ErrorMsg = truncate(output, 500) + } + sess.Events = append(sess.Events, evt) + delete(pendingTools, block.ToolUseID) + } + + case "text": + if text := strings.TrimSpace(block.Text); text != "" { + sess.Events = append(sess.Events, Event{ + Timestamp: ts, + Type: "user", + Input: truncate(text, 500), + }) + } + } + } + } + } + + return sess, scanner.Err() +} + +func extractToolInput(toolName string, raw json.RawMessage) string { + if raw == nil { + return "" + } + + switch toolName { + case "Bash": + var inp bashInput + if json.Unmarshal(raw, &inp) == nil { + desc := inp.Description + if desc != "" { + desc = " # " + desc + } + return inp.Command + desc + } + case "Read": + var inp readInput + if json.Unmarshal(raw, &inp) == nil { + return inp.FilePath + } + case "Edit": + var inp editInput + if json.Unmarshal(raw, &inp) == nil { + return fmt.Sprintf("%s (edit)", inp.FilePath) + } + case "Write": + var inp writeInput + if json.Unmarshal(raw, &inp) == nil { + return fmt.Sprintf("%s (%d bytes)", inp.FilePath, len(inp.Content)) + } + case "Grep": + var inp grepInput + if json.Unmarshal(raw, &inp) == nil { + path := inp.Path + if path == "" { + path = "." + } + return fmt.Sprintf("/%s/ in %s", inp.Pattern, path) + } + case "Glob": + var inp globInput + if json.Unmarshal(raw, &inp) == nil { + return inp.Pattern + } + case "Task": + var inp taskInput + if json.Unmarshal(raw, &inp) == nil { + desc := inp.Description + if desc == "" { + desc = truncate(inp.Prompt, 80) + } + return fmt.Sprintf("[%s] %s", inp.SubagentType, desc) + } + } + + // Fallback: show raw JSON keys + var m map[string]interface{} + if json.Unmarshal(raw, &m) == nil { + var parts []string + for k := range m { + parts = append(parts, k) + } + sort.Strings(parts) + return strings.Join(parts, ", ") + } + + return "" +} + +func extractResultContent(content interface{}) string { + switch v := content.(type) { + case string: + return v + case []interface{}: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") + case map[string]interface{}: + if text, ok := v["text"].(string); ok { + return text + } + } + return fmt.Sprintf("%v", content) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/search.go b/search.go new file mode 100644 index 0000000..71d4cb2 --- /dev/null +++ b/search.go @@ -0,0 +1,54 @@ +package session + +import ( + "path/filepath" + "strings" + "time" +) + +// SearchResult represents a match found in a session transcript. +type SearchResult struct { + SessionID string + Timestamp time.Time + Tool string + Match string +} + +// 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 + } + + var results []SearchResult + query = strings.ToLower(query) + + for _, path := range matches { + sess, err := ParseTranscript(path) + if err != nil { + continue + } + + 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) + } + results = append(results, SearchResult{ + SessionID: sess.ID, + Timestamp: evt.Timestamp, + Tool: evt.Tool, + Match: matchCtx, + }) + } + } + } + + return results, nil +} diff --git a/video.go b/video.go new file mode 100644 index 0000000..2258fe1 --- /dev/null +++ b/video.go @@ -0,0 +1,127 @@ +package session + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// RenderMP4 generates an MP4 video from session events using VHS (charmbracelet). +func RenderMP4(sess *Session, outputPath string) error { + if _, err := exec.LookPath("vhs"); err != nil { + return fmt.Errorf("vhs not installed (go install github.com/charmbracelet/vhs@latest)") + } + + tape := generateTape(sess, outputPath) + + tmpFile, err := os.CreateTemp("", "session-*.tape") + if err != nil { + return fmt.Errorf("create tape: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(tape); err != nil { + tmpFile.Close() + return fmt.Errorf("write tape: %w", err) + } + tmpFile.Close() + + cmd := exec.Command("vhs", tmpFile.Name()) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("vhs render: %w", err) + } + + return nil +} + +func generateTape(sess *Session, outputPath string) string { + var b strings.Builder + + b.WriteString(fmt.Sprintf("Output %s\n", outputPath)) + b.WriteString("Set FontSize 16\n") + b.WriteString("Set Width 1400\n") + b.WriteString("Set Height 800\n") + b.WriteString("Set TypingSpeed 30ms\n") + b.WriteString("Set Theme \"Catppuccin Mocha\"\n") + b.WriteString("Set Shell bash\n") + b.WriteString("\n") + + // Title frame + id := sess.ID + if len(id) > 8 { + id = id[:8] + } + b.WriteString(fmt.Sprintf("Type \"# Session %s | %s\"\n", + id, sess.StartTime.Format("2006-01-02 15:04"))) + b.WriteString("Enter\n") + b.WriteString("Sleep 2s\n") + b.WriteString("\n") + + for _, evt := range sess.Events { + if evt.Type != "tool_use" { + continue + } + + switch evt.Tool { + case "Bash": + cmd := extractCommand(evt.Input) + if cmd == "" { + continue + } + // Show the command + b.WriteString(fmt.Sprintf("Type %q\n", "$ "+cmd)) + b.WriteString("Enter\n") + + // Show abbreviated output + output := evt.Output + if len(output) > 200 { + output = output[:200] + "..." + } + if output != "" { + for _, line := range strings.Split(output, "\n") { + if line == "" { + continue + } + b.WriteString(fmt.Sprintf("Type %q\n", line)) + b.WriteString("Enter\n") + } + } + + // Status indicator + if !evt.Success { + b.WriteString("Type \"# ✗ FAILED\"\n") + } else { + b.WriteString("Type \"# ✓ OK\"\n") + } + b.WriteString("Enter\n") + b.WriteString("Sleep 1s\n") + b.WriteString("\n") + + case "Read", "Edit", "Write": + b.WriteString(fmt.Sprintf("Type %q\n", + fmt.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80)))) + b.WriteString("Enter\n") + b.WriteString("Sleep 500ms\n") + + case "Task": + b.WriteString(fmt.Sprintf("Type %q\n", + fmt.Sprintf("# Agent: %s", truncate(evt.Input, 80)))) + b.WriteString("Enter\n") + b.WriteString("Sleep 1s\n") + } + } + + b.WriteString("Sleep 3s\n") + return b.String() +} + +func extractCommand(input string) string { + // Remove description suffix (after " # ") + if idx := strings.Index(input, " # "); idx > 0 { + return input[:idx] + } + return input +}