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:
commit
474b0861e1
6 changed files with 843 additions and 0 deletions
19
CLAUDE.md
Normal file
19
CLAUDE.md
Normal 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
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module forge.lthn.ai/core/go-session
|
||||
|
||||
go 1.25.5
|
||||
257
html.go
Normal file
257
html.go
Normal 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)">✓</span>`
|
||||
} else {
|
||||
statusIcon = `<span style="color:var(--red)">✗</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">▶</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
383
parser.go
Normal 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
54
search.go
Normal 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
127
video.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue