ccexport 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1177 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'set'
7
+ require 'erb'
8
+ require 'open3'
9
+ require_relative 'secret_detector'
10
+
11
+ class ClaudeConversationExporter
12
+ class << self
13
+ def export(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {})
14
+ new(project_path, output_dir, options).export
15
+ end
16
+
17
+ def generate_preview(output_path_or_dir, open_browser = true, leaf_summaries = [], template_name = 'default', silent = false)
18
+ # Helper method for output
19
+ output_helper = lambda { |message| puts message unless silent }
20
+
21
+ # Handle both directory and specific file paths
22
+ if output_path_or_dir.end_with?('.md') && File.exist?(output_path_or_dir)
23
+ # Specific markdown file provided
24
+ latest_md = output_path_or_dir
25
+ output_dir = File.dirname(output_path_or_dir)
26
+ else
27
+ # Directory provided - find the latest markdown file
28
+ output_dir = output_path_or_dir
29
+ latest_md = Dir.glob(File.join(output_dir, '*.md')).sort.last
30
+ end
31
+
32
+ if latest_md.nil? || !File.exist?(latest_md)
33
+ output_helper.call "No markdown files found in #{output_dir}/"
34
+ return false
35
+ end
36
+
37
+ output_helper.call "Creating preview for: #{File.basename(latest_md)}"
38
+
39
+ # Check if cmark-gfm is available
40
+ unless system('which cmark-gfm > /dev/null 2>&1')
41
+ output_helper.call "Error: cmark-gfm not found. Install it with:"
42
+ output_helper.call " brew install cmark-gfm"
43
+ return false
44
+ end
45
+
46
+ # Use cmark-gfm to convert markdown to HTML with --unsafe for collapsed sections
47
+ # The --unsafe flag prevents escaping in code blocks
48
+ stdout, stderr, status = Open3.capture3(
49
+ 'cmark-gfm', '--unsafe', '--extension', 'table', '--extension', 'strikethrough',
50
+ '--extension', 'autolink', '--extension', 'tagfilter', '--extension', 'tasklist',
51
+ latest_md
52
+ )
53
+
54
+ unless status.success?
55
+ output_helper.call "Error running cmark-gfm: #{stderr}"
56
+ return false
57
+ end
58
+
59
+ md_html = stdout
60
+
61
+ # Load ERB template - support both template names and file paths
62
+ if template_name.include?('/') || template_name.end_with?('.erb')
63
+ # Full path provided
64
+ template_path = template_name
65
+ else
66
+ # Template name provided - look in templates directory
67
+ template_path = File.join(File.dirname(__FILE__), 'templates', "#{template_name}.html.erb")
68
+ end
69
+ unless File.exist?(template_path)
70
+ output_helper.call "Error: ERB template not found at #{template_path}"
71
+ return false
72
+ end
73
+
74
+ template = File.read(template_path)
75
+ erb = ERB.new(template)
76
+
77
+ # Create the complete HTML content using ERB template
78
+ content = md_html
79
+ title = extract_title_from_summaries_or_markdown(latest_md, leaf_summaries)
80
+ full_html = erb.result(binding)
81
+
82
+ # Create HTML file in output directory
83
+ html_filename = latest_md.gsub(/\.md$/, '.html')
84
+ File.write(html_filename, full_html)
85
+
86
+ output_helper.call "HTML preview: #{html_filename}"
87
+
88
+ # Open in the default browser only if requested
89
+ if open_browser
90
+ system("open", html_filename)
91
+ output_helper.call "Opening in browser..."
92
+ end
93
+
94
+ html_filename
95
+ end
96
+
97
+ def include_prism
98
+ # Prism.js CSS and JavaScript for syntax highlighting
99
+ # MIT License - https://github.com/PrismJS/prism
100
+ css = File.read(File.join(__dir__, 'assets/prism.css'))
101
+ js = File.read(File.join(__dir__, 'assets/prism.js'))
102
+
103
+ # Additional language components
104
+ language_components = %w[
105
+ prism-python.js
106
+ prism-markdown.js
107
+ prism-typescript.js
108
+ prism-json.js
109
+ prism-yaml.js
110
+ prism-bash.js
111
+ ]
112
+
113
+ # Load and concatenate language components
114
+ language_js = language_components.map do |component|
115
+ component_path = File.join(__dir__, 'assets', component)
116
+ File.exist?(component_path) ? File.read(component_path) : ""
117
+ end.join("\n")
118
+
119
+ # Add initialization code to automatically highlight code blocks
120
+ init_js = <<~JS
121
+
122
+ /* Initialize Prism.js */
123
+ if (typeof window !== 'undefined' && window.document) {
124
+ document.addEventListener('DOMContentLoaded', function() {
125
+ if (typeof Prism !== 'undefined' && Prism.highlightAll) {
126
+ Prism.highlightAll();
127
+ }
128
+ });
129
+ }
130
+ JS
131
+
132
+ # Return both CSS and JS as a complete block
133
+ "<style>#{css}</style>\n<script>#{js}\n#{language_js}#{init_js}</script>"
134
+ end
135
+
136
+ def extract_title_from_summaries_or_markdown(markdown_file, leaf_summaries = [])
137
+ # Try to get title from leaf summaries first
138
+ if leaf_summaries.any?
139
+ # Use the first (oldest) summary as the main title
140
+ return leaf_summaries.first[:summary]
141
+ end
142
+
143
+ # Fallback: read the markdown file and extract title from content
144
+ begin
145
+ content = File.read(markdown_file)
146
+
147
+ # Look for the first user message content as fallback
148
+ if content.match(/## 👤 User\s*\n\n(.+?)(?:\n\n|$)/m)
149
+ first_user_message = $1.strip
150
+ # Clean up the message for use as title
151
+ title_words = first_user_message.split(/\s+/).first(8).join(' ')
152
+ return title_words.length > 60 ? title_words[0..57] + '...' : title_words
153
+ end
154
+
155
+ # Final fallback
156
+ "Claude Code Conversation"
157
+ rescue
158
+ "Claude Code Conversation"
159
+ end
160
+ end
161
+ end
162
+
163
+ def initialize(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {})
164
+ @project_path = File.expand_path(project_path)
165
+ @output_dir = File.expand_path(output_dir)
166
+ @claude_home = find_claude_home
167
+ @compacted_conversation_processed = false
168
+ @options = options
169
+ @show_timestamps = options[:timestamps] || false
170
+ @silent = options[:silent] || false
171
+ @leaf_summaries = []
172
+ @skipped_messages = []
173
+ @secrets_detected = []
174
+ setup_date_filters
175
+ setup_secret_detection
176
+ end
177
+
178
+ def export
179
+ if @options[:jsonl]
180
+ # Process specific JSONL file
181
+ session_files = [File.expand_path(@options[:jsonl])]
182
+ session_dir = File.dirname(session_files.first)
183
+ else
184
+ # Scan for session files in directory
185
+ session_dir = find_session_directory
186
+ session_files = Dir.glob(File.join(session_dir, '*.jsonl')).sort
187
+ raise "No session files found in #{session_dir}" if session_files.empty?
188
+ end
189
+
190
+ # Handle output path - could be a directory or specific file
191
+ if @output_dir.end_with?('.md')
192
+ # Specific file path provided
193
+ output_path = File.expand_path(@output_dir)
194
+ output_dir = File.dirname(output_path)
195
+ FileUtils.mkdir_p(output_dir)
196
+ else
197
+ # Directory provided
198
+ FileUtils.mkdir_p(@output_dir)
199
+ output_dir = @output_dir
200
+ output_path = nil # Will be generated later
201
+ end
202
+
203
+ if @options[:jsonl]
204
+ output "Processing specific JSONL file: #{File.basename(session_files.first)}"
205
+ else
206
+ output "Found #{session_files.length} session file(s) in #{session_dir}"
207
+ end
208
+
209
+ sessions = []
210
+ total_messages = 0
211
+
212
+ session_files.each do |session_file|
213
+ session = process_session(session_file)
214
+ next if session[:messages].empty?
215
+
216
+ sessions << session
217
+ output "✓ #{session[:session_id]}: #{session[:messages].length} messages"
218
+ total_messages += session[:messages].length
219
+ end
220
+
221
+ # Sort sessions by first timestamp to ensure chronological order
222
+ sessions.sort_by! { |session| session[:first_timestamp] || '1970-01-01T00:00:00Z' }
223
+
224
+ if sessions.empty?
225
+ output "\nNo sessions to export"
226
+ return { sessions_exported: 0, total_messages: 0 }
227
+ end
228
+
229
+ # Generate output path if not already specified
230
+ if output_path.nil?
231
+ filename = generate_combined_filename(sessions)
232
+ output_path = File.join(output_dir, filename)
233
+ end
234
+
235
+ File.write(output_path, format_combined_markdown(sessions))
236
+
237
+ # Write skip log if there were any skipped messages
238
+ write_skip_log(output_path)
239
+
240
+ # Write secrets detection log if any secrets were detected
241
+ write_secrets_log(output_path)
242
+
243
+ output "\nExported #{sessions.length} conversations (#{total_messages} total messages) to #{output_path}"
244
+
245
+ { sessions_exported: sessions.length, total_messages: total_messages, leaf_summaries: @leaf_summaries, output_file: output_path }
246
+ end
247
+
248
+ private
249
+
250
+
251
+ def output(message)
252
+ puts message unless @silent
253
+ end
254
+
255
+ def detect_language_from_path(file_path)
256
+ return '' if file_path.nil? || file_path.empty?
257
+
258
+ file_ext = File.extname(file_path).downcase
259
+ file_name = File.basename(file_path)
260
+
261
+ case file_ext
262
+ when '.rb' then 'ruby'
263
+ when '.js' then 'javascript'
264
+ when '.ts' then 'typescript'
265
+ when '.py' then 'python'
266
+ when '.java' then 'java'
267
+ when '.cpp', '.cc', '.cxx' then 'cpp'
268
+ when '.c' then 'c'
269
+ when '.h' then 'c'
270
+ when '.html' then 'html'
271
+ when '.css' then 'css'
272
+ when '.scss' then 'scss'
273
+ when '.json' then 'json'
274
+ when '.yaml', '.yml' then 'yaml'
275
+ when '.xml' then 'xml'
276
+ when '.md' then 'markdown'
277
+ when '.sh' then 'bash'
278
+ when '.sql' then 'sql'
279
+ else
280
+ # Check common file names without extensions
281
+ case file_name.downcase
282
+ when 'gemfile', 'rakefile', 'guardfile', 'capfile', 'vagrantfile' then 'ruby'
283
+ when 'makefile' then 'makefile'
284
+ when 'dockerfile' then 'dockerfile'
285
+ else ''
286
+ end
287
+ end
288
+ end
289
+
290
+ def track_skipped_message(line_index, reason, data = nil)
291
+ return if reason == 'outside date range' # Don't log date range skips
292
+
293
+ skip_entry = {
294
+ line: line_index + 1,
295
+ reason: reason
296
+ }
297
+
298
+ # Include the full JSON data if available
299
+ skip_entry[:data] = data if data
300
+
301
+ @skipped_messages << skip_entry
302
+ end
303
+
304
+ def write_skip_log(output_path)
305
+ return if @skipped_messages.empty?
306
+
307
+ log_path = output_path.gsub(/\.md$/, '_skipped.jsonl')
308
+
309
+ File.open(log_path, 'w') do |f|
310
+ @skipped_messages.each do |skip|
311
+ f.puts JSON.generate(skip)
312
+ end
313
+ end
314
+
315
+ output "Skipped #{@skipped_messages.length} messages (see #{File.basename(log_path)})"
316
+ end
317
+
318
+ def write_secrets_log(output_path)
319
+ return if @secrets_detected.empty?
320
+
321
+ log_path = output_path.gsub(/\.md$/, '_secrets.jsonl')
322
+
323
+ File.open(log_path, 'w') do |f|
324
+ @secrets_detected.each do |secret|
325
+ f.puts JSON.generate(secret)
326
+ end
327
+ end
328
+
329
+ output "⚠️ Detected #{@secrets_detected.length} potential secrets in conversation content (see #{File.basename(log_path)})"
330
+ output " Please review and ensure no sensitive information is shared in exports."
331
+ end
332
+
333
+ def setup_secret_detection
334
+ # Initialize our custom secret detector
335
+ @secret_detector = SecretDetector.new
336
+ rescue StandardError => e
337
+ output "Warning: Secret detection initialization failed: #{e.message}"
338
+ output "Proceeding without secret detection."
339
+ @secret_detector = nil
340
+ end
341
+
342
+ def setup_date_filters
343
+ if @options[:today]
344
+ # Filter for today only in user's timezone
345
+ today = Time.now
346
+ @from_time = Time.new(today.year, today.month, today.day, 0, 0, 0, today.utc_offset)
347
+ @to_time = Time.new(today.year, today.month, today.day, 23, 59, 59, today.utc_offset)
348
+ else
349
+ if @options[:from]
350
+ @from_time = parse_date_input(@options[:from], 'from', start_of_day: true)
351
+ end
352
+
353
+ if @options[:to]
354
+ @to_time = parse_date_input(@options[:to], 'to', start_of_day: false)
355
+ end
356
+ end
357
+ end
358
+
359
+ def parse_date_input(date_input, param_name, start_of_day:)
360
+ begin
361
+ # Try timestamp format first (from --timestamps output)
362
+ if date_input.match?(/\w+ \d{1,2}, \d{4} at \d{1,2}:\d{2}:\d{2} (AM|PM)/)
363
+ parsed_time = Time.parse(date_input)
364
+ return parsed_time
365
+ end
366
+
367
+ # Try YYYY-MM-DD format
368
+ date = Date.parse(date_input)
369
+ hour = start_of_day ? 0 : 23
370
+ minute = start_of_day ? 0 : 59
371
+ second = start_of_day ? 0 : 59
372
+
373
+ Time.new(date.year, date.month, date.day, hour, minute, second, Time.now.utc_offset)
374
+ rescue ArgumentError
375
+ raise "Invalid #{param_name} date format: #{date_input}. Use YYYY-MM-DD or 'Month DD, YYYY at HH:MM:SS AM/PM' format."
376
+ end
377
+ end
378
+
379
+ def message_in_date_range?(timestamp)
380
+ return true unless @from_time || @to_time
381
+
382
+ begin
383
+ message_time = Time.parse(timestamp)
384
+
385
+ if @from_time && message_time < @from_time
386
+ return false
387
+ end
388
+
389
+ if @to_time && message_time > @to_time
390
+ return false
391
+ end
392
+
393
+ true
394
+ rescue ArgumentError
395
+ # If timestamp is invalid, include the message
396
+ true
397
+ end
398
+ end
399
+
400
+ def find_claude_home
401
+ candidates = [
402
+ File.join(Dir.home, '.claude'),
403
+ File.join(Dir.home, '.config', 'claude')
404
+ ]
405
+
406
+ claude_home = candidates.find { |path| Dir.exist?(File.join(path, 'projects')) }
407
+ raise "Claude home directory not found. Searched: #{candidates.join(', ')}" unless claude_home
408
+
409
+ claude_home
410
+ end
411
+
412
+ def find_session_directory
413
+ encoded_path = encode_path(@project_path)
414
+ session_dir = File.join(@claude_home, 'projects', encoded_path)
415
+
416
+ return session_dir if Dir.exist?(session_dir)
417
+
418
+ # Fallback: search for directories containing project name
419
+ project_name = File.basename(@project_path)
420
+ projects_dir = File.join(@claude_home, 'projects')
421
+
422
+ candidates = Dir.entries(projects_dir)
423
+ .select { |dir| dir.include?(project_name) && Dir.exist?(File.join(projects_dir, dir)) }
424
+ .map { |dir| File.join(projects_dir, dir) }
425
+
426
+ raise "No Claude sessions found for project: #{@project_path}" if candidates.empty?
427
+
428
+ candidates.first
429
+ end
430
+
431
+ def encode_path(path)
432
+ path.gsub('/', '-').gsub('_', '-')
433
+ end
434
+
435
+ def process_session(session_file)
436
+ session_id = File.basename(session_file, '.jsonl')
437
+ messages = process_messages_linearly(session_file)
438
+
439
+ {
440
+ session_id: session_id,
441
+ messages: messages,
442
+ first_timestamp: messages.first&.dig(:timestamp),
443
+ last_timestamp: messages.last&.dig(:timestamp)
444
+ }
445
+ end
446
+
447
+ def process_messages_linearly(jsonl_file)
448
+ messages = []
449
+ pending_tool_use = nil
450
+
451
+ File.readlines(jsonl_file, chomp: true).each_with_index do |line, index|
452
+ next if line.strip.empty?
453
+
454
+ begin
455
+ data = JSON.parse(line)
456
+
457
+ # Skip ignorable message types, but collect leaf summaries first
458
+ if data.key?('leafUuid')
459
+ extract_leaf_summary(data)
460
+ track_skipped_message(index, 'leaf summary message', data)
461
+ next
462
+ end
463
+
464
+ if data.key?('isApiErrorMessage') || data.key?('isMeta')
465
+ track_skipped_message(index, 'api error or meta message', data)
466
+ next
467
+ end
468
+
469
+ # Skip messages outside date range
470
+ unless message_in_date_range?(data['timestamp'])
471
+ track_skipped_message(index, 'outside date range', data)
472
+ next
473
+ end
474
+
475
+ if data.key?('isCompactSummary')
476
+ # Extract clean compacted conversation (only if first one)
477
+ unless @compacted_conversation_processed
478
+ messages << format_compacted_conversation(data)
479
+ @compacted_conversation_processed = true
480
+ end
481
+ elsif data.key?('toolUseResult')
482
+ # Pair with previous tool_use
483
+ if pending_tool_use
484
+ messages << format_combined_tool_use(pending_tool_use, data)
485
+ pending_tool_use = nil
486
+ end
487
+ elsif data.key?('requestId') || regular_message?(data)
488
+ # Check if this assistant message contains tool_use
489
+ if tool_use_message?(data)
490
+ pending_tool_use = data # Hold for pairing with next toolUseResult
491
+ else
492
+ message = format_regular_message(data)
493
+ if message
494
+ messages << message
495
+ else
496
+ track_skipped_message(index, 'empty or system-generated message', data)
497
+ end
498
+ end
499
+ end
500
+ rescue JSON::ParserError => e
501
+ track_skipped_message(index, "invalid JSON: #{e.message}", nil)
502
+ output "Warning: Skipping invalid JSON at line #{index + 1}: #{e.message}"
503
+ end
504
+ end
505
+
506
+ messages
507
+ end
508
+
509
+ def regular_message?(data)
510
+ # Messages without special keys are regular messages
511
+ special_keys = %w[isApiErrorMessage leafUuid isMeta isCompactSummary requestId toolUseResult]
512
+ special_keys.none? { |key| data.key?(key) }
513
+ end
514
+
515
+ def tool_use_message?(data)
516
+ return false unless data['message']
517
+ content = data['message']['content']
518
+ return false unless content.is_a?(Array)
519
+
520
+ content.any? { |item| item.is_a?(Hash) && item['type'] == 'tool_use' }
521
+ end
522
+
523
+ def format_compacted_conversation(data)
524
+ content = data.dig('message', 'content')
525
+ text = content.is_a?(Array) ? content.first['text'] : content
526
+
527
+ {
528
+ role: 'user',
529
+ content: format_compacted_block(text),
530
+ timestamp: data['timestamp'],
531
+ message_id: data.dig('message', 'id'),
532
+ index: 0
533
+ }
534
+ end
535
+
536
+ def format_combined_tool_use(tool_use_data, tool_result_data)
537
+ # Extract tool_use from assistant message
538
+ tool_uses = tool_use_data.dig('message', 'content')&.select { |item| item['type'] == 'tool_use' }
539
+ return nil unless tool_uses&.any?
540
+
541
+ # Extract tool_result
542
+ tool_result = tool_result_data.dig('message', 'content')&.first
543
+
544
+ # Format as combined tool use + result
545
+ content = format_tool_use(tool_uses.first, tool_result)
546
+
547
+ {
548
+ role: 'assistant',
549
+ content: content,
550
+ timestamp: tool_use_data['timestamp'],
551
+ message_id: tool_use_data.dig('message', 'id'),
552
+ index: 0
553
+ }
554
+ end
555
+
556
+ def format_thinking_message(data)
557
+ thinking_content = data['thinking']
558
+
559
+ # Format thinking content as blockquote
560
+ thinking_lines = thinking_content.split("\n").map { |line| "> #{line}" }
561
+ formatted_content = thinking_lines.join("\n")
562
+
563
+ {
564
+ role: 'assistant_thinking',
565
+ content: formatted_content,
566
+ timestamp: data['timestamp'] || Time.now.iso8601,
567
+ message_id: data.dig('message', 'id'),
568
+ index: 0
569
+ }
570
+ end
571
+
572
+ def format_regular_message(data)
573
+ role = data.dig('message', 'role')
574
+ content = data.dig('message', 'content')
575
+
576
+ return nil if system_generated_data?(data)
577
+
578
+ message_id = data.dig('message', 'id') || 'unknown'
579
+
580
+ if content.is_a?(Array)
581
+ result = extract_text_content(content, "message_#{message_id}")
582
+ processed_content = result[:content]
583
+ has_thinking = result[:has_thinking]
584
+
585
+ # Update role if message contains thinking
586
+ role = 'assistant_thinking' if has_thinking && role == 'assistant'
587
+ elsif content.is_a?(String)
588
+ processed_content = content
589
+ else
590
+ processed_content = JSON.pretty_generate(content)
591
+ end
592
+
593
+ return nil if processed_content.strip.empty?
594
+
595
+ # Fix nested backticks in regular content
596
+ processed_content = fix_nested_backticks_in_content(processed_content)
597
+
598
+ # Skip messages that contain compacted conversation phrases
599
+ # Only official isCompactSummary messages should contain these
600
+ # text_to_check = processed_content.to_s
601
+ # has_compacted_phrases = text_to_check.include?('Primary Request and Intent:') ||
602
+ # text_to_check.include?('This session is being continued from a previous conversation')
603
+
604
+ # return nil if has_compacted_phrases
605
+
606
+ {
607
+ role: role,
608
+ content: processed_content,
609
+ timestamp: data['timestamp'],
610
+ message_id: data.dig('message', 'id'),
611
+ index: 0
612
+ }
613
+ end
614
+
615
+ def system_generated_data?(data)
616
+ content = data.dig('message', 'content')
617
+
618
+ if content.is_a?(Array)
619
+ text_content = content.select { |item| item['type'] == 'text' }.map { |item| item['text'] }.join(' ')
620
+ elsif content.is_a?(String)
621
+ text_content = content
622
+ else
623
+ return false
624
+ end
625
+
626
+ system_generated?(text_content)
627
+ end
628
+
629
+ # Test helper method - processes a single message data for testing
630
+ def test_process_message(data, index = 0)
631
+ # Skip ignorable message types, but collect leaf summaries first
632
+ if data.key?('leafUuid')
633
+ extract_leaf_summary(data)
634
+ return nil
635
+ end
636
+ return nil if data.key?('isApiErrorMessage') || data.key?('isMeta')
637
+
638
+ if data['type'] == 'thinking'
639
+ format_thinking_message(data)
640
+ elsif data.key?('isCompactSummary')
641
+ format_compacted_conversation(data)
642
+ elsif data.key?('toolUseResult')
643
+ # For testing, just return the tool result data
644
+ {
645
+ role: 'user',
646
+ content: data.dig('message', 'content').to_s,
647
+ timestamp: data['timestamp'],
648
+ index: index,
649
+ tool_result: true
650
+ }
651
+ elsif data.key?('requestId') || regular_message?(data)
652
+ if tool_use_message?(data)
653
+ # Extract tool_use_ids for testing
654
+ content = data.dig('message', 'content')
655
+ tool_use_ids = content.select { |item| item.is_a?(Hash) && item['type'] == 'tool_use' }
656
+ .map { |item| item['id'] }
657
+
658
+ result = extract_text_content(content, "tool_use_#{data['uuid'] || 'unknown'}")
659
+
660
+ # Return tool use message for testing
661
+ {
662
+ role: 'assistant',
663
+ content: result[:content],
664
+ timestamp: data['timestamp'],
665
+ index: index,
666
+ tool_use: true,
667
+ tool_use_ids: tool_use_ids
668
+ }
669
+ else
670
+ format_regular_message(data)
671
+ end
672
+ end
673
+ end
674
+
675
+ def extract_text_content(content_array, context_id = 'content')
676
+ parts = []
677
+ has_thinking = false
678
+
679
+ content_array.each do |item|
680
+ if item.is_a?(Hash) && item['type'] == 'text' && item['text']
681
+ parts << item['text']
682
+ elsif item.is_a?(Hash) && item['type'] == 'thinking' && item['thinking']
683
+ # Format thinking content as blockquote
684
+ thinking_lines = item['thinking'].split("\n").map { |line| "> #{line}" }
685
+ parts << thinking_lines.join("\n")
686
+ has_thinking = true
687
+ elsif item.is_a?(Hash) && item['type'] == 'tool_use'
688
+ # Format tool_use without tool_result (will be paired later at message level)
689
+ parts << format_tool_use(item, nil)
690
+ else
691
+ # Preserve other content types as JSON for now
692
+ parts << JSON.pretty_generate(item)
693
+ end
694
+ end
695
+
696
+ # If this message contains thinking, it needs special role handling
697
+ content = parts.join("\n\n")
698
+
699
+ { content: content, has_thinking: has_thinking }
700
+ end
701
+
702
+ # Scan content for secrets and redact them using our custom detector
703
+ def scan_and_redact_secrets(content, context_id = 'unknown')
704
+ return content if @secret_detector.nil? || content.nil? || content.empty?
705
+
706
+ original_content = content.to_s
707
+
708
+ # Scan for secrets
709
+ findings = @secret_detector.scan(original_content)
710
+
711
+ # If no secrets found, return original content
712
+ return content if findings.empty?
713
+
714
+ # Record detected secrets for logging
715
+ findings.each do |finding|
716
+ @secrets_detected << {
717
+ context: context_id,
718
+ type: finding.type,
719
+ pattern: finding.pattern_name,
720
+ confidence: finding.confidence
721
+ }
722
+ end
723
+
724
+ # Redact the secrets
725
+ redacted_content = @secret_detector.redact(original_content)
726
+
727
+ redacted_content
728
+ rescue StandardError => e
729
+ output "Warning: Secret detection/redaction failed for #{context_id}: #{e.message}"
730
+ content
731
+ end
732
+
733
+
734
+ # Helper method to escape backticks in code blocks
735
+ def escape_backticks(content)
736
+ content.to_s.gsub('`', '\\\`')
737
+ end
738
+
739
+ # Helper method to escape HTML tags
740
+ def escape_html(content)
741
+ content.to_s.gsub('&', '&amp;')
742
+ .gsub('<', '&lt;')
743
+ .gsub('>', '&gt;')
744
+ .gsub('"', '&quot;')
745
+ .gsub("'", '&#39;')
746
+ end
747
+
748
+ # Helper method to determine how many backticks are needed to wrap content
749
+ def determine_backticks_needed(content)
750
+ # Find the longest sequence of consecutive backticks in the content
751
+ max_backticks = content.scan(/`+/).map(&:length).max || 0
752
+
753
+ # Use one more than the maximum found, with a minimum of 3
754
+ needed = [max_backticks + 1, 3].max
755
+ '`' * needed
756
+ end
757
+
758
+ # Helper method to escape content for code blocks (combines both escaping approaches)
759
+ def escape_for_code_block(content)
760
+ # First escape HTML entities, but don't escape backticks since we're handling them
761
+ # with dynamic backtick counts
762
+ # escape_html(content)
763
+ content
764
+ end
765
+
766
+ # Fix nested backticks in regular message content
767
+ def fix_nested_backticks_in_content(content)
768
+ require_relative 'markdown_code_block_parser'
769
+
770
+ parser = MarkdownCodeBlockParser.new(content)
771
+ parser.parse
772
+
773
+ # Use Opus's parser to escape nested blocks
774
+ # Even though the pairing isn't perfect, it produces balanced HTML
775
+ parser.send(:escape_nested_blocks)
776
+ end
777
+
778
+
779
+ # Format any compacted conversation content as collapsible section
780
+ def format_compacted_block(text)
781
+ lines = []
782
+ lines << "<details>"
783
+ lines << "<summary>Compacted</summary>"
784
+ lines << ""
785
+ lines << escape_html(escape_backticks(text))
786
+ lines << "</details>"
787
+
788
+ lines.join("\n")
789
+ end
790
+
791
+ def format_tool_use(tool_use, tool_result = nil)
792
+ tool_name = tool_use['name'] || 'Unknown Tool'
793
+ tool_input = tool_use['input'] || {}
794
+
795
+ markdown = ["## 🤖🔧 Assistant"]
796
+
797
+ # Main collapsed section for the tool
798
+ markdown << "<details>"
799
+
800
+ # Special formatting for Write tool
801
+ if tool_name == 'Write' && tool_input['file_path']
802
+ # Extract relative path from the file_path
803
+ relative_path = tool_input['file_path'].gsub(@project_path, '').gsub(/^\//, '')
804
+ markdown << "<summary>Write #{relative_path}</summary>"
805
+ markdown << ""
806
+
807
+ # Format content in appropriate code block
808
+ if tool_input['content']
809
+ language = detect_language_from_path(tool_input['file_path'])
810
+
811
+ # Use appropriate number of backticks to wrap content that may contain backticks
812
+ backticks = determine_backticks_needed(tool_input['content'])
813
+ markdown << "#{backticks}#{language}"
814
+ markdown << escape_for_code_block(tool_input['content'])
815
+ markdown << backticks
816
+ else
817
+ # Fallback to JSON if no content
818
+ markdown << "```json"
819
+ markdown << escape_backticks(JSON.pretty_generate(tool_input))
820
+ markdown << "```"
821
+ end
822
+ # Special formatting for Bash tool
823
+ elsif tool_name == 'Bash' && tool_input['command']
824
+ description = tool_input['description'] || 'Run bash command'
825
+ markdown << "<summary>Bash: #{description}</summary>"
826
+ markdown << ""
827
+
828
+ # Make paths relative in the command
829
+ command = tool_input['command'].gsub(@project_path, '.').gsub(@project_path.gsub('/', '\\/'), '.')
830
+
831
+ backticks = determine_backticks_needed(command)
832
+ markdown << "#{backticks}bash"
833
+ markdown << escape_for_code_block(command)
834
+ markdown << backticks
835
+ # Special formatting for Edit tool
836
+ elsif tool_name == 'Edit' && tool_input['file_path']
837
+ # Extract relative path from the file_path
838
+ relative_path = tool_input['file_path'].gsub(@project_path, '').gsub(/^\//, '')
839
+ markdown << "<summary>Edit #{relative_path}</summary>"
840
+ markdown << ""
841
+
842
+ # Determine file extension for syntax highlighting
843
+ language = detect_language_from_path(tool_input['file_path'])
844
+
845
+ if tool_input['old_string'] && tool_input['new_string']
846
+ old_backticks = determine_backticks_needed(tool_input['old_string'])
847
+ new_backticks = determine_backticks_needed(tool_input['new_string'])
848
+
849
+ markdown << "**Before:**"
850
+ markdown << "#{old_backticks}#{language}"
851
+ markdown << escape_for_code_block(tool_input['old_string'])
852
+ markdown << old_backticks
853
+ markdown << ""
854
+ markdown << "**After:**"
855
+ markdown << "#{new_backticks}#{language}"
856
+ markdown << escape_for_code_block(tool_input['new_string'])
857
+ markdown << new_backticks
858
+ else
859
+ # Fallback to JSON if old_string/new_string not available
860
+ markdown << "```json"
861
+ markdown << escape_backticks(JSON.pretty_generate(tool_input))
862
+ markdown << "```"
863
+ end
864
+ # Special formatting for TodoWrite tool
865
+ elsif tool_name == 'TodoWrite' && tool_input['todos']
866
+ markdown << "<summary>#{tool_name}</summary>"
867
+ markdown << ""
868
+ markdown << format_todo_list(tool_input['todos'])
869
+ # Special formatting for Write tool to unescape content
870
+ elsif tool_name == 'Write' && tool_input['content']
871
+ markdown << "<summary>#{tool_name}: #{tool_input['file_path']}</summary>"
872
+ markdown << ""
873
+ if tool_input['content'].is_a?(String)
874
+ # Detect language from file extension and common file names
875
+ language = detect_language_from_path(tool_input['file_path'])
876
+
877
+ markdown << "```#{language}"
878
+ # Unescape the JSON string content
879
+ unescaped_content = tool_input['content'].gsub('\\n', "\n").gsub('\\t', "\t").gsub('\\"', '"').gsub('\\\\', '\\')
880
+ markdown << escape_backticks(unescaped_content)
881
+ markdown << "```"
882
+ else
883
+ markdown << "```json"
884
+ markdown << JSON.pretty_generate(tool_input)
885
+ markdown << "```"
886
+ end
887
+ else
888
+ # Default JSON formatting for other tools
889
+ markdown << "<summary>#{tool_name}</summary>"
890
+ markdown << ""
891
+ markdown << "```json"
892
+ markdown << JSON.pretty_generate(tool_input)
893
+ markdown << "```"
894
+ end
895
+
896
+ markdown << "</details>"
897
+
898
+ # Separate collapsed section for tool result if available
899
+ if tool_result
900
+ markdown << ""
901
+ markdown << "<details>"
902
+ markdown << "<summary>Tool Result</summary>"
903
+ markdown << ""
904
+ markdown << "```"
905
+
906
+ result_content = if tool_result['content'].is_a?(String)
907
+ tool_result['content']
908
+ else
909
+ JSON.pretty_generate(tool_result['content'])
910
+ end
911
+ markdown << escape_backticks(result_content)
912
+ markdown << "```"
913
+ markdown << "</details>"
914
+ end
915
+
916
+ markdown.join("\n")
917
+ end
918
+
919
+ def format_todo_list(todos)
920
+ lines = []
921
+
922
+ todos.each do |todo|
923
+ status_emoji = case todo['status']
924
+ when 'completed'
925
+ '✅'
926
+ when 'in_progress'
927
+ '🔄'
928
+ when 'pending'
929
+ '⏳'
930
+ else
931
+ '❓'
932
+ end
933
+
934
+ lines << "#{status_emoji} #{todo['content']} "
935
+ end
936
+
937
+ lines.join("\n")
938
+ end
939
+
940
+
941
+ def system_generated?(content)
942
+ return false unless content.is_a?(String)
943
+
944
+ # Skip tool use content - it's legitimate
945
+ return false if content.start_with?('## 🤖🔧 Assistant')
946
+
947
+ skip_patterns = [
948
+ 'Caveat: The messages below were generated',
949
+ '<command-name>',
950
+ '<local-command-stdout>',
951
+ '<local-command-stderr>',
952
+ '<system-reminder>'
953
+ ]
954
+
955
+ skip_patterns.any? { |pattern| content.include?(pattern) }
956
+ end
957
+
958
+ def generate_filename(session)
959
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
960
+ title = generate_title(session[:messages])
961
+ "#{timestamp}-#{title}-#{session[:session_id]}.md"
962
+ end
963
+
964
+ def generate_combined_filename(sessions)
965
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
966
+
967
+ if sessions.length == 1
968
+ title = generate_title(sessions.first[:messages])
969
+ "#{timestamp}-#{title}-#{sessions.first[:session_id]}.md"
970
+ else
971
+ first_session = sessions.first
972
+ last_session = sessions.last
973
+ title = generate_title(first_session[:messages])
974
+ "#{timestamp}-#{title}-combined-#{sessions.length}-sessions.md"
975
+ end
976
+ end
977
+
978
+ def generate_title(messages)
979
+ first_user_message = messages.find { |m| m[:role] == 'user' }
980
+ return 'untitled' unless first_user_message
981
+
982
+ content = first_user_message[:content]
983
+ title = content.split("\n").first.to_s
984
+ .gsub(/^(drop it\.?|real|actually|honestly)[\s,.]*/i, '')
985
+ .strip
986
+ .split(/[\s\/]+/)
987
+ .first(5)
988
+ .join('-')
989
+ .gsub(/[^a-zA-Z0-9-]/, '')
990
+ .downcase
991
+
992
+ title.empty? ? 'untitled' : title[0, 50]
993
+ end
994
+
995
+ def format_combined_markdown(sessions)
996
+ md = []
997
+ title = get_markdown_title
998
+ md << "# #{title}"
999
+ md << ""
1000
+
1001
+ if sessions.length == 1
1002
+ # Single session - use original format
1003
+ return format_markdown(sessions.first)
1004
+ end
1005
+
1006
+ # Multiple sessions - combined format
1007
+ total_messages = sessions.sum { |s| s[:messages].length }
1008
+ total_user = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'user' } }
1009
+ total_assistant = sessions.sum { |s| s[:messages].count { |m| m[:role] == 'assistant' } }
1010
+
1011
+ md << "**Sessions:** #{sessions.length}"
1012
+ md << "**Total Messages:** #{total_messages} (#{total_user} user, #{total_assistant} assistant)"
1013
+ md << ""
1014
+
1015
+ first_timestamp = sessions.map { |s| s[:first_timestamp] }.compact.min
1016
+ last_timestamp = sessions.map { |s| s[:last_timestamp] }.compact.max
1017
+
1018
+ if first_timestamp
1019
+ md << "**Started:** #{Time.parse(first_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1020
+ end
1021
+
1022
+ if last_timestamp
1023
+ md << "**Last activity:** #{Time.parse(last_timestamp).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1024
+ end
1025
+
1026
+ md << ""
1027
+ md << "---"
1028
+ md << ""
1029
+
1030
+ # Process each session with separators
1031
+ sessions.each_with_index do |session, session_index|
1032
+ unless session_index == 0
1033
+ md << ""
1034
+ md << "---"
1035
+ md << ""
1036
+ md << "# Session #{session_index + 1}"
1037
+ md << ""
1038
+ md << "**Session ID:** `#{session[:session_id]}`"
1039
+
1040
+ if session[:first_timestamp]
1041
+ md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1042
+ end
1043
+
1044
+ user_count = session[:messages].count { |m| m[:role] == 'user' }
1045
+ assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1046
+
1047
+ md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1048
+ md << ""
1049
+ end
1050
+
1051
+ # Add messages for this session
1052
+ session[:messages].each_with_index do |message, index|
1053
+ md.concat(format_message(message, index + 1))
1054
+ md << "" unless index == session[:messages].length - 1
1055
+ end
1056
+ end
1057
+
1058
+ # Replace all absolute project paths with relative paths in the final output
1059
+ final_content = make_paths_relative(md.join("\n"))
1060
+
1061
+ # Scan and redact secrets from the entire final markdown content
1062
+ scan_and_redact_secrets(final_content, "final_markdown")
1063
+ end
1064
+
1065
+ def format_markdown(session)
1066
+ md = []
1067
+ title = get_markdown_title
1068
+ md << "# #{title}"
1069
+ md << ""
1070
+ md << "**Session:** `#{session[:session_id]}`"
1071
+ md << ""
1072
+
1073
+ if session[:first_timestamp]
1074
+ md << "**Started:** #{Time.parse(session[:first_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1075
+ end
1076
+
1077
+ if session[:last_timestamp]
1078
+ md << "**Last activity:** #{Time.parse(session[:last_timestamp]).getlocal.strftime('%B %d, %Y at %I:%M %p')}"
1079
+ end
1080
+
1081
+ user_count = session[:messages].count { |m| m[:role] == 'user' }
1082
+ assistant_count = session[:messages].count { |m| m[:role] == 'assistant' }
1083
+
1084
+ md << "**Messages:** #{session[:messages].length} (#{user_count} user, #{assistant_count} assistant)"
1085
+ md << ""
1086
+ md << "---"
1087
+ md << ""
1088
+
1089
+ # Process messages linearly - they're already processed and paired
1090
+ session[:messages].each_with_index do |message, index|
1091
+ md.concat(format_message(message, index + 1))
1092
+ md << "" unless index == session[:messages].length - 1
1093
+ end
1094
+
1095
+ # Replace all absolute project paths with relative paths in the final output
1096
+ final_content = make_paths_relative(md.join("\n"))
1097
+
1098
+ # Scan and redact secrets from the entire final markdown content
1099
+ scan_and_redact_secrets(final_content, "final_markdown")
1100
+ end
1101
+
1102
+
1103
+ def format_message(message, number)
1104
+ lines = []
1105
+
1106
+ # Check if message content starts with Tool Use heading
1107
+ skip_assistant_heading = message[:role] == 'assistant' &&
1108
+ message[:content].start_with?('## 🤖🔧 Assistant')
1109
+
1110
+ unless skip_assistant_heading
1111
+ # Format role header with optional timestamp
1112
+ role_header = case message[:role]
1113
+ when 'user'
1114
+ "## 👤 User"
1115
+ when 'assistant'
1116
+ "## 🤖 Assistant"
1117
+ when 'assistant_thinking'
1118
+ "## 🤖💭 Assistant"
1119
+ when 'system'
1120
+ "## ⚙️ System"
1121
+ end
1122
+
1123
+ # Add timestamp if requested and available
1124
+ if @show_timestamps && message[:timestamp]
1125
+ begin
1126
+ local_time = Time.parse(message[:timestamp]).getlocal
1127
+ timestamp_str = local_time.strftime('%B %d, %Y at %I:%M:%S %p')
1128
+ role_header += " - #{timestamp_str}"
1129
+ rescue ArgumentError
1130
+ # Skip timestamp if parsing fails
1131
+ end
1132
+ end
1133
+
1134
+ lines << role_header
1135
+
1136
+ # Add message ID as HTML comment if available
1137
+ if message[:message_id]
1138
+ lines << "<!-- #{message[:message_id]} -->"
1139
+ end
1140
+
1141
+ lines << ""
1142
+ end
1143
+
1144
+ lines << message[:content]
1145
+ lines << ""
1146
+
1147
+ lines
1148
+ end
1149
+
1150
+ def make_paths_relative(content)
1151
+ # Replace absolute project paths with relative paths throughout the content
1152
+ content.gsub(@project_path + '/', '')
1153
+ .gsub(@project_path, '.')
1154
+ end
1155
+
1156
+ def extract_leaf_summary(data)
1157
+ # Extract summary from leafUuid JSONL lines
1158
+ if data['leafUuid'] && data['summary']
1159
+ @leaf_summaries << {
1160
+ uuid: data['leafUuid'],
1161
+ summary: data['summary'],
1162
+ timestamp: data['timestamp']
1163
+ }
1164
+ end
1165
+ end
1166
+
1167
+ def get_markdown_title
1168
+ # Use the first leaf summary as the markdown title
1169
+ if @leaf_summaries.any?
1170
+ return @leaf_summaries.first[:summary]
1171
+ end
1172
+
1173
+ # Fallback titles
1174
+ "Claude Code Conversation"
1175
+ end
1176
+
1177
+ end