wip: trajectory command

Kujtim Hoxha created

Change summary

internal/cmd/root.go                   |   1 
internal/cmd/trajectory.go             | 139 +++++++
internal/trajectory/atif.go            | 199 ++++++++++
internal/trajectory/atif_test.go       | 249 +++++++++++++
internal/trajectory/html.go            |  36 +
internal/trajectory/html_template.html | 508 ++++++++++++++++++++++++++++
6 files changed, 1,132 insertions(+)

Detailed changes

internal/cmd/trajectory.go ๐Ÿ”—

@@ -0,0 +1,139 @@
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/trajectory"
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/spf13/cobra"
+)
+
+var trajectoryCmd = &cobra.Command{
+	Use:   "trajectory",
+	Short: "Trajectory export utilities",
+	Long:  "Export session trajectories in Harbor ATIF format for analysis and sharing",
+}
+
+var trajectoryExportCmd = &cobra.Command{
+	Use:   "export",
+	Short: "Export a session as ATIF trajectory",
+	Long:  "Export a Crush session in Harbor ATIF (Agent Trajectory Interchange Format) v1.4",
+	Example: `
+# Export a session as JSON to stdout
+crush trajectory export --session <session-id>
+
+# Export a session to a JSON file
+crush trajectory export --session <session-id> --output trajectory.json
+
+# Export as HTML for visualization
+crush trajectory export --session <session-id> --format html --output trajectory.html
+
+# Validate with Harbor validator
+crush trajectory export --session <session-id> > out.json
+python -m harbor.utils.trajectory_validator out.json
+  `,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		sessionID, _ := cmd.Flags().GetString("session")
+		outputFile, _ := cmd.Flags().GetString("output")
+		format, _ := cmd.Flags().GetString("format")
+		dataDir, _ := cmd.Flags().GetString("data-dir")
+
+		if sessionID == "" {
+			return fmt.Errorf("--session flag is required")
+		}
+
+		ctx := cmd.Context()
+
+		cwd, err := ResolveCwd(cmd)
+		if err != nil {
+			return err
+		}
+
+		// Load config (lightweight, no full app init).
+		cfg, err := config.Load(cwd, dataDir, false)
+		if err != nil {
+			return fmt.Errorf("failed to load config: %w", err)
+		}
+
+		// Connect to DB.
+		conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
+		if err != nil {
+			return fmt.Errorf("failed to connect to database: %w", err)
+		}
+		defer conn.Close()
+
+		querier := db.New(conn)
+		sessionSvc := session.NewService(querier)
+		messageSvc := message.NewService(querier)
+
+		// Load session.
+		sess, err := sessionSvc.Get(ctx, sessionID)
+		if err != nil {
+			return fmt.Errorf("failed to get session: %w", err)
+		}
+
+		// Load messages.
+		messages, err := messageSvc.List(ctx, sessionID)
+		if err != nil {
+			return fmt.Errorf("failed to list messages: %w", err)
+		}
+
+		// Determine model name from first assistant message.
+		var modelName string
+		for _, msg := range messages {
+			if msg.Role == message.Assistant && msg.Model != "" {
+				modelName = msg.Model
+				break
+			}
+		}
+
+		// Export to ATIF.
+		traj, err := trajectory.ExportSession(sess, messages, "Crush", version.Version, modelName)
+		if err != nil {
+			return fmt.Errorf("failed to export trajectory: %w", err)
+		}
+
+		var data []byte
+		switch format {
+		case "html":
+			data, err = trajectory.RenderHTML(traj)
+			if err != nil {
+				return fmt.Errorf("failed to render HTML: %w", err)
+			}
+		case "json":
+			data, err = json.MarshalIndent(traj, "", "  ")
+			if err != nil {
+				return fmt.Errorf("failed to marshal trajectory: %w", err)
+			}
+		default:
+			return fmt.Errorf("unknown format: %s (use 'json' or 'html')", format)
+		}
+
+		// Write output.
+		if outputFile != "" {
+			if err := os.WriteFile(outputFile, data, 0o644); err != nil {
+				return fmt.Errorf("failed to write output file: %w", err)
+			}
+			cmd.Printf("Exported trajectory to %s\n", outputFile)
+		} else {
+			cmd.Println(string(data))
+		}
+
+		return nil
+	},
+}
+
+func init() {
+	trajectoryExportCmd.Flags().StringP("session", "s", "", "Session ID to export (required)")
+	trajectoryExportCmd.Flags().StringP("output", "o", "", "Output file path (defaults to stdout)")
+	trajectoryExportCmd.Flags().StringP("format", "f", "json", "Output format: json or html")
+	_ = trajectoryExportCmd.MarkFlagRequired("session")
+
+	trajectoryCmd.AddCommand(trajectoryExportCmd)
+}

internal/trajectory/atif.go ๐Ÿ”—

