openclacky 0.7.0 → 0.7.2

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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -0,0 +1,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Message compression functionality for managing conversation history
6
+ # Handles automatic compression when token limits are exceeded
7
+ module MessageCompressorHelper
8
+ # Compression thresholds
9
+ COMPRESSION_THRESHOLD = 150_000 # Trigger compression when exceeding this (in tokens)
10
+ MESSAGE_COUNT_THRESHOLD = 150 # Trigger compression when exceeding this (in message count)
11
+ MAX_RECENT_MESSAGES = 20 # Keep this many recent message pairs intact
12
+ TARGET_COMPRESSED_TOKENS = 10_000 # Target size after compression
13
+ IDLE_COMPRESSION_THRESHOLD = 20_000 # Minimum messages needed for idle compression
14
+
15
+ # Trigger compression during idle time (user-friendly, interruptible)
16
+ # Returns true if compression was performed, false otherwise
17
+ def trigger_idle_compression
18
+ # Check if we should compress (force mode)
19
+ compression_context = compress_messages_if_needed(force: true)
20
+ @ui&.show_info("Idle detected. Compressing conversation to optimize costs...")
21
+ if compression_context.nil?
22
+ @ui&.show_info("Idle skipped.")
23
+ return false
24
+ end
25
+
26
+ # Insert compression message
27
+ @messages << compression_context[:compression_message]
28
+
29
+ begin
30
+ # Execute compression using shared LLM call logic
31
+ response = call_llm
32
+ handle_compression_response(response, compression_context)
33
+ true
34
+ rescue Clacky::AgentInterrupted => e
35
+ @ui&.log("Idle compression canceled: #{e.message}", level: :info)
36
+ # Remove the compression message we added
37
+ @messages.pop if @messages.last == compression_context[:compression_message]
38
+ false
39
+ rescue => e
40
+ @ui&.log("Idle compression failed: #{e.message}", level: :error)
41
+ # Remove the compression message we added
42
+ @messages.pop if @messages.last == compression_context[:compression_message]
43
+ false
44
+ end
45
+ end
46
+
47
+ # Check if compression is needed and return compression context
48
+ # @param force [Boolean] Force compression even if thresholds not met
49
+ # @return [Hash, nil] Compression context or nil if not needed
50
+ def compress_messages_if_needed(force: false)
51
+ # Check if compression is enabled
52
+ return nil unless @config.enable_compression
53
+
54
+ # Calculate total tokens and message count
55
+ total_tokens = total_message_tokens[:total]
56
+ message_count = @messages.length
57
+
58
+ # Force compression (for idle compression) - use lower threshold
59
+ if force
60
+ # Only compress if we have more than MAX_RECENT_MESSAGES + system message
61
+ return nil unless message_count > MAX_RECENT_MESSAGES + 1
62
+ # Also require minimum message count to make compression worthwhile
63
+ return nil unless total_tokens >= IDLE_COMPRESSION_THRESHOLD
64
+ else
65
+ # Normal compression - check thresholds
66
+ # Either: token count exceeds threshold OR message count exceeds threshold
67
+ token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
68
+ message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
69
+
70
+ # Only compress if we exceed at least one threshold
71
+ return nil unless token_threshold_exceeded || message_count_exceeded
72
+ end
73
+
74
+ # Calculate how much we need to reduce
75
+ reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
76
+
77
+ # Don't compress if reduction is minimal (< 10% of current size)
78
+ # Only apply this check when triggered by token threshold (not for force mode)
79
+ if !force && token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
80
+ return nil
81
+ end
82
+
83
+ # If only message count threshold is exceeded, force compression
84
+ # to keep conversation history manageable
85
+
86
+ # Calculate target size for recent messages based on compression level
87
+ target_recent_count = calculate_target_recent_count(reduction_needed)
88
+
89
+ # Increment compression level for progressive summarization
90
+ @compression_level += 1
91
+
92
+ # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
93
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
94
+ recent_messages = [] if recent_messages.nil?
95
+
96
+ # Build compression instruction message (to be inserted into conversation)
97
+ compression_message = @message_compressor.build_compression_message(@messages, recent_messages: recent_messages)
98
+
99
+ return nil if compression_message.nil?
100
+
101
+ # Return compression context for agent to handle
102
+ {
103
+ compression_message: compression_message,
104
+ recent_messages: recent_messages,
105
+ original_token_count: total_tokens,
106
+ original_message_count: @messages.length,
107
+ compression_level: @compression_level
108
+ }
109
+ end
110
+
111
+ # Handle compression response and rebuild message list
112
+ def handle_compression_response(response, compression_context)
113
+ # Extract compressed content from response
114
+ compressed_content = response[:content]
115
+
116
+ # Note: Cost tracking is already handled by call_llm, no need to track again here
117
+
118
+ # Rebuild message list with compression
119
+ # Note: we need to remove the compression instruction message we just added
120
+ original_messages = @messages[0..-2] # All except the last (compression instruction)
121
+
122
+ @messages = @message_compressor.rebuild_with_compression(
123
+ compressed_content,
124
+ original_messages: original_messages,
125
+ recent_messages: compression_context[:recent_messages]
126
+ )
127
+
128
+ # Track this compression
129
+ @compressed_summaries << {
130
+ level: compression_context[:compression_level],
131
+ message_count: compression_context[:original_message_count],
132
+ timestamp: Time.now.iso8601,
133
+ strategy: :insert_then_compress
134
+ }
135
+
136
+ final_tokens = total_message_tokens[:total]
137
+
138
+ # Show compression info
139
+ @ui&.show_info(
140
+ "History compressed (~#{compression_context[:original_token_count]} -> ~#{final_tokens} tokens, " \
141
+ "level #{compression_context[:compression_level]})"
142
+ )
143
+ end
144
+
145
+ # Get recent messages while preserving tool_calls/tool_results pairs
146
+ # This ensures assistant messages with tool_calls are kept together with ALL their tool results
147
+ # @param messages [Array] All messages
148
+ # @param count [Integer] Target number of recent messages to keep
149
+ # @return [Array] Recent messages with complete tool pairs
150
+ def get_recent_messages_with_tool_pairs(messages, count)
151
+ # This method ensures that assistant messages with tool_calls are always kept together
152
+ # with ALL their corresponding tool_results, maintaining the correct order.
153
+ # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
154
+
155
+ return [] if messages.nil? || messages.empty?
156
+
157
+ # Track which messages to include
158
+ messages_to_include = Set.new
159
+
160
+ # Start from the end and work backwards
161
+ i = messages.size - 1
162
+ messages_collected = 0
163
+
164
+ while i >= 0 && messages_collected < count
165
+ msg = messages[i]
166
+
167
+ # Skip if already marked for inclusion
168
+ if messages_to_include.include?(i)
169
+ i -= 1
170
+ next
171
+ end
172
+
173
+ # Mark this message for inclusion
174
+ messages_to_include.add(i)
175
+ messages_collected += 1
176
+
177
+ # If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
178
+ if msg[:role] == "assistant" && msg[:tool_calls]
179
+ tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
180
+
181
+ # Find all tool results that belong to this assistant message
182
+ # They should be in the messages immediately following this assistant message
183
+ j = i + 1
184
+ while j < messages.size
185
+ next_msg = messages[j]
186
+
187
+ # If we find a tool result for one of our tool_calls, include it
188
+ if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
189
+ messages_to_include.add(j)
190
+ elsif next_msg[:role] != "tool"
191
+ # Stop when we hit a non-tool message (start of next turn)
192
+ break
193
+ end
194
+
195
+ j += 1
196
+ end
197
+ end
198
+
199
+ # If this is a tool result, make sure its assistant message is also included
200
+ if msg[:role] == "tool"
201
+ # Find the corresponding assistant message
202
+ j = i - 1
203
+ while j >= 0
204
+ prev_msg = messages[j]
205
+ if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
206
+ # Check if this assistant has the matching tool_call
207
+ has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
208
+ if has_matching_call
209
+ unless messages_to_include.include?(j)
210
+ messages_to_include.add(j)
211
+ messages_collected += 1
212
+ end
213
+
214
+ # Also include all other tool results for this assistant message
215
+ tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
216
+ k = j + 1
217
+ while k < messages.size
218
+ result_msg = messages[k]
219
+ if result_msg[:role] == "tool" && tool_call_ids.include?(result_msg[:tool_call_id])
220
+ messages_to_include.add(k)
221
+ elsif result_msg[:role] != "tool"
222
+ break
223
+ end
224
+ k += 1
225
+ end
226
+
227
+ break
228
+ end
229
+ end
230
+ j -= 1
231
+ end
232
+ end
233
+
234
+ i -= 1
235
+ end
236
+
237
+ # Extract the messages in their original order
238
+ recent_messages = messages_to_include.to_a.sort.map { |idx| messages[idx] }
239
+
240
+ # Truncate large tool results to prevent token bloat
241
+ recent_messages.map do |msg|
242
+ if msg[:role] == "tool" && msg[:content].is_a?(String) && msg[:content].length > 2000
243
+ msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
244
+ else
245
+ msg
246
+ end
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ # Calculate how many recent messages to keep based on how much we need to compress
253
+ def calculate_target_recent_count(reduction_needed)
254
+ # We want recent messages to be around 20-30% of the total target
255
+ # This keeps the context window useful without being too large
256
+ tokens_per_message = 500 # Average estimate for a message with content
257
+
258
+ # Target recent messages budget (~20% of target compressed size)
259
+ recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
260
+ target_messages = (recent_budget / tokens_per_message).to_i
261
+
262
+ # Clamp to reasonable bounds
263
+ [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
264
+ end
265
+
266
+ # Generate hierarchical summary based on compression level
267
+ # Level 1: Detailed summary with files, decisions, features
268
+ # Level 2: Concise summary with key items
269
+ # Level 3: Minimal summary (just project type)
270
+ # Level 4+: Ultra-minimal (single line)
271
+ def generate_hierarchical_summary(messages)
272
+ level = @compression_level
273
+
274
+ # Extract key information from messages
275
+ extracted = extract_key_information(messages)
276
+
277
+ summary_text = case level
278
+ when 1
279
+ generate_level1_summary(extracted)
280
+ when 2
281
+ generate_level2_summary(extracted)
282
+ when 3
283
+ generate_level3_summary(extracted)
284
+ else
285
+ generate_level4_summary(extracted)
286
+ end
287
+
288
+ {
289
+ role: "user",
290
+ content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
291
+ system_injected: true,
292
+ compression_level: level
293
+ }
294
+ end
295
+
296
+ # Extract key information from messages for summarization
297
+ def extract_key_information(messages)
298
+ return empty_extraction_data if messages.nil?
299
+
300
+ {
301
+ # Message counts
302
+ user_msgs: messages.count { |m| m[:role] == "user" },
303
+ assistant_msgs: messages.count { |m| m[:role] == "assistant" },
304
+ tool_msgs: messages.count { |m| m[:role] == "tool" },
305
+
306
+ # Tools used
307
+ tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
308
+
309
+ # Files created/modified
310
+ files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
311
+ files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
312
+
313
+ # Key decisions (limit to first 5)
314
+ decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
315
+
316
+ # Completed tasks (from TODO results)
317
+ completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
318
+
319
+ # Current in-progress work
320
+ in_progress: find_in_progress(messages),
321
+
322
+ # Key results from shell commands
323
+ shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
324
+ }
325
+ end
326
+
327
+ # Helper: safely extract from messages with proper nil handling
328
+ def extract_from_messages(messages, role_filter = nil, &block)
329
+ return [] if messages.nil?
330
+
331
+ results = messages
332
+ .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
333
+ .map(&block)
334
+ .compact
335
+
336
+ # Flatten if we have nested arrays (from methods returning arrays of items)
337
+ results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
338
+ end
339
+
340
+ # Helper: extract tool names from tool_calls
341
+ def extract_tool_names(tool_calls)
342
+ return [] unless tool_calls.is_a?(Array)
343
+ tool_calls.map { |tc| tc.dig(:function, :name) }
344
+ end
345
+
346
+ # Helper: filter write results by action
347
+ def filter_write_results(result, action)
348
+ result && result[:action] == action ? result[:file] : nil
349
+ end
350
+
351
+ # Helper: filter todo results by status
352
+ def filter_todo_results(result, status)
353
+ result && result[:status] == status ? result[:task] : nil
354
+ end
355
+
356
+ # Helper: extract decision text from content (returns array of decisions or empty array)
357
+ def extract_decision_text(content)
358
+ return [] unless content.is_a?(String)
359
+ return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
360
+
361
+ sentences = content.split(/[.!?]/).select do |s|
362
+ s.include?("decision") || s.include?("chose") || s.include?("using") ||
363
+ s.include?("decided") || s.include?("will use") || s.include?("selected")
364
+ end
365
+ sentences.map(&:strip).map { |s| s[0..100] }
366
+ end
367
+
368
+ # Helper: find in-progress task
369
+ def find_in_progress(messages)
370
+ return nil if messages.nil?
371
+
372
+ messages.reverse_each do |m|
373
+ if m[:role] == "tool"
374
+ content = m[:content].to_s
375
+ if content.include?("in progress") || content.include?("working on")
376
+ return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
377
+ end
378
+ end
379
+ end
380
+ nil
381
+ end
382
+
383
+ # Helper: empty extraction data
384
+ def empty_extraction_data
385
+ {
386
+ user_msgs: 0,
387
+ assistant_msgs: 0,
388
+ tool_msgs: 0,
389
+ tools_used: [],
390
+ files_created: [],
391
+ files_modified: [],
392
+ decisions: [],
393
+ completed_tasks: [],
394
+ in_progress: nil,
395
+ shell_results: []
396
+ }
397
+ end
398
+
399
+ def parse_write_result(content)
400
+ return nil unless content.is_a?(String)
401
+
402
+ # Check for "Created: path" or "Updated: path" patterns
403
+ if content.include?("Created:")
404
+ { action: :created, file: content[/Created:\s*(.+)/, 1]&.strip }
405
+ elsif content.include?("Updated:") || content.include?("modified")
406
+ { action: :modified, file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
407
+ else
408
+ nil
409
+ end
410
+ end
411
+
412
+ def parse_todo_result(content)
413
+ return nil unless content.is_a?(String)
414
+
415
+ if content.include?("completed")
416
+ { status: :completed, task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
417
+ elsif content.include?("added")
418
+ { status: :added, task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
419
+ else
420
+ nil
421
+ end
422
+ end
423
+
424
+ def parse_shell_result(content)
425
+ return nil unless content.is_a?(String)
426
+
427
+ if content.include?("passed") || content.include?("success")
428
+ "tests passed"
429
+ elsif content.include?("failed") || content.include?("error")
430
+ "command failed"
431
+ elsif content =~ /bundle install|npm install|go mod download/
432
+ "dependencies installed"
433
+ elsif content.include?("Installed")
434
+ content[/Installed:\s*(.+)/, 1]&.strip
435
+ else
436
+ nil
437
+ end
438
+ end
439
+
440
+ # Level 1: Detailed summary (for first compression)
441
+ def generate_level1_summary(data)
442
+ parts = []
443
+
444
+ parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
445
+
446
+ # Files created
447
+ if data[:files_created].any?
448
+ files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
449
+ parts << "Created: #{files_list}"
450
+ end
451
+
452
+ # Files modified
453
+ if data[:files_modified].any?
454
+ files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
455
+ parts << "Modified: #{files_list}"
456
+ end
457
+
458
+ # Completed tasks
459
+ if data[:completed_tasks].any?
460
+ tasks_list = data[:completed_tasks].first(3).join(", ")
461
+ parts << "Completed: #{tasks_list}"
462
+ end
463
+
464
+ # In progress
465
+ if data[:in_progress]
466
+ parts << "In Progress: #{data[:in_progress]}"
467
+ end
468
+
469
+ # Key decisions
470
+ if data[:decisions].any?
471
+ decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
472
+ parts << "Decisions: #{decisions_text}"
473
+ end
474
+
475
+ # Tools used
476
+ if data[:tools_used].any?
477
+ parts << "Tools: #{data[:tools_used].join(', ')}"
478
+ end
479
+
480
+ parts << "Continuing with recent conversation..."
481
+ parts.join("\n")
482
+ end
483
+
484
+ # Level 2: Concise summary (for second compression)
485
+ def generate_level2_summary(data)
486
+ parts = []
487
+
488
+ parts << "Conversation summary:"
489
+
490
+ # Key files (limit to most important)
491
+ all_files = (data[:files_created] + data[:files_modified]).uniq
492
+ if all_files.any?
493
+ key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
494
+ parts << "Files: #{key_files}"
495
+ end
496
+
497
+ # Key accomplishments
498
+ accomplishments = []
499
+ accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
500
+ accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
501
+ accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
502
+
503
+ parts << accomplishments.join(", ") if accomplishments.any?
504
+
505
+ parts << "Recent context follows..."
506
+ parts.join("\n")
507
+ end
508
+
509
+ # Level 3: Minimal summary (for third compression)
510
+ def generate_level3_summary(data)
511
+ parts = []
512
+
513
+ parts << "Project progress:"
514
+
515
+ # Just counts and key items
516
+ all_files = (data[:files_created] + data[:files_modified]).uniq
517
+ parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
518
+
519
+ if data[:in_progress]
520
+ parts << "Currently: #{data[:in_progress]}"
521
+ end
522
+
523
+ parts << "See recent messages for details."
524
+ parts.join("\n")
525
+ end
526
+
527
+ # Level 4: Ultra-minimal summary (for fourth+ compression)
528
+ def generate_level4_summary(data)
529
+ all_files = (data[:files_created] + data[:files_modified]).uniq
530
+ "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
531
+ end
532
+ end
533
+ end
534
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Session serialization for saving and restoring agent state
6
+ # Handles session data serialization and deserialization
7
+ module SessionSerializer
8
+ # Restore from a saved session
9
+ # @param session_data [Hash] Saved session data
10
+ def restore_session(session_data)
11
+ @session_id = session_data[:session_id]
12
+ @messages = session_data[:messages]
13
+ @todos = session_data[:todos] || [] # Restore todos from session
14
+ @iterations = session_data.dig(:stats, :total_iterations) || 0
15
+ @total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
16
+ @working_dir = session_data[:working_dir]
17
+ @created_at = session_data[:created_at]
18
+ @total_tasks = session_data.dig(:stats, :total_tasks) || 0
19
+
20
+ # Restore cache statistics if available
21
+ @cache_stats = session_data.dig(:stats, :cache_stats) || {
22
+ cache_creation_input_tokens: 0,
23
+ cache_read_input_tokens: 0,
24
+ total_requests: 0,
25
+ cache_hit_requests: 0
26
+ }
27
+
28
+ # Restore previous_total_tokens for accurate delta calculation across sessions
29
+ @previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
30
+
31
+ # Restore Time Machine state
32
+ @task_parents = session_data.dig(:time_machine, :task_parents) || {}
33
+ @current_task_id = session_data.dig(:time_machine, :current_task_id) || 0
34
+ @active_task_id = session_data.dig(:time_machine, :active_task_id) || 0
35
+
36
+ # Check if the session ended with an error
37
+ last_status = session_data.dig(:stats, :last_status)
38
+ last_error = session_data.dig(:stats, :last_error)
39
+
40
+ if last_status == "error" && last_error
41
+ # Find and remove the last user message that caused the error
42
+ # This allows the user to retry with a different prompt
43
+ last_user_index = @messages.rindex { |m| m[:role] == "user" }
44
+ if last_user_index
45
+ @messages = @messages[0...last_user_index]
46
+
47
+ # Trigger a hook to notify about the rollback
48
+ @hooks.trigger(:session_rollback, {
49
+ reason: "Previous session ended with error",
50
+ error_message: last_error,
51
+ rolled_back_message_index: last_user_index
52
+ })
53
+ end
54
+ end
55
+ end
56
+
57
+ # Generate session data for saving
58
+ # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
59
+ # @param error_message [String] Error message if status is :error
60
+ # @return [Hash] Session data ready for serialization
61
+ def to_session_data(status: :success, error_message: nil)
62
+ # Get last real user message for preview (skip compressed system messages)
63
+ last_user_msg = @messages.reverse.find do |m|
64
+ m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
65
+ end
66
+
67
+ # Extract preview text from last user message
68
+ last_message_preview = if last_user_msg
69
+ content = last_user_msg[:content]
70
+ if content.is_a?(String)
71
+ # Truncate to 100 characters for preview
72
+ content.length > 100 ? "#{content[0..100]}..." : content
73
+ else
74
+ "User message (non-string content)"
75
+ end
76
+ else
77
+ "No messages"
78
+ end
79
+
80
+ stats_data = {
81
+ total_tasks: @total_tasks,
82
+ total_iterations: @iterations,
83
+ total_cost_usd: @total_cost.round(4),
84
+ duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
85
+ last_status: status.to_s,
86
+ cache_stats: @cache_stats,
87
+ debug_logs: @debug_logs,
88
+ previous_total_tokens: @previous_total_tokens
89
+ }
90
+
91
+ # Add error message if status is error
92
+ stats_data[:last_error] = error_message if status == :error && error_message
93
+
94
+ {
95
+ session_id: @session_id,
96
+ created_at: @created_at,
97
+ updated_at: Time.now.iso8601,
98
+ working_dir: @working_dir,
99
+ todos: @todos, # Include todos in session data
100
+ time_machine: { # Include Time Machine state
101
+ task_parents: @task_parents || {},
102
+ current_task_id: @current_task_id || 0,
103
+ active_task_id: @active_task_id || 0
104
+ },
105
+ config: {
106
+ models: @config.models,
107
+ permission_mode: @config.permission_mode.to_s,
108
+ enable_compression: @config.enable_compression,
109
+ enable_prompt_caching: @config.enable_prompt_caching,
110
+ max_tokens: @config.max_tokens,
111
+ verbose: @config.verbose
112
+ },
113
+ stats: stats_data,
114
+ messages: @messages,
115
+ last_user_message: last_message_preview
116
+ }
117
+ end
118
+
119
+ # Get recent user messages from conversation history
120
+ # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
121
+ # @return [Array<String>] Array of recent user message contents
122
+ def get_recent_user_messages(limit: 5)
123
+ # Filter messages to only include real user messages (exclude system-injected ones)
124
+ user_messages = @messages.select do |m|
125
+ m[:role] == "user" && !m[:system_injected]
126
+ end
127
+
128
+ # Extract text content from the last N user messages
129
+ user_messages.last(limit).map do |msg|
130
+ extract_text_from_content(msg[:content])
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ # Extract text from message content (handles string and array formats)
137
+ # @param content [String, Array, Object] Message content
138
+ # @return [String] Extracted text
139
+ def extract_text_from_content(content)
140
+ if content.is_a?(String)
141
+ content
142
+ elsif content.is_a?(Array)
143
+ # Extract text from content array (may contain text and images)
144
+ text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
145
+ text_parts.map { |c| c[:text] }.join("\n")
146
+ else
147
+ content.to_s
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end