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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-19 16:09:12 +00:00
commit 474b0861e1
6 changed files with 843 additions and 0 deletions

19
CLAUDE.md Normal file
View file

@ -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 <virgil@lethean.io>`

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module forge.lthn.ai/core/go-session
go 1.25.5

257
html.go Normal file
View file

@ -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, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Session %s</title>
<style>
:root {
--bg: #0d1117; --bg2: #161b22; --bg3: #21262d;
--fg: #c9d1d9; --dim: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d29922;
--border: #30363d; --font: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--fg); font-family: var(--font); font-size: 13px; line-height: 1.5; }
.header { background: var(--bg2); border-bottom: 1px solid var(--border); padding: 16px 24px; position: sticky; top: 0; z-index: 10; }
.header h1 { font-size: 16px; font-weight: 600; color: var(--accent); }
.header .meta { color: var(--dim); font-size: 12px; margin-top: 4px; }
.header .stats span { display: inline-block; margin-right: 16px; }
.header .stats .err { color: var(--red); }
.search { margin-top: 8px; display: flex; gap: 8px; }
.search input { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-family: var(--font); font-size: 12px; padding: 6px 12px; width: 300px; outline: none; }
.search input:focus { border-color: var(--accent); }
.search select { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-family: var(--font); font-size: 12px; padding: 6px 8px; outline: none; }
.timeline { padding: 16px 24px; }
.event { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.15s; }
.event:hover { border-color: var(--accent); }
.event.error { border-color: var(--red); }
.event.hidden { display: none; }
.event-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; user-select: none; background: var(--bg2); }
.event-header:hover { background: var(--bg3); }
.event-header .time { color: var(--dim); font-size: 11px; min-width: 70px; }
.event-header .tool { font-weight: 600; color: var(--accent); min-width: 60px; }
.event-header .tool.bash { color: var(--green); }
.event-header .tool.error { color: var(--red); }
.event-header .tool.user { color: var(--yellow); }
.event-header .tool.assistant { color: var(--dim); }
.event-header .input { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.event-header .dur { color: var(--dim); font-size: 11px; min-width: 50px; text-align: right; }
.event-header .status { font-size: 14px; min-width: 20px; text-align: center; }
.event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; }
.event.open .arrow { transform: rotate(90deg); }
.event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); }
.event.open .event-body { display: block; }
.event-body pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 400px; overflow-y: auto; }
.event-body .label { color: var(--dim); font-size: 11px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.event-body .section { margin-bottom: 12px; }
.event-body .output { color: var(--fg); }
.event-body .output.err { color: var(--red); }
</style>
</head>
<body>
<div class="header">
<h1>Session %s</h1>
<div class="meta">
<div class="stats">
<span>%s</span>
<span>Duration: %s</span>
<span>%d tool calls</span>`,
shortID(sess.ID), shortID(sess.ID),
sess.StartTime.Format("2006-01-02 15:04:05"),
formatDuration(duration),
toolCount)
if errorCount > 0 {
fmt.Fprintf(f, `
<span class="err">%d errors</span>`, errorCount)
}
fmt.Fprintf(f, `
</div>
</div>
<div class="search">
<input type="text" id="search" placeholder="Search commands, outputs..." oninput="filterEvents()">
<select id="filter" onchange="filterEvents()">
<option value="all">All events</option>
<option value="tool_use">Tool calls only</option>
<option value="errors">Errors only</option>
<option value="Bash">Bash only</option>
<option value="user">User messages</option>
</select>
</div>
</div>
<div class="timeline" id="timeline">
`)
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 = `<span style="color:var(--green)">&#10003;</span>`
} else {
statusIcon = `<span style="color:var(--red)">&#10007;</span>`
}
}
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, `<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
<div class="event-header" onclick="toggle(%d)">
<span class="arrow">&#9654;</span>
<span class="time">%s</span>
<span class="tool %s">%s</span>
<span class="input">%s</span>
<span class="dur">%s</span>
<span class="status">%s</span>
</div>
<div class="event-body">
`,
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, ` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
`, label, html.EscapeString(evt.Input))
}
if evt.Output != "" {
outClass := "output"
if !evt.Success {
outClass = "output err"
}
fmt.Fprintf(f, ` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
`, outClass, html.EscapeString(evt.Output))
}
fmt.Fprint(f, ` </div>
</div>
`)
}
fmt.Fprint(f, `</div>
<script>
function toggle(i) {
document.getElementById('evt-'+i).classList.toggle('open');
}
function filterEvents() {
const q = document.getElementById('search').value.toLowerCase();
const f = document.getElementById('filter').value;
document.querySelectorAll('.event').forEach(el => {
const type = el.dataset.type;
const tool = el.dataset.tool;
const text = el.dataset.text;
let show = true;
if (f === 'tool_use' && type !== 'tool_use') show = false;
if (f === 'errors' && !el.classList.contains('error')) show = false;
if (f === 'Bash' && tool !== 'Bash') show = false;
if (f === 'user' && type !== 'user') show = false;
if (q && !text.includes(q)) show = false;
el.classList.toggle('hidden', !show);
});
}
document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
document.getElementById('search').focus();
}
});
</script>
</body>
</html>
`)
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)
}

383
parser.go Normal file
View file

@ -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] + "..."
}

54
search.go Normal file
View file

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

127
video.go Normal file
View file

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