@@ -0,0 +1,199 @@
+// Package trajectory provides export functionality for the Harbor ATIF
+// (Agent Trajectory Interchange Format) v1.4 specification.
+package trajectory
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// Trajectory represents the root ATIF document structure.
+type Trajectory struct {
+	SchemaVersion string        `json:"schema_version"`
+	SessionID     string        `json:"session_id"`
+	Agent         Agent         `json:"agent"`
+	Steps         []Step        `json:"steps"`
+	FinalMetrics  *FinalMetrics `json:"final_metrics,omitempty"`
+	Extra         any           `json:"extra,omitempty"`
+}
+
+// Agent describes the agent that generated the trajectory.
+type Agent struct {
+	Name      string `json:"name"`
+	Version   string `json:"version,omitempty"`
+	ModelName string `json:"model_name,omitempty"`
+}
+
+// Step represents a single step in the trajectory.
+type Step struct {
+	StepID           int          `json:"step_id"`
+	Timestamp        string       `json:"timestamp"`
+	Source           string       `json:"source"` // "user", "agent", or "system"
+	Message          string       `json:"message"`
+	ReasoningContent string       `json:"reasoning_content,omitempty"`
+	ToolCalls        []ToolCall   `json:"tool_calls,omitempty"`
+	Observation      *Observation `json:"observation,omitempty"`
+	Metrics          *StepMetrics `json:"metrics,omitempty"`
+}
+
+// ToolCall represents a tool invocation by the agent.
+type ToolCall struct {
+	ToolCallID   string `json:"tool_call_id"`
+	FunctionName string `json:"function_name"`
+	Arguments    any    `json:"arguments"`
+}
+
+// Observation contains the results of tool executions.
+type Observation struct {
+	Results []ObservationResult `json:"results"`
+}
+
+// ObservationResult is a single tool result linked to its call.
+type ObservationResult struct {
+	SourceCallID string `json:"source_call_id,omitempty"`
+	Content      string `json:"content,omitempty"`
+}
+
+// StepMetrics contains token usage for a single step.
+type StepMetrics struct {
+	PromptTokens     int64   `json:"prompt_tokens,omitempty"`
+	CompletionTokens int64   `json:"completion_tokens,omitempty"`
+	CachedTokens     int64   `json:"cached_tokens,omitempty"`
+	CostUSD          float64 `json:"cost_usd,omitempty"`
+}
+
+// FinalMetrics contains aggregate metrics for the entire session.
+type FinalMetrics struct {
+	TotalPromptTokens     int64   `json:"total_prompt_tokens,omitempty"`
+	TotalCompletionTokens int64   `json:"total_completion_tokens,omitempty"`
+	TotalCostUSD          float64 `json:"total_cost_usd,omitempty"`
+	TotalSteps            int     `json:"total_steps,omitempty"`
+}
+
+// ExportSession converts a Crush session and its messages to ATIF format.
+func ExportSession(
+	sess session.Session,
+	messages []message.Message,
+	agentName string,
+	agentVersion string,
+	modelName string,
+) (*Trajectory, error) {
+	traj := &Trajectory{
+		SchemaVersion: "ATIF-v1.4",
+		SessionID:     sess.ID,
+		Agent: Agent{
+			Name:      agentName,
+			Version:   agentVersion,
+			ModelName: modelName,
+		},
+		Steps: make([]Step, 0, len(messages)),
+	}
+
+	stepID := 1
+	var lastAgentStep *Step
+
+	for _, msg := range messages {
+		switch msg.Role {
+		case message.User:
+			step := convertUserMessage(msg, stepID)
+			traj.Steps = append(traj.Steps, step)
+			stepID++
+			lastAgentStep = nil
+
+		case message.Assistant:
+			step := convertAgentMessage(msg, stepID)
+			traj.Steps = append(traj.Steps, step)
+			lastAgentStep = &traj.Steps[len(traj.Steps)-1]
+			stepID++
+
+		case message.Tool:
+			// Attach tool results to the last agent step as observations.
+			if lastAgentStep != nil {
+				attachToolResults(lastAgentStep, msg)
+			}
+			// Don't create a separate step for tool results.
+		}
+	}
+
+	// Add final metrics from session totals.
+	if sess.PromptTokens > 0 || sess.CompletionTokens > 0 || sess.Cost > 0 {
+		traj.FinalMetrics = &FinalMetrics{
+			TotalPromptTokens:     sess.PromptTokens,
+			TotalCompletionTokens: sess.CompletionTokens,
+			TotalCostUSD:          sess.Cost,
+			TotalSteps:            len(traj.Steps),
+		}
+	}
+
+	return traj, nil
+}
+
+// convertUserMessage transforms a user message into an ATIF step.
+func convertUserMessage(msg message.Message, stepID int) Step {
+	return Step{
+		StepID:    stepID,
+		Timestamp: time.Unix(msg.CreatedAt, 0).UTC().Format(time.RFC3339),
+		Source:    "user",
+		Message:   msg.Content().Text,
+	}
+}
+
+// convertAgentMessage transforms an assistant message into an ATIF step.
+func convertAgentMessage(msg message.Message, stepID int) Step {
+	step := Step{
+		StepID:    stepID,
+		Timestamp: time.Unix(msg.CreatedAt, 0).UTC().Format(time.RFC3339),
+		Source:    "agent",
+		Message:   msg.Content().Text,
+	}
+
+	// Include reasoning content if present.
+	if reasoning := msg.ReasoningContent(); reasoning.Thinking != "" {
+		step.ReasoningContent = reasoning.Thinking
+	}
+
+	// Convert tool calls.
+	toolCalls := msg.ToolCalls()
+	if len(toolCalls) > 0 {
+		step.ToolCalls = make([]ToolCall, 0, len(toolCalls))
+		for _, tc := range toolCalls {
+			atifCall := ToolCall{
+				ToolCallID:   tc.ID,
+				FunctionName: tc.Name,
+			}
+			// Try to parse arguments as JSON, fall back to string.
+			var args any
+			if err := json.Unmarshal([]byte(tc.Input), &args); err != nil {
+				args = tc.Input
+			}
+			atifCall.Arguments = args
+			step.ToolCalls = append(step.ToolCalls, atifCall)
+		}
+	}
+
+	return step
+}
+
+// attachToolResults attaches tool results from a tool message to an agent step.
+func attachToolResults(step *Step, msg message.Message) {
+	results := msg.ToolResults()
+	if len(results) == 0 {
+		return
+	}
+
+	if step.Observation == nil {
+		step.Observation = &Observation{
+			Results: make([]ObservationResult, 0, len(results)),
+		}
+	}
+
+	for _, tr := range results {
+		step.Observation.Results = append(step.Observation.Results, ObservationResult{
+			SourceCallID: tr.ToolCallID,
+			Content:      tr.Content,
+		})
+	}
+}

