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.
- checksums.yaml +7 -0
- data/.gitignore +7 -0
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +156 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +36 -0
- data/LICENSE +21 -0
- data/README.md +534 -0
- data/Rakefile +8 -0
- data/bin/ccexport +95 -0
- data/ccexport.gemspec +49 -0
- data/exe/ccexport +156 -0
- data/lib/assets/prism-bash.js +1 -0
- data/lib/assets/prism-json.js +1 -0
- data/lib/assets/prism-markdown.js +1 -0
- data/lib/assets/prism-python.js +1 -0
- data/lib/assets/prism-typescript.js +1 -0
- data/lib/assets/prism-yaml.js +1 -0
- data/lib/assets/prism.css +1 -0
- data/lib/assets/prism.js +16 -0
- data/lib/ccexport/version.rb +5 -0
- data/lib/ccexport.rb +9 -0
- data/lib/claude_conversation_exporter.rb +1177 -0
- data/lib/markdown_code_block_parser.rb +268 -0
- data/lib/secret_detector.rb +89 -0
- data/lib/templates/default.html.erb +241 -0
- data/lib/templates/github.html.erb +369 -0
- data/lib/templates/solarized.html.erb +467 -0
- metadata +101 -0
|
@@ -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('&', '&')
|
|
742
|
+
.gsub('<', '<')
|
|
743
|
+
.gsub('>', '>')
|
|
744
|
+
.gsub('"', '"')
|
|
745
|
+
.gsub("'", ''')
|
|
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
|