internal/cmd/root.go ๐
@@ -46,6 +46,7 @@ func init() {
logsCmd,
schemaCmd,
loginCmd,
+ trajectoryCmd,
)
}
Kujtim Hoxha created
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(+)
@@ -46,6 +46,7 @@ func init() {
logsCmd,
schemaCmd,
loginCmd,
+ trajectoryCmd,
)
}
@@ -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)
+}
@@ -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,
+ })
+ }
+}
@@ -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)
+}
@@ -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
+}
@@ -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>