internal/trajectory/atif_test.go ๐Ÿ”—

@@ -0,0 +1,249 @@
+package trajectory
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/stretchr/testify/require"
+)
+
+func TestExportSession(t *testing.T) {
+	t.Parallel()
+
+	now := time.Now().Unix()
+
+	sess := session.Session{
+		ID:               "test-session-123",
+		Title:            "Test Session",
+		PromptTokens:     1000,
+		CompletionTokens: 500,
+		Cost:             0.05,
+	}
+
+	messages := []message.Message{
+		{
+			ID:        "msg-1",
+			SessionID: sess.ID,
+			Role:      message.User,
+			Parts:     []message.ContentPart{message.TextContent{Text: "Hello, can you help me?"}},
+			CreatedAt: now,
+		},
+		{
+			ID:        "msg-2",
+			SessionID: sess.ID,
+			Role:      message.Assistant,
+			Parts: []message.ContentPart{
+				message.ReasoningContent{Thinking: "User is asking for help. I should respond helpfully."},
+				message.TextContent{Text: "Of course! How can I assist you today?"},
+			},
+			Model:     "claude-sonnet-4-20250514",
+			CreatedAt: now + 1,
+		},
+		{
+			ID:        "msg-3",
+			SessionID: sess.ID,
+			Role:      message.User,
+			Parts:     []message.ContentPart{message.TextContent{Text: "List files in the current directory"}},
+			CreatedAt: now + 2,
+		},
+		{
+			ID:        "msg-4",
+			SessionID: sess.ID,
+			Role:      message.Assistant,
+			Parts: []message.ContentPart{
+				message.TextContent{Text: "I'll list the files for you."},
+				message.ToolCall{
+					ID:    "call-123",
+					Name:  "ls",
+					Input: `{"path": "."}`,
+				},
+			},
+			Model:     "claude-sonnet-4-20250514",
+			CreatedAt: now + 3,
+		},
+		{
+			ID:        "msg-5",
+			SessionID: sess.ID,
+			Role:      message.Tool,
+			Parts: []message.ContentPart{
+				message.ToolResult{
+					ToolCallID: "call-123",
+					Name:       "ls",
+					Content:    "file1.go\nfile2.go\nREADME.md",
+				},
+			},
+			CreatedAt: now + 4,
+		},
+		{
+			ID:        "msg-6",
+			SessionID: sess.ID,
+			Role:      message.Assistant,
+			Parts: []message.ContentPart{
+				message.TextContent{Text: "Here are the files: file1.go, file2.go, README.md"},
+			},
+			Model:     "claude-sonnet-4-20250514",
+			CreatedAt: now + 5,
+		},
+	}
+
+	traj, err := ExportSession(sess, messages, "Crush", "1.0.0", "claude-sonnet-4-20250514")
+	require.NoError(t, err)
+
+	// Verify root structure.
+	require.Equal(t, "ATIF-v1.4", traj.SchemaVersion)
+	require.Equal(t, "test-session-123", traj.SessionID)
+	require.Equal(t, "Crush", traj.Agent.Name)
+	require.Equal(t, "1.0.0", traj.Agent.Version)
+	require.Equal(t, "claude-sonnet-4-20250514", traj.Agent.ModelName)
+
+	// Verify steps (tool results are attached to agent steps, not separate).
+	require.Len(t, traj.Steps, 5)
+
+	// Step 1: User message.
+	require.Equal(t, 1, traj.Steps[0].StepID)
+	require.Equal(t, "user", traj.Steps[0].Source)
+	require.Equal(t, "Hello, can you help me?", traj.Steps[0].Message)
+	require.Empty(t, traj.Steps[0].ToolCalls)
+	require.Nil(t, traj.Steps[0].Observation)
+
+	// Step 2: Assistant with reasoning.
+	require.Equal(t, 2, traj.Steps[1].StepID)
+	require.Equal(t, "agent", traj.Steps[1].Source)
+	require.Equal(t, "Of course! How can I assist you today?", traj.Steps[1].Message)
+	require.Equal(t, "User is asking for help. I should respond helpfully.", traj.Steps[1].ReasoningContent)
+
+	// Step 3: User message.
+	require.Equal(t, 3, traj.Steps[2].StepID)
+	require.Equal(t, "user", traj.Steps[2].Source)
+
+	// Step 4: Assistant with tool call AND observation (tool result attached).
+	require.Equal(t, 4, traj.Steps[3].StepID)
+	require.Equal(t, "agent", traj.Steps[3].Source)
+	require.Len(t, traj.Steps[3].ToolCalls, 1)
+	require.Equal(t, "call-123", traj.Steps[3].ToolCalls[0].ToolCallID)
+	require.Equal(t, "ls", traj.Steps[3].ToolCalls[0].FunctionName)
+	require.NotNil(t, traj.Steps[3].ToolCalls[0].Arguments)
+	// Observation attached to the same agent step.
+	require.NotNil(t, traj.Steps[3].Observation)
+	require.Len(t, traj.Steps[3].Observation.Results, 1)
+	require.Equal(t, "call-123", traj.Steps[3].Observation.Results[0].SourceCallID)
+	require.Contains(t, traj.Steps[3].Observation.Results[0].Content, "file1.go")
+
+	// Step 5: Final assistant response.
+	require.Equal(t, 5, traj.Steps[4].StepID)
+	require.Equal(t, "agent", traj.Steps[4].Source)
+	require.Equal(t, "Here are the files: file1.go, file2.go, README.md", traj.Steps[4].Message)
+
+	// Verify final metrics.
+	require.NotNil(t, traj.FinalMetrics)
+	require.Equal(t, int64(1000), traj.FinalMetrics.TotalPromptTokens)
+	require.Equal(t, int64(500), traj.FinalMetrics.TotalCompletionTokens)
+	require.Equal(t, 5, traj.FinalMetrics.TotalSteps)
+	require.InDelta(t, 0.05, traj.FinalMetrics.TotalCostUSD, 0.001)
+
+	// Verify timestamps are ISO 8601.
+	for _, step := range traj.Steps {
+		_, err := time.Parse(time.RFC3339, step.Timestamp)
+		require.NoError(t, err, "step %d has invalid timestamp: %s", step.StepID, step.Timestamp)
+	}
+
+	// Verify JSON marshaling works.
+	data, err := json.MarshalIndent(traj, "", "  ")
+	require.NoError(t, err)
+	require.Contains(t, string(data), `"schema_version": "ATIF-v1.4"`)
+}
+
+func TestExportSession_EmptyMessages(t *testing.T) {
+	t.Parallel()
+
+	sess := session.Session{
+		ID:    "empty-session",
+		Title: "Empty",
+	}
+
+	traj, err := ExportSession(sess, nil, "Crush", "1.0.0", "")
+	require.NoError(t, err)
+	require.Empty(t, traj.Steps)
+	require.Nil(t, traj.FinalMetrics)
+}
+
+func TestExportSession_ToolCallArgumentsParsing(t *testing.T) {
+	t.Parallel()
+
+	sess := session.Session{ID: "tool-args-session"}
+	now := time.Now().Unix()
+
+	messages := []message.Message{
+		{
+			ID:        "msg-1",
+			SessionID: sess.ID,
+			Role:      message.Assistant,
+			Parts: []message.ContentPart{
+				message.ToolCall{
+					ID:    "call-1",
+					Name:  "edit",
+					Input: `{"file_path": "/tmp/test.go", "old_string": "foo", "new_string": "bar"}`,
+				},
+			},
+			CreatedAt: now,
+		},
+	}
+
+	traj, err := ExportSession(sess, messages, "Crush", "1.0.0", "test-model")
+	require.NoError(t, err)
+	require.Len(t, traj.Steps, 1)
+	require.Len(t, traj.Steps[0].ToolCalls, 1)
+
+	// Arguments should be parsed as JSON object.
+	args, ok := traj.Steps[0].ToolCalls[0].Arguments.(map[string]any)
+	require.True(t, ok)
+	require.Equal(t, "/tmp/test.go", args["file_path"])
+}
+
+func TestExportSession_ToolError(t *testing.T) {
+	t.Parallel()
+
+	sess := session.Session{ID: "error-session"}
+	now := time.Now().Unix()
+
+	messages := []message.Message{
+		{
+			ID:        "msg-1",
+			SessionID: sess.ID,
+			Role:      message.Assistant,
+			Parts: []message.ContentPart{
+				message.ToolCall{
+					ID:    "call-1",
+					Name:  "bash",
+					Input: `{"command": "foobar"}`,
+				},
+			},
+			CreatedAt: now,
+		},
+		{
+			ID:        "msg-2",
+			SessionID: sess.ID,
+			Role:      message.Tool,
+			Parts: []message.ContentPart{
+				message.ToolResult{
+					ToolCallID: "call-1",
+					Name:       "bash",
+					Content:    "command not found: foobar",
+					IsError:    true,
+				},
+			},
+			CreatedAt: now + 1,
+		},
+	}
+
+	traj, err := ExportSession(sess, messages, "Crush", "1.0.0", "")
+	require.NoError(t, err)
+	require.Len(t, traj.Steps, 1)
+	require.Equal(t, "agent", traj.Steps[0].Source)
+	require.NotNil(t, traj.Steps[0].Observation)
+	require.Len(t, traj.Steps[0].Observation.Results, 1)
+	require.Equal(t, "command not found: foobar", traj.Steps[0].Observation.Results[0].Content)
+}

