1package hooks
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "time"
10
11 "github.com/charmbracelet/crush/internal/message"
12)
13
14// TranscriptMessage represents a message in the exported transcript.
15type TranscriptMessage struct {
16 ID string `json:"id"`
17 Role string `json:"role"`
18 Content string `json:"content,omitempty"`
19 ToolCalls []TranscriptToolCall `json:"tool_calls,omitempty"`
20 ToolResults []TranscriptToolResult `json:"tool_results,omitempty"`
21 Timestamp string `json:"timestamp"`
22}
23
24// TranscriptToolCall represents a tool call in the transcript.
25type TranscriptToolCall struct {
26 ID string `json:"id"`
27 Name string `json:"name"`
28 Input string `json:"input"`
29}
30
31// TranscriptToolResult represents a tool result in the transcript.
32type TranscriptToolResult struct {
33 ToolCallID string `json:"tool_call_id"`
34 Name string `json:"name"`
35 Content string `json:"content"`
36 IsError bool `json:"is_error"`
37}
38
39// Transcript represents the complete transcript structure.
40type Transcript struct {
41 SessionID string `json:"session_id"`
42 Messages []TranscriptMessage `json:"messages"`
43}
44
45// exportTranscript exports session messages to a temporary JSON file.
46func exportTranscript(
47 ctx context.Context,
48 messages message.Service,
49 sessionID string,
50) (string, error) {
51 // Get all messages for the session
52 msgs, err := messages.List(ctx, sessionID)
53 if err != nil {
54 return "", fmt.Errorf("failed to list messages: %w", err)
55 }
56
57 // Convert to transcript format
58 transcript := Transcript{
59 SessionID: sessionID,
60 Messages: make([]TranscriptMessage, 0, len(msgs)),
61 }
62
63 for _, msg := range msgs {
64 tm := TranscriptMessage{
65 ID: msg.ID,
66 Role: string(msg.Role),
67 Timestamp: time.Unix(msg.CreatedAt, 0).Format("2006-01-02T15:04:05Z07:00"),
68 }
69
70 // Extract content
71 for _, part := range msg.Parts {
72 if text, ok := part.(message.TextContent); ok {
73 if tm.Content != "" {
74 tm.Content += "\n"
75 }
76 tm.Content += text.Text
77 }
78 }
79
80 // Extract tool calls
81 if msg.Role == message.Assistant {
82 toolCalls := msg.ToolCalls()
83 for _, tc := range toolCalls {
84 tm.ToolCalls = append(tm.ToolCalls, TranscriptToolCall{
85 ID: tc.ID,
86 Name: tc.Name,
87 Input: tc.Input,
88 })
89 }
90 }
91
92 // Extract tool results
93 if msg.Role == message.Tool {
94 toolResults := msg.ToolResults()
95 for _, tr := range toolResults {
96 tm.ToolResults = append(tm.ToolResults, TranscriptToolResult{
97 ToolCallID: tr.ToolCallID,
98 Name: tr.Name,
99 Content: tr.Content,
100 IsError: tr.IsError,
101 })
102 }
103 }
104
105 transcript.Messages = append(transcript.Messages, tm)
106 }
107
108 // Marshal to JSON
109 data, err := json.MarshalIndent(transcript, "", " ")
110 if err != nil {
111 return "", fmt.Errorf("failed to marshal transcript: %w", err)
112 }
113
114 // Write to temporary file
115 tmpDir := os.TempDir()
116 filename := fmt.Sprintf("crush-transcript-%s.json", sessionID)
117 path := filepath.Join(tmpDir, filename)
118
119 // Use restrictive permissions (0600)
120 if err := os.WriteFile(path, data, 0o600); err != nil {
121 return "", fmt.Errorf("failed to write transcript file: %w", err)
122 }
123
124 return path, nil
125}
126
127func cleanupTranscript(path string) {
128 if path != "" {
129 if err := os.Remove(path); err != nil {
130 fmt.Fprintf(os.Stderr, "Warning: failed to cleanup transcript file %s: %v\n", path, err)
131 }
132 }
133}