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.
- checksums.yaml +4 -4
- data/.clacky/skills/commit/SKILL.md +29 -4
- data/.clackyrules +3 -1
- data/CHANGELOG.md +103 -2
- data/README.md +70 -161
- data/bin/clarky +11 -0
- data/docs/HOW-TO-USE-CN.md +96 -0
- data/docs/HOW-TO-USE.md +94 -0
- data/docs/config.example.yml +27 -0
- data/docs/deploy_subagent_design.md +540 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/why-openclacky.md +0 -1
- data/lib/clacky/agent/cost_tracker.rb +180 -0
- data/lib/clacky/agent/llm_caller.rb +54 -0
- data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
- data/lib/clacky/agent/message_compressor_helper.rb +534 -0
- data/lib/clacky/agent/session_serializer.rb +152 -0
- data/lib/clacky/agent/skill_manager.rb +138 -0
- data/lib/clacky/agent/system_prompt_builder.rb +96 -0
- data/lib/clacky/agent/time_machine.rb +199 -0
- data/lib/clacky/agent/tool_executor.rb +434 -0
- data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
- data/lib/clacky/agent.rb +260 -1370
- data/lib/clacky/agent_config.rb +447 -10
- data/lib/clacky/cli.rb +275 -98
- data/lib/clacky/client.rb +12 -2
- data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
- data/lib/clacky/default_skills/new/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +195 -0
- data/lib/clacky/providers.rb +107 -0
- data/lib/clacky/skill.rb +48 -7
- data/lib/clacky/skill_loader.rb +7 -0
- data/lib/clacky/tools/edit.rb +105 -48
- data/lib/clacky/tools/file_reader.rb +44 -73
- data/lib/clacky/tools/invoke_skill.rb +89 -0
- data/lib/clacky/tools/list_tasks.rb +54 -0
- data/lib/clacky/tools/redo_task.rb +41 -0
- data/lib/clacky/tools/safe_shell.rb +1 -1
- data/lib/clacky/tools/shell.rb +74 -62
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/undo_task.rb +32 -0
- data/lib/clacky/tools/web_fetch.rb +2 -1
- data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
- data/lib/clacky/ui2/components/inline_input.rb +23 -2
- data/lib/clacky/ui2/components/input_area.rb +65 -21
- data/lib/clacky/ui2/components/modal_component.rb +199 -62
- data/lib/clacky/ui2/layout_manager.rb +75 -25
- data/lib/clacky/ui2/line_editor.rb +23 -2
- data/lib/clacky/ui2/markdown_renderer.rb +31 -10
- data/lib/clacky/ui2/screen_buffer.rb +2 -0
- data/lib/clacky/ui2/ui_controller.rb +316 -37
- data/lib/clacky/ui2.rb +2 -0
- data/lib/clacky/ui_interface.rb +50 -0
- data/lib/clacky/utils/arguments_parser.rb +31 -3
- data/lib/clacky/utils/file_processor.rb +13 -18
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +19 -9
- data/scripts/install.sh +274 -97
- data/scripts/uninstall.sh +12 -12
- metadata +40 -13
- data/.clacky/skills/test-skill/SKILL.md +0 -15
- data/lib/clacky/compression/base.rb +0 -231
- data/lib/clacky/compression/standard.rb +0 -339
- data/lib/clacky/config.rb +0 -117
- /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
- /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
- /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
- /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
- /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
- /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
|