internal/trajectory/html.go ๐Ÿ”—

@@ -0,0 +1,36 @@
+package trajectory
+
+import (
+	"bytes"
+	_ "embed"
+	"encoding/json"
+	"html/template"
+)
+
+//go:embed html_template.html
+var htmlTemplate string
+
+// RenderHTML renders the trajectory as a standalone HTML document.
+func RenderHTML(traj *Trajectory) ([]byte, error) {
+	tmpl, err := template.New("trajectory").Parse(htmlTemplate)
+	if err != nil {
+		return nil, err
+	}
+
+	trajJSON, err := json.Marshal(traj)
+	if err != nil {
+		return nil, err
+	}
+
+	data := map[string]any{
+		"Title":          traj.Agent.Name + " - " + traj.SessionID,
+		"TrajectoryJSON": template.JS(trajJSON),
+	}
+
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, data); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}

internal/trajectory/html_template.html ๐Ÿ”—

@@ -0,0 +1,508 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{{.Title}}</title>
+    <style>
+        :root {
+            /* Charmtone palette */
+            --pepper: #201F26;
+            --bbq: #2d2c35;
+            --charcoal: #3A3943;
+            --iron: #4D4C57;
+            --oyster: #605F6B;
+            --squid: #858392;
+            --smoke: #BFBCC8;
+            --ash: #DFDBDD;
+            --salt: #F1EFEF;
+            --butter: #FFFAF1;
+            
+            /* Accents */
+            --charple: #6B50FF;
+            --dolly: #FF60FF;
+            --julep: #00FFB2;
+            --tang: #FF985A;
+            --malibu: #00A4FF;
+            --cherry: #FF388B;
+            --hazy: #8B75FF;
+            --blush: #FF84FF;
+            --bok: #68FFD6;
+            
+            /* Semantic */
+            --bg: var(--pepper);
+            --bg-secondary: var(--bbq);
+            --bg-tertiary: var(--charcoal);
+            --border: var(--iron);
+            --text: var(--smoke);
+            --text-bright: var(--butter);
+            --text-muted: var(--squid);
+            --user: var(--julep);
+            --agent: var(--dolly);
+            --system: var(--tang);
+        }
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+            background: var(--bg);
+            color: var(--text);
+            line-height: 1.5;
+            padding: 1.5rem;
+            font-size: 14px;
+        }
+        .container { max-width: 1100px; margin: 0 auto; }
+        header {
+            border-bottom: 1px solid var(--border);
+            padding-bottom: 1rem;
+            margin-bottom: 1.5rem;
+        }
+        h1 { font-size: 1.25rem; font-weight: 600; color: var(--text-bright); margin-bottom: 0.25rem; }
+        .meta { color: var(--text-muted); font-size: 0.75rem; display: flex; gap: 1rem; flex-wrap: wrap; }
+        .metrics {
+            display: flex;
+            gap: 1.5rem;
+            margin-top: 0.75rem;
+            padding: 0.75rem 1rem;
+            background: var(--bg-secondary);
+            border-radius: 6px;
+            font-size: 0.8rem;
+        }
+        .metric { text-align: center; }
+        .metric-value { font-size: 1.1rem; font-weight: 600; color: var(--charple); }
+        .metric-label { color: var(--text-muted); font-size: 0.7rem; }
+        
+        /* Timeline */
+        .timeline { display: flex; flex-direction: column; gap: 2px; }
+        .step {
+            background: var(--bg-secondary);
+            border-radius: 4px;
+            overflow: hidden;
+        }
+        .step-header {
+            display: flex;
+            align-items: center;
+            gap: 0.5rem;
+            padding: 0.5rem 0.75rem;
+            cursor: pointer;
+            user-select: none;
+            transition: background 0.15s;
+        }
+        .step-header:hover { background: var(--bg-tertiary); }
+        .step-toggle {
+            color: var(--text-muted);
+            font-size: 0.7rem;
+            transition: transform 0.15s;
+            width: 1rem;
+        }
+        .step.expanded .step-toggle { transform: rotate(90deg); }
+        .step-source {
+            font-size: 0.65rem;
+            font-weight: 600;
+            text-transform: uppercase;
+            padding: 0.125rem 0.4rem;
+            border-radius: 3px;
+            letter-spacing: 0.03em;
+        }
+        .step.user .step-source { background: rgba(0, 255, 178, 0.15); color: var(--user); }
+        .step.agent .step-source { background: rgba(255, 96, 255, 0.15); color: var(--agent); }
+        .step.system .step-source { background: rgba(255, 152, 90, 0.15); color: var(--system); }
+        .step-id { font-size: 0.7rem; color: var(--text-muted); font-family: monospace; }
+        .step-preview {
+            flex: 1;
+            font-size: 0.8rem;
+            color: var(--text);
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            margin-left: 0.5rem;
+        }
+        .step-time { font-size: 0.65rem; color: var(--text-muted); margin-left: auto; }
+        .step-badges { display: flex; gap: 0.25rem; margin-left: 0.5rem; }
+        .badge {
+            font-size: 0.6rem;
+            padding: 0.1rem 0.35rem;
+            border-radius: 3px;
+            background: var(--bg-tertiary);
+            color: var(--text-muted);
+        }
+        .badge.tools { background: rgba(107, 80, 255, 0.2); color: var(--charple); }
+        .badge.thinking { background: rgba(255, 96, 255, 0.2); color: var(--blush); }
+        
+        /* Expanded content */
+        .step-body { display: none; padding: 0.75rem; border-top: 1px solid var(--border); }
+        .step.expanded .step-body { display: block; }
+        .message {
+            background: var(--bg);
+            padding: 0.75rem;
+            border-radius: 4px;
+            white-space: pre-wrap;
+            word-wrap: break-word;
+            font-size: 0.85rem;
+            max-height: 300px;
+            overflow-y: auto;
+        }
+        .message:empty { display: none; }
+        .reasoning {
+            margin-top: 0.5rem;
+            padding: 0.5rem 0.75rem;
+            background: rgba(255, 96, 255, 0.08);
+            border-left: 2px solid var(--agent);
+            border-radius: 0 4px 4px 0;
+            font-size: 0.8rem;
+            color: var(--text-muted);
+            max-height: 200px;
+            overflow-y: auto;
+        }
+        .section-label {
+            font-size: 0.65rem;
+            text-transform: uppercase;
+            color: var(--text-muted);
+            margin-bottom: 0.25rem;
+            letter-spacing: 0.03em;
+        }
+        .reasoning .section-label { color: var(--blush); }
+        
+        /* Tool calls */
+        .tool-calls { margin-top: 0.5rem; display: flex; flex-direction: column; gap: 2px; }
+        .tool-call {
+            background: var(--bg);
+            border-radius: 4px;
+            overflow: hidden;
+        }
+        .tool-call-header {
+            display: flex;
+            align-items: center;
+            gap: 0.5rem;
+            padding: 0.4rem 0.6rem;
+            cursor: pointer;
+            user-select: none;
+            font-size: 0.8rem;
+        }
+        .tool-call-header:hover { background: var(--bg-tertiary); }
+        .tool-status { 
+            font-size: 0.7rem; 
+            width: 1rem;
+        }
+        .tool-call.completed .tool-status { color: var(--julep); }
+        .tool-call.pending .tool-status { color: var(--squid); }
+        .tool-call-name { font-family: monospace; font-weight: 600; color: var(--malibu); }
+        .tool-call-preview { 
+            flex: 1;
+            color: var(--squid); 
+            font-size: 0.75rem;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+        .tool-call-toggle { 
+            color: var(--text-muted); 
+            font-size: 0.6rem; 
+            transition: transform 0.15s;
+            margin-left: auto;
+        }
+        .tool-call.expanded .tool-call-toggle { transform: rotate(90deg); }
+        .tool-call-body { display: none; padding: 0.5rem 0.6rem; border-top: 1px solid var(--border); }
+        .tool-call.expanded .tool-call-body { display: block; }
+        .tool-args { margin-bottom: 0.5rem; }
+        .tool-result {
+            border-top: 1px solid var(--border);
+            padding-top: 0.5rem;
+            margin-top: 0.5rem;
+        }
+        .tool-result-content {
+            background: var(--bg-secondary);
+            padding: 0.5rem;
+            border-radius: 4px;
+            font-size: 0.75rem;
+            font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+            white-space: pre-wrap;
+            word-wrap: break-word;
+            max-height: 200px;
+            overflow-y: auto;
+            color: var(--smoke);
+        }
+        .tool-pending {
+            color: var(--squid);
+            font-size: 0.75rem;
+            font-style: italic;
+            padding: 0.25rem 0;
+        }
+        pre {
+            background: var(--bg-tertiary);
+            padding: 0.5rem;
+            border-radius: 3px;
+            overflow-x: auto;
+            font-size: 0.75rem;
+            font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+            max-height: 200px;
+            overflow-y: auto;
+        }
+        
+        footer {
+            margin-top: 1.5rem;
+            padding-top: 0.75rem;
+            border-top: 1px solid var(--border);
+            text-align: center;
+            color: var(--text-muted);
+            font-size: 0.7rem;
+        }
+        footer a { color: var(--charple); text-decoration: none; }
+        footer a:hover { text-decoration: underline; }
+        
+        /* Controls */
+        .controls {
+            display: flex;
+            gap: 0.5rem;
+            margin-bottom: 0.75rem;
+        }
+        .btn {
+            font-size: 0.7rem;
+            padding: 0.3rem 0.6rem;
+            border-radius: 4px;
+            border: 1px solid var(--border);
+            background: var(--bg-secondary);
+            color: var(--text);
+            cursor: pointer;
+            transition: all 0.15s;
+        }
+        .btn:hover { background: var(--bg-tertiary); border-color: var(--oyster); }
+        .filter-group { display: flex; gap: 0.25rem; }
+        .filter-btn { border-radius: 3px; }
+        .filter-btn.active { background: var(--charple); border-color: var(--charple); color: var(--butter); }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <header>
+            <h1 id="title"></h1>
+            <div class="meta">
+                <span id="session-id"></span>
+                <span id="schema-version"></span>
+                <span id="model-name"></span>
+            </div>
+            <div class="metrics" id="metrics"></div>
+        </header>
+        <div class="controls">
+            <button class="btn" onclick="expandAll()">Expand All</button>
+            <button class="btn" onclick="collapseAll()">Collapse All</button>
+            <div class="filter-group">
+                <button class="btn filter-btn active" data-filter="all" onclick="filterSteps('all', this)">All</button>
+                <button class="btn filter-btn" data-filter="user" onclick="filterSteps('user', this)">User</button>
+                <button class="btn filter-btn" data-filter="agent" onclick="filterSteps('agent', this)">Agent</button>
+                <button class="btn filter-btn" data-filter="system" onclick="filterSteps('system', this)">System</button>
+            </div>
+        </div>
+        <div class="timeline" id="timeline"></div>
+        <footer>
+            Generated by <a href="https://github.com/charmbracelet/crush">Crush</a> ยท Harbor ATIF v1.4
+        </footer>
+    </div>
+
+    <script>
+        const trajectory = {{.TrajectoryJSON}};
+
+        function formatTimestamp(ts) {
+            if (!ts) return '';
+            const d = new Date(ts);
+            return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
+        }
+
+        function escapeHtml(text) {
+            if (!text) return '';
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        function truncate(text, max = 100) {
+            if (!text) return '';
+            const single = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
+            if (single.length <= max) return single;
+            return single.slice(0, max) + 'โ€ฆ';
+        }
+
+        function renderHeader() {
+            document.getElementById('title').textContent = trajectory.agent.name + ' Trajectory';
+            document.getElementById('session-id').textContent = 'Session: ' + trajectory.session_id.slice(0, 20) + (trajectory.session_id.length > 20 ? 'โ€ฆ' : '');
+            document.getElementById('schema-version').textContent = trajectory.schema_version;
+            if (trajectory.agent.model_name) {
+                document.getElementById('model-name').textContent = 'Model: ' + trajectory.agent.model_name;
+            }
+
+            const metrics = trajectory.final_metrics;
+            if (metrics) {
+                document.getElementById('metrics').innerHTML = `
+                    <div class="metric">
+                        <div class="metric-value">${metrics.total_steps || trajectory.steps.length}</div>
+                        <div class="metric-label">Steps</div>
+                    </div>
+                    <div class="metric">
+                        <div class="metric-value">${((metrics.total_prompt_tokens || 0) / 1000).toFixed(1)}k</div>
+                        <div class="metric-label">Prompt</div>
+                    </div>
+                    <div class="metric">
+                        <div class="metric-value">${((metrics.total_completion_tokens || 0) / 1000).toFixed(1)}k</div>
+                        <div class="metric-label">Completion</div>
+                    </div>
+                    <div class="metric">
+                        <div class="metric-value">$${(metrics.total_cost_usd || 0).toFixed(3)}</div>
+                        <div class="metric-label">Cost</div>
+                    </div>
+                `;
+            }
+        }
+
+        function renderToolCall(tc, idx, observationResults) {
+            const argsJson = typeof tc.arguments === 'string' 
+                ? tc.arguments 
+                : JSON.stringify(tc.arguments, null, 2);
+            
+            // Extract key params for header preview
+            const args = typeof tc.arguments === 'object' ? tc.arguments : {};
+            const preview = getToolPreview(tc.function_name, args);
+            
+            // Find the matching result for this tool call
+            const result = (observationResults || []).find(r => r.source_call_id === tc.tool_call_id);
+            
+            const resultHtml = result ? `
+                <div class="tool-result">
+                    <div class="tool-result-content">${escapeHtml(result.content)}</div>
+                </div>
+            ` : '<div class="tool-pending">Waiting for result...</div>';
+            
+            return `
+                <div class="tool-call ${result ? 'completed' : 'pending'}" id="tc-${idx}">
+                    <div class="tool-call-header" onclick="toggleToolCall('tc-${idx}')">
+                        <span class="tool-status">${result ? 'โœ“' : 'โ—‹'}</span>
+                        <span class="tool-call-name">${escapeHtml(tc.function_name)}</span>
+                        <span class="tool-call-preview">${escapeHtml(preview)}</span>
+                        <span class="tool-call-toggle">โ–ถ</span>
+                    </div>
+                    <div class="tool-call-body">
+                        <div class="tool-args">
+                            <div class="section-label">Arguments</div>
+                            <pre>${escapeHtml(argsJson)}</pre>
+                        </div>
+                        ${resultHtml}
+                    </div>
+                </div>
+            `;
+        }
+
+        function getToolPreview(name, args) {
+            // Extract key params like the TUI does
+            switch(name) {
+                case 'bash':
+                    return truncate(args.command || '', 60);
+                case 'view':
+                case 'edit':
+                case 'write':
+                case 'multiedit':
+                    return args.file_path || '';
+                case 'glob':
+                    return args.pattern || '';
+                case 'grep':
+                    return `${args.pattern || ''}${args.path ? ' in ' + args.path : ''}`;
+                case 'ls':
+                    return args.path || '.';
+                case 'fetch':
+                case 'agentic_fetch':
+                case 'download':
+                    return args.url || '';
+                case 'sourcegraph':
+                    return args.query || '';
+                case 'todos':
+                    const todos = args.todos || [];
+                    const completed = todos.filter(t => t.status === 'completed').length;
+                    return `${completed}/${todos.length}`;
+                case 'agent':
+                    return truncate(args.prompt || '', 50);
+                default:
+                    // Try to get first string value
+                    for (const key of Object.keys(args)) {
+                        if (typeof args[key] === 'string' && args[key].length < 80) {
+                            return truncate(args[key], 60);
+                        }
+                    }
+                    return '';
+            }
+        }
+
+        function renderStep(step, idx) {
+            const observationResults = step.observation?.results || [];
+            const toolCalls = (step.tool_calls || []).map((tc, i) => renderToolCall(tc, `${idx}-${i}`, observationResults)).join('');
+            const reasoning = step.reasoning_content ? `
+                <div class="reasoning">
+                    <div class="section-label">Thinking</div>
+                    ${escapeHtml(step.reasoning_content)}
+                </div>
+            ` : '';
+            
+            const badges = [];
+            if (step.tool_calls && step.tool_calls.length > 0) {
+                badges.push(`<span class="badge tools">${step.tool_calls.length} tool${step.tool_calls.length > 1 ? 's' : ''}</span>`);
+            }
+            if (step.reasoning_content) {
+                badges.push(`<span class="badge thinking">thinking</span>`);
+            }
+
+            const preview = step.message || (step.observation?.results?.[0]?.content) || '';
+
+            return `
+                <div class="step ${step.source}" data-source="${step.source}" id="step-${idx}">
+                    <div class="step-header" onclick="toggleStep('step-${idx}')">
+                        <span class="step-toggle">โ–ถ</span>
+                        <span class="step-source">${step.source}</span>
+                        <span class="step-id">#${step.step_id}</span>
+                        <span class="step-preview">${escapeHtml(truncate(preview, 80))}</span>
+                        <div class="step-badges">${badges.join('')}</div>
+                        <span class="step-time">${formatTimestamp(step.timestamp)}</span>
+                    </div>
+                    <div class="step-body">
+                        ${step.message ? `<div class="message">${escapeHtml(step.message)}</div>` : ''}
+                        ${reasoning}
+                        ${toolCalls ? '<div class="tool-calls">' + toolCalls + '</div>' : ''}
+                    </div>
+                </div>
+            `;
+        }
+
+        function renderTimeline() {
+            const timeline = document.getElementById('timeline');
+            timeline.innerHTML = trajectory.steps.map((s, i) => renderStep(s, i)).join('');
+        }
+
+        function toggleStep(id) {
+            document.getElementById(id).classList.toggle('expanded');
+        }
+
+        function toggleToolCall(id) {
+            document.getElementById(id).classList.toggle('expanded');
+        }
+
+        function expandAll() {
+            document.querySelectorAll('.step').forEach(el => el.classList.add('expanded'));
+        }
+
+        function collapseAll() {
+            document.querySelectorAll('.step').forEach(el => el.classList.remove('expanded'));
+            document.querySelectorAll('.tool-call').forEach(el => el.classList.remove('expanded'));
+        }
+
+        function filterSteps(filter, btn) {
+            document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+            btn.classList.add('active');
+            document.querySelectorAll('.step').forEach(el => {
+                if (filter === 'all' || el.dataset.source === filter) {
+                    el.style.display = '';
+                } else {
+                    el.style.display = 'none';
+                }
+            });
+        }
+
+        renderHeader();
+        renderTimeline();
+    </script>
+</body>
+</html>