export-crush-session.sh

· qlyntraex's pastes · raw

expires: 2026-02-24

  1#!/bin/bash
  2# Interactive Crush conversation exporter to Markdown
  3#
  4# REQUIRED SOFTWARE:
  5# - bash (shell)
  6# - sqlite3 (database queries)
  7# - gum (interactive CLI tools)
  8# - python3 (HTML escaping)
  9# - jq (JSON parsing)
 10# - base64 (encoding tool inputs)
 11# - sed, grep, cut, tr, date (standard Unix utilities)
 12
 13set -e
 14
 15DB=".crush/crush.db"
 16
 17# HTML escape function using Python
 18html_escape() {
 19    python3 -c 'import html,sys; print(html.escape(sys.stdin.read()), end="")' <<< "$1"
 20}
 21
 22# Check if database exists
 23if [ ! -f "$DB" ]; then
 24    echo "Error: Database not found at $DB"
 25    exit 1
 26fi
 27
 28# Check if gum is available
 29if ! command -v gum >/dev/null 2>&1; then
 30    echo "Error: gum is not installed"
 31    exit 1
 32fi
 33
 34# Get sessions list with formatted display
 35sessions=$(sqlite3 "$DB" "
 36SELECT 
 37    id,
 38    datetime(created_at, 'unixepoch'),
 39    printf('%3d', message_count),
 40    title
 41FROM sessions 
 42ORDER BY created_at DESC
 43")
 44
 45if [ -z "$sessions" ]; then
 46    echo "No sessions found"
 47    exit 0
 48fi
 49
 50# Format sessions for gum selection and build lookup map
 51formatted=""
 52tmpfile=$(mktemp)
 53while IFS='|' read -r id created_at msg_count title; do
 54    display_line="${created_at}${msg_count} msgs │ ${title}"
 55    formatted="${formatted}${display_line}
 56"
 57    # Store mapping of display line to ID
 58    printf '%s|%s\n' "$display_line" "$id" >> "$tmpfile"
 59done << EOF
 60$sessions
 61EOF
 62
 63# Let user select a session
 64selected=$(printf "%s" "$formatted" | gum filter --placeholder "Search for a conversation...")
 65
 66if [ -z "$selected" ]; then
 67    rm -f "$tmpfile"
 68    echo "No session selected"
 69    exit 0
 70fi
 71
 72# Extract session ID from lookup
 73session_id=$(grep -F "$selected" "$tmpfile" | cut -d'|' -f2)
 74rm -f "$tmpfile"
 75
 76# Get session details
 77session_title=$(sqlite3 "$DB" "SELECT title FROM sessions WHERE id = '$session_id'")
 78
 79# Ask for output filename
 80default_filename=$(echo "$session_title" | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g').md
 81output_file=$(gum input --placeholder "Output filename" --value "$default_filename")
 82
 83# Use default if user just pressed enter
 84if [ -z "$output_file" ]; then
 85    output_file="$default_filename"
 86fi
 87
 88# Export conversation to markdown
 89echo "Exporting conversation: $session_title"
 90echo "# $session_title" > "$output_file"
 91echo "" >> "$output_file"
 92
 93# Temp file to store tool calls for lookup by tool_call_id
 94tool_calls_file=$(mktemp)
 95trap "rm -f $tool_calls_file" EXIT
 96
 97# Get all messages with full data in one query
 98sqlite3 "$DB" "
 99SELECT 
100    id,
101    role,
102    model,
103    datetime(created_at, 'unixepoch'),
104    parts
105FROM messages 
106WHERE session_id = '$session_id'
107AND (is_summary_message = 0 OR is_summary_message IS NULL)
108ORDER BY created_at ASC
109" | while IFS='|' read -r msg_id role model created parts; do
110    # Determine group role (user vs assistant/tool)
111    if [ "$role" = "user" ]; then
112        group_role="user"
113    else
114        group_role="assistant"
115    fi
116    
117    # Check if we need a new section header (role changed)
118    if [ "$group_role" != "$prev_group_role" ]; then
119        # Close previous section if exists
120        if [ -n "$prev_group_role" ]; then
121            echo "" >> "$output_file"
122            echo "---" >> "$output_file"
123            echo "" >> "$output_file"
124        fi
125        
126        # Start new section
127        case "$group_role" in
128            user)
129                echo "## User" >> "$output_file"
130                ;;
131            assistant)
132                if [ -n "$model" ]; then
133                    echo "## Crush ($model)" >> "$output_file"
134                else
135                    echo "## Crush" >> "$output_file"
136                fi
137                ;;
138        esac
139        
140        echo "" >> "$output_file"
141        echo "_${created}_" >> "$output_file"
142        echo "" >> "$output_file"
143        prev_group_role="$group_role"
144        first_msg_in_section=true
145    fi
146    
147    # Skip timestamp for subsequent messages in same section
148    if [ "$first_msg_in_section" = "true" ]; then
149        first_msg_in_section=false
150    fi
151    
152    # Process each part in the message
153    last_tool_name=""
154    echo "$parts" | jq -c '.[]' | while read -r part; do
155        part_type=$(echo "$part" | jq -r '.type')
156        
157        # Skip finish parts
158        if [ "$part_type" = "finish" ]; then
159            continue
160        fi
161        
162        # Extract and format based on part type
163        case "$part_type" in
164            text)
165                text=$(echo "$part" | jq -r '.data.text')
166                echo "$text" >> "$output_file"
167                ;;
168            reasoning)
169                thinking=$(echo "$part" | jq -r '.data.thinking')
170                if [ -n "$thinking" ] && [ "$thinking" != "null" ]; then
171                    echo "" >> "$output_file"
172                    echo "<details>" >> "$output_file"
173                    echo "<summary>💭 Thinking</summary>" >> "$output_file"
174                    echo "" >> "$output_file"
175                    echo "$thinking" >> "$output_file"
176                    echo "</details>" >> "$output_file"
177                    echo "" >> "$output_file"
178                fi
179                ;;
180            tool_call)
181                tool_name=$(echo "$part" | jq -r '.data.name')
182                tool_input=$(echo "$part" | jq -r '.data.input')
183                last_tool_name="$tool_name"
184                
185                # Format tool call header based on tool type
186                case "$tool_name" in
187                    view)
188                        file_path=$(echo "$tool_input" | jq -r '.file_path // empty')
189                        if [ ${#file_path} -gt 60 ]; then
190                            display_path="${file_path:0:57}"
191                        else
192                            display_path="$file_path"
193                        fi
194                        tool_summary="📄 view: <code>$(html_escape "$display_path")</code>"
195                        ;;
196                    ls)
197                        ls_path=$(echo "$tool_input" | jq -r '.path // "."')
198                        if [ ${#ls_path} -gt 60 ]; then
199                            display_path="${ls_path:0:57}"
200                        else
201                            display_path="$ls_path"
202                        fi
203                        tool_summary="📂 ls: <code>$(html_escape "$display_path")</code>"
204                        ;;
205                    bash)
206                        description=$(echo "$tool_input" | jq -r '.description // empty')
207                        if [ ${#description} -gt 57 ]; then
208                            display_desc="${description:0:54}"
209                        else
210                            display_desc="$description"
211                        fi
212                        tool_summary="🖥️ bash: $(html_escape "$display_desc")"
213                        ;;
214                    grep)
215                        pattern=$(echo "$tool_input" | jq -r '.pattern // empty')
216                        grep_path=$(echo "$tool_input" | jq -r '.path // "."')
217                        if [ ${#pattern} -gt 40 ]; then
218                            display_pattern="${pattern:0:37}"
219                        else
220                            display_pattern="$pattern"
221                        fi
222                        tool_summary="🔎 grep: <code>$(html_escape "$display_pattern")</code> in <code>$(html_escape "$grep_path")</code>"
223                        ;;
224                    glob)
225                        pattern=$(echo "$tool_input" | jq -r '.pattern // empty')
226                        glob_path=$(echo "$tool_input" | jq -r '.path // "."')
227                        if [ ${#pattern} -gt 40 ]; then
228                            display_pattern="${pattern:0:37}"
229                        else
230                            display_pattern="$pattern"
231                        fi
232                        tool_summary="🔎 glob: <code>$(html_escape "$display_pattern")</code> in <code>$(html_escape "$glob_path")</code>"
233                        ;;
234                    write|edit|multiedit)
235                        file_path=$(echo "$tool_input" | jq -r '.file_path // empty')
236                        if [ ${#file_path} -gt 60 ]; then
237                            display_path="${file_path:0:57}"
238                        else
239                            display_path="$file_path"
240                        fi
241                        tool_summary="✏️ $tool_name: <code>$(html_escape "$display_path")</code>"
242                        ;;
243                    *)
244                        tool_summary="🔧 $(html_escape "$tool_name")"
245                        ;;
246                esac
247                
248                # Store for lookup by tool_result (using tool_call_id)
249                # Base64 encode tool_input to preserve newlines and special chars
250                tool_call_id=$(echo "$part" | jq -r '.data.id')
251                tool_input_b64=$(printf '%s' "$tool_input" | base64 -w0)
252                printf '%s\x1f%s\x1f%s\x1f%s\n' "$tool_call_id" "$tool_summary" "$tool_input_b64" "$tool_name" >> "$tool_calls_file"
253                ;;
254            tool_result)
255                tool_content=$(echo "$part" | jq -r '.data.content')
256                is_error=$(echo "$part" | jq -r '.data.is_error')
257                tool_call_id=$(echo "$part" | jq -r '.data.tool_call_id')
258                
259                # Look up tool call info by ID
260                tool_info=$(grep "^${tool_call_id}" "$tool_calls_file" | head -1 || true)
261                if [ -n "$tool_info" ]; then
262                    last_tool_summary=$(echo "$tool_info" | cut -d$'\x1f' -f2)
263                    last_tool_input_b64=$(echo "$tool_info" | cut -d$'\x1f' -f3)
264                    last_tool_input=$(printf '%s' "$last_tool_input_b64" | base64 -d)
265                    last_tool_name=$(echo "$tool_info" | cut -d$'\x1f' -f4)
266                fi
267                
268                # Strip XML-style tags from tool output
269                cleaned_content=$(echo "$tool_content" | sed -E '/<\/?result>/d; /<\/?file>/d; /<\/?output>/d')
270                
271                echo "" >> "$output_file"
272                echo "<details>" >> "$output_file"
273                echo "<summary>$last_tool_summary</summary>" >> "$output_file"
274                echo "" >> "$output_file"
275                echo '```json' >> "$output_file"
276                echo "$last_tool_input" | jq . >> "$output_file"
277                echo '```' >> "$output_file"
278                echo "" >> "$output_file"
279                if [ "$is_error" = "true" ]; then
280                    echo "**❌ Result**" >> "$output_file"
281                else
282                    echo "**✅ Result**" >> "$output_file"
283                fi
284                echo "" >> "$output_file"
285                # Special handling for edit tools - show before/after
286                if [ "$last_tool_name" = "edit" ]; then
287                    old_str=$(echo "$last_tool_input" | jq -r '.old_string // empty')
288                    new_str=$(echo "$last_tool_input" | jq -r '.new_string // empty')
289                    echo "**Before:**" >> "$output_file"
290                    echo '```' >> "$output_file"
291                    echo "$old_str" >> "$output_file"
292                    echo '```' >> "$output_file"
293                    echo "" >> "$output_file"
294                    echo "**After:**" >> "$output_file"
295                    echo '```' >> "$output_file"
296                    echo "$new_str" >> "$output_file"
297                    echo '```' >> "$output_file"
298                    echo "" >> "$output_file"
299                    echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file"
300                elif [ "$last_tool_name" = "multiedit" ]; then
301                    # Show each edit's before/after
302                    edits_count=$(echo "$last_tool_input" | jq '.edits | length')
303                    for ((i=0; i<edits_count; i++)); do
304                        old_str=$(echo "$last_tool_input" | jq -r ".edits[$i].old_string // empty")
305                        new_str=$(echo "$last_tool_input" | jq -r ".edits[$i].new_string // empty")
306                        echo "**Edit $((i+1)) - Before:**" >> "$output_file"
307                        echo '```' >> "$output_file"
308                        echo "$old_str" >> "$output_file"
309                        echo '```' >> "$output_file"
310                        echo "" >> "$output_file"
311                        echo "**Edit $((i+1)) - After:**" >> "$output_file"
312                        echo '```' >> "$output_file"
313                        echo "$new_str" >> "$output_file"
314                        echo '```' >> "$output_file"
315                        echo "" >> "$output_file"
316                    done
317                    echo "**Result:** $(html_escape "$cleaned_content")" >> "$output_file"
318                else
319                    echo '```' >> "$output_file"
320                    echo "$cleaned_content" >> "$output_file"
321                    echo '```' >> "$output_file"
322                fi
323                echo "</details>" >> "$output_file"
324                echo "" >> "$output_file"
325                ;;
326        esac
327    done
328    
329    echo "" >> "$output_file"
330done
331
332# Add metadata footer
333echo "---" >> "$output_file"
334echo "" >> "$output_file"
335echo "## Metadata" >> "$output_file"
336echo "" >> "$output_file"
337
338# Get session statistics
339metadata=$(sqlite3 "$DB" "
340SELECT 
341    COUNT(*) as msg_count,
342    datetime(MIN(created_at), 'unixepoch') as first_msg,
343    datetime(MAX(created_at), 'unixepoch') as last_msg,
344    MAX(created_at) - MIN(created_at) as duration_seconds
345FROM messages 
346WHERE session_id = '$session_id'
347AND (is_summary_message = 0 OR is_summary_message IS NULL)
348")
349
350msg_count=$(echo "$metadata" | cut -d'|' -f1)
351first_msg=$(echo "$metadata" | cut -d'|' -f2)
352last_msg=$(echo "$metadata" | cut -d'|' -f3)
353duration_seconds=$(echo "$metadata" | cut -d'|' -f4)
354
355# Calculate duration in human-readable format
356if [ "$duration_seconds" -ge 3600 ]; then
357    hours=$((duration_seconds / 3600))
358    minutes=$(((duration_seconds % 3600) / 60))
359    duration="${hours}h ${minutes}m"
360elif [ "$duration_seconds" -ge 60 ]; then
361    minutes=$((duration_seconds / 60))
362    seconds=$((duration_seconds % 60))
363    duration="${minutes}m ${seconds}s"
364else
365    duration="${duration_seconds}s"
366fi
367
368echo "- **Messages:** $msg_count" >> "$output_file"
369echo "- **Duration:** $duration" >> "$output_file"
370echo "- **Started:** $first_msg" >> "$output_file"
371echo "- **Ended:** $last_msg" >> "$output_file"
372
373gum style --foreground 212 "✓ Exported to: $output_file"