openclacky 0.9.4 → 0.9.5
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/CHANGELOG.md +20 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +8 -3
- data/lib/clacky/agent/memory_updater.rb +3 -3
- data/lib/clacky/agent/message_compressor_helper.rb +14 -12
- data/lib/clacky/agent/session_serializer.rb +8 -22
- data/lib/clacky/agent/skill_manager.rb +27 -20
- data/lib/clacky/agent/time_machine.rb +7 -7
- data/lib/clacky/agent/tool_executor.rb +10 -5
- data/lib/clacky/agent.rb +59 -65
- data/lib/clacky/brand_config.rb +6 -4
- data/lib/clacky/message_history.rb +181 -0
- data/lib/clacky/server/http_server.rb +1 -1
- data/lib/clacky/skill.rb +1 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +43 -7
- data/lib/clacky/web/brand.js +30 -31
- data/lib/clacky/web/i18n.js +4 -0
- data/lib/clacky/web/settings.js +11 -2
- data/lib/clacky/web/skills.js +13 -0
- data/lib/clacky/web/version.js +16 -4
- data/lib/clacky.rb +1 -0
- metadata +2 -2
- data/lib/clacky/default_skills/activate-license/SKILL.md +0 -118
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a50886ecfabfb60ea86a139a0180fc64803d1853d49aee14b201aa4e1d14a907
|
|
4
|
+
data.tar.gz: 7979255d8dc2113189a5934081a8b5cd24cce0e84aad9ab3deda97116924c6a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0882ad06699e96e87581066d2be75755ccc0b82bd2bf1997fad7155f493733015c8abe5fb857391796157173c1408b9d011d6cc731b18123890cd33c2c9f34e4'
|
|
7
|
+
data.tar.gz: 78e98487a191ba8014fb1d6b84d518bd1949b3724e90ab49523919bbbf0a17cfd1596b00ddf7576727fe96cf5e2d6783a17855c9be940fd5d9f9b7ea6304ee96
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.5] - 2026-03-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **License activation now navigates directly to Brand Skills tab**: after entering a valid license key, the UI automatically opens the Brand Skills settings tab — no extra steps needed to find and load your skills
|
|
14
|
+
- **Version badge always clickable**: clicking the version number in the sidebar now always works regardless of update state; when already on the latest version, a small "up to date" popover appears and auto-dismisses
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
- **MessageHistory domain object**: agent message handling is now encapsulated in a dedicated `MessageHistory` class, making the codebase cleaner and message operations (compression, caching, transient marking) more reliable and testable
|
|
18
|
+
- **Brand skill isolation via transient message marking**: brand skill subagent calls no longer spin up a separate isolated agent; instead, messages are marked as transient and stripped after the call — simpler architecture with the same isolation guarantees
|
|
19
|
+
- **License activation flow simplified**: the `activate-license` skill is replaced with direct in-UI navigation and settings highlighting, reducing round-trips and making activation feel more native
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Tilde (`~`) in file paths now expanded correctly**: tool preview checks now expand `~` to the home directory before checking file existence, so paths like `~/Documents/file.txt` no longer falsely report as missing
|
|
23
|
+
- **Subagent with empty arguments no longer crashes**: when a skill invocation passes empty arguments, a safe placeholder message is used instead of raising an error
|
|
24
|
+
- **Version popover shows "up to date" state**: clicking the version badge when already on the latest version now shows a friendly confirmation instead of silently falling through to open the settings panel
|
|
25
|
+
|
|
26
|
+
### More
|
|
27
|
+
- Simplify error messages in brand config decryption
|
|
28
|
+
- Update test matchers to match simplified error messages
|
|
29
|
+
|
|
10
30
|
## [0.9.4] - 2026-03-16
|
|
11
31
|
|
|
12
32
|
### Fixed
|
|
@@ -19,9 +19,14 @@ module Clacky
|
|
|
19
19
|
retries = 0
|
|
20
20
|
|
|
21
21
|
begin
|
|
22
|
-
# Use active_messages
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
# Use active_messages (Time Machine) when undone, otherwise send full history.
|
|
23
|
+
# to_api strips internal fields and handles orphaned tool_calls.
|
|
24
|
+
messages_to_send = if respond_to?(:active_messages)
|
|
25
|
+
active_messages
|
|
26
|
+
else
|
|
27
|
+
@history.to_api
|
|
28
|
+
end
|
|
29
|
+
|
|
25
30
|
response = @client.send_messages_with_tools(
|
|
26
31
|
messages_to_send,
|
|
27
32
|
model: current_model,
|
|
@@ -44,12 +44,12 @@ module Clacky
|
|
|
44
44
|
@memory_updating = true
|
|
45
45
|
@ui&.show_progress("Updating long-term memory…")
|
|
46
46
|
|
|
47
|
-
@
|
|
47
|
+
@history.append({
|
|
48
48
|
role: "user",
|
|
49
49
|
content: build_memory_update_prompt,
|
|
50
50
|
system_injected: true,
|
|
51
51
|
memory_update: true
|
|
52
|
-
}
|
|
52
|
+
})
|
|
53
53
|
|
|
54
54
|
true
|
|
55
55
|
end
|
|
@@ -59,7 +59,7 @@ module Clacky
|
|
|
59
59
|
def cleanup_memory_messages
|
|
60
60
|
return unless @memory_prompt_injected
|
|
61
61
|
|
|
62
|
-
@
|
|
62
|
+
@history.delete_where { |m| m[:memory_update] }
|
|
63
63
|
@memory_prompt_injected = false
|
|
64
64
|
@memory_updating = false
|
|
65
65
|
@ui&.clear_progress
|
|
@@ -24,7 +24,8 @@ module Clacky
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Insert compression message
|
|
27
|
-
|
|
27
|
+
compression_message = compression_context[:compression_message]
|
|
28
|
+
@history.append(compression_message)
|
|
28
29
|
|
|
29
30
|
begin
|
|
30
31
|
# Execute compression using shared LLM call logic
|
|
@@ -33,13 +34,13 @@ module Clacky
|
|
|
33
34
|
true
|
|
34
35
|
rescue Clacky::AgentInterrupted => e
|
|
35
36
|
@ui&.log("Idle compression canceled: #{e.message}", level: :info)
|
|
36
|
-
|
|
37
|
-
@
|
|
37
|
+
@history.pop_while { |m| m[:system_injected] && !m.equal?(compression_message) }
|
|
38
|
+
@history.pop_last if @history.to_a.last&.equal?(compression_message)
|
|
38
39
|
false
|
|
39
40
|
rescue => e
|
|
40
41
|
@ui&.log("Idle compression failed: #{e.message}", level: :error)
|
|
41
|
-
|
|
42
|
-
@
|
|
42
|
+
@history.pop_while { |m| m[:system_injected] && !m.equal?(compression_message) }
|
|
43
|
+
@history.pop_last if @history.to_a.last&.equal?(compression_message)
|
|
43
44
|
false
|
|
44
45
|
end
|
|
45
46
|
end
|
|
@@ -53,7 +54,7 @@ module Clacky
|
|
|
53
54
|
|
|
54
55
|
# Calculate total tokens and message count
|
|
55
56
|
total_tokens = total_message_tokens[:total]
|
|
56
|
-
message_count = @
|
|
57
|
+
message_count = @history.size
|
|
57
58
|
|
|
58
59
|
# Force compression (for idle compression) - use lower threshold
|
|
59
60
|
if force
|
|
@@ -90,11 +91,12 @@ module Clacky
|
|
|
90
91
|
@compression_level += 1
|
|
91
92
|
|
|
92
93
|
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
93
|
-
|
|
94
|
+
all_messages = @history.to_a
|
|
95
|
+
recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
|
|
94
96
|
recent_messages = [] if recent_messages.nil?
|
|
95
97
|
|
|
96
98
|
# Build compression instruction message (to be inserted into conversation)
|
|
97
|
-
compression_message = @message_compressor.build_compression_message(
|
|
99
|
+
compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
|
|
98
100
|
|
|
99
101
|
return nil if compression_message.nil?
|
|
100
102
|
|
|
@@ -103,7 +105,7 @@ module Clacky
|
|
|
103
105
|
compression_message: compression_message,
|
|
104
106
|
recent_messages: recent_messages,
|
|
105
107
|
original_token_count: total_tokens,
|
|
106
|
-
original_message_count: @
|
|
108
|
+
original_message_count: @history.size,
|
|
107
109
|
compression_level: @compression_level
|
|
108
110
|
}
|
|
109
111
|
end
|
|
@@ -117,7 +119,7 @@ module Clacky
|
|
|
117
119
|
|
|
118
120
|
# Rebuild message list with compression
|
|
119
121
|
# Note: we need to remove the compression instruction message we just added
|
|
120
|
-
original_messages = @
|
|
122
|
+
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
121
123
|
|
|
122
124
|
# Archive compressed messages to a chunk MD file before discarding them
|
|
123
125
|
chunk_index = @compressed_summaries.size + 1
|
|
@@ -128,12 +130,12 @@ module Clacky
|
|
|
128
130
|
compression_level: compression_context[:compression_level]
|
|
129
131
|
)
|
|
130
132
|
|
|
131
|
-
@
|
|
133
|
+
@history.replace_all(@message_compressor.rebuild_with_compression(
|
|
132
134
|
compressed_content,
|
|
133
135
|
original_messages: original_messages,
|
|
134
136
|
recent_messages: compression_context[:recent_messages],
|
|
135
137
|
chunk_path: chunk_path
|
|
136
|
-
)
|
|
138
|
+
))
|
|
137
139
|
|
|
138
140
|
# Track this compression
|
|
139
141
|
@compressed_summaries << {
|
|
@@ -10,7 +10,7 @@ module Clacky
|
|
|
10
10
|
def restore_session(session_data)
|
|
11
11
|
@session_id = session_data[:session_id]
|
|
12
12
|
@name = session_data[:name] || ""
|
|
13
|
-
@
|
|
13
|
+
@history = MessageHistory.new(session_data[:messages] || [])
|
|
14
14
|
@todos = session_data[:todos] || [] # Restore todos from session
|
|
15
15
|
@iterations = session_data.dig(:stats, :total_iterations) || 0
|
|
16
16
|
@total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
|
|
@@ -39,13 +39,11 @@ module Clacky
|
|
|
39
39
|
last_error = session_data.dig(:stats, :last_error)
|
|
40
40
|
|
|
41
41
|
if last_status == "error" && last_error
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
last_user_index = @messages.rindex { |m| m[:role] == "user" }
|
|
42
|
+
# Trim back to just before the last real user message that caused the error
|
|
43
|
+
last_user_index = @history.last_real_user_index
|
|
45
44
|
if last_user_index
|
|
46
|
-
@
|
|
45
|
+
@history.truncate_from(last_user_index)
|
|
47
46
|
|
|
48
|
-
# Trigger a hook to notify about the rollback
|
|
49
47
|
@hooks.trigger(:session_rollback, {
|
|
50
48
|
reason: "Previous session ended with error",
|
|
51
49
|
error_message: last_error,
|
|
@@ -100,7 +98,7 @@ module Clacky
|
|
|
100
98
|
verbose: @config.verbose
|
|
101
99
|
},
|
|
102
100
|
stats: stats_data,
|
|
103
|
-
messages: @
|
|
101
|
+
messages: @history.to_a
|
|
104
102
|
}
|
|
105
103
|
end
|
|
106
104
|
|
|
@@ -108,13 +106,7 @@ module Clacky
|
|
|
108
106
|
# @param limit [Integer] Number of recent user messages to retrieve (default: 5)
|
|
109
107
|
# @return [Array<String>] Array of recent user message contents
|
|
110
108
|
def get_recent_user_messages(limit: 5)
|
|
111
|
-
|
|
112
|
-
user_messages = @messages.select do |m|
|
|
113
|
-
m[:role] == "user" && !m[:system_injected]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Extract text content from the last N user messages
|
|
117
|
-
user_messages.last(limit).map do |msg|
|
|
109
|
+
@history.real_user_messages.last(limit).map do |msg|
|
|
118
110
|
extract_text_from_content(msg[:content])
|
|
119
111
|
end
|
|
120
112
|
end
|
|
@@ -133,7 +125,7 @@ module Clacky
|
|
|
133
125
|
rounds = []
|
|
134
126
|
current_round = nil
|
|
135
127
|
|
|
136
|
-
@
|
|
128
|
+
@history.to_a.each do |msg|
|
|
137
129
|
role = msg[:role].to_s
|
|
138
130
|
|
|
139
131
|
# A real user message can have either a String content or an Array content
|
|
@@ -237,13 +229,7 @@ module Clacky
|
|
|
237
229
|
@skill_loader.load_all
|
|
238
230
|
|
|
239
231
|
fresh_prompt = build_system_prompt
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if system_index
|
|
243
|
-
@messages[system_index] = { role: "system", content: fresh_prompt }
|
|
244
|
-
else
|
|
245
|
-
@messages.unshift({ role: "system", content: fresh_prompt })
|
|
246
|
-
end
|
|
232
|
+
@history.replace_system_prompt(fresh_prompt)
|
|
247
233
|
rescue StandardError => e
|
|
248
234
|
# Log and continue — a stale system prompt is better than a broken restore
|
|
249
235
|
Clacky::Logger.warn("refresh_system_prompt failed during session restore: #{e.message}")
|
|
@@ -155,9 +155,8 @@ module Clacky
|
|
|
155
155
|
skill = parsed[:skill]
|
|
156
156
|
arguments = parsed[:arguments]
|
|
157
157
|
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
if skill.encrypted? || skill.fork_agent?
|
|
158
|
+
# fork_agent skills still run in an isolated subagent.
|
|
159
|
+
if skill.fork_agent?
|
|
161
160
|
execute_skill_with_subagent(skill, arguments)
|
|
162
161
|
return
|
|
163
162
|
end
|
|
@@ -173,23 +172,31 @@ module Clacky
|
|
|
173
172
|
# real LLM call would find an assistant message at the tail of the history,
|
|
174
173
|
# causing a 400 "invalid message order" error.
|
|
175
174
|
#
|
|
175
|
+
# For encrypted (brand) skills, both injected messages are marked transient: true
|
|
176
|
+
# so they are excluded from session.json serialization. The LLM sees the content
|
|
177
|
+
# during the current session, but it is never persisted to disk.
|
|
178
|
+
#
|
|
176
179
|
# Final message order:
|
|
177
180
|
# user: "/skill-name [args]" ← real user input
|
|
178
181
|
# assistant: "[expanded skill content]" ← system_injected (skill instructions)
|
|
179
182
|
# user: "[SYSTEM] Please proceed..." ← system_injected (Claude compat shim)
|
|
180
|
-
|
|
183
|
+
transient = skill.encrypted?
|
|
184
|
+
|
|
185
|
+
@history.append({
|
|
181
186
|
role: "assistant",
|
|
182
187
|
content: expanded_content,
|
|
183
188
|
task_id: task_id,
|
|
184
|
-
system_injected: true
|
|
185
|
-
|
|
189
|
+
system_injected: true,
|
|
190
|
+
transient: transient
|
|
191
|
+
})
|
|
186
192
|
|
|
187
|
-
@
|
|
193
|
+
@history.append({
|
|
188
194
|
role: "user",
|
|
189
195
|
content: "[SYSTEM] The skill instructions above have been loaded. Please proceed to execute the task now.",
|
|
190
196
|
task_id: task_id,
|
|
191
|
-
system_injected: true
|
|
192
|
-
|
|
197
|
+
system_injected: true,
|
|
198
|
+
transient: transient
|
|
199
|
+
})
|
|
193
200
|
|
|
194
201
|
@ui&.show_info("Injected skill content for /#{skill.identifier}")
|
|
195
202
|
end
|
|
@@ -293,21 +300,21 @@ module Clacky
|
|
|
293
300
|
system_prompt_suffix: skill_instructions
|
|
294
301
|
)
|
|
295
302
|
|
|
296
|
-
# Run subagent with the actual task as the sole user turn
|
|
297
|
-
|
|
303
|
+
# Run subagent with the actual task as the sole user turn.
|
|
304
|
+
# If the user typed the skill command with no arguments (e.g. "/jade-appraisal"),
|
|
305
|
+
# use a generic trigger phrase so the user message is never empty.
|
|
306
|
+
task_input = arguments.to_s.strip.empty? ? "Please proceed." : arguments
|
|
307
|
+
result = subagent.run(task_input)
|
|
298
308
|
|
|
299
309
|
# Generate summary
|
|
300
310
|
summary = generate_subagent_summary(subagent)
|
|
301
311
|
|
|
302
|
-
#
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
instruction_msg.delete(:subagent_instructions)
|
|
309
|
-
instruction_msg[:subagent_result] = true
|
|
310
|
-
instruction_msg[:skill_name] = skill.identifier
|
|
312
|
+
# Mutate the subagent_instructions message in-place to become the result summary
|
|
313
|
+
@history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
|
|
314
|
+
m[:content] = summary
|
|
315
|
+
m.delete(:subagent_instructions)
|
|
316
|
+
m[:subagent_result] = true
|
|
317
|
+
m[:skill_name] = skill.identifier
|
|
311
318
|
end
|
|
312
319
|
|
|
313
320
|
# Log completion
|
|
@@ -90,15 +90,15 @@ module Clacky
|
|
|
90
90
|
raise
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
# Filter messages to only show tasks up to active_task_id
|
|
94
|
-
# This hides "future" messages when user has undone
|
|
93
|
+
# Filter messages to only show tasks up to active_task_id.
|
|
94
|
+
# This hides "future" messages when user has undone.
|
|
95
|
+
# Returns API-ready array (strips internal fields + handles orphaned tool_calls).
|
|
95
96
|
# Made public for testing
|
|
96
97
|
def active_messages
|
|
97
|
-
return @
|
|
98
|
-
|
|
99
|
-
@
|
|
100
|
-
|
|
101
|
-
msg_task_id <= @active_task_id
|
|
98
|
+
return @history.to_api if @active_task_id == @current_task_id
|
|
99
|
+
|
|
100
|
+
@history.for_task(@active_task_id).map do |msg|
|
|
101
|
+
msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
@@ -346,15 +346,17 @@ module Clacky
|
|
|
346
346
|
# @return [nil] Always returns nil (no errors for write)
|
|
347
347
|
private def show_write_preview(args)
|
|
348
348
|
path = args[:path] || args['path']
|
|
349
|
+
# Expand ~ to home directory so File.exist? works correctly
|
|
350
|
+
expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
|
|
349
351
|
new_content = args[:content] || args['content'] || ""
|
|
350
352
|
|
|
351
|
-
is_new_file = !(
|
|
353
|
+
is_new_file = !(expanded_path && File.exist?(expanded_path))
|
|
352
354
|
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
353
355
|
|
|
354
356
|
if is_new_file
|
|
355
357
|
@ui&.show_diff("", new_content, max_lines: 50)
|
|
356
358
|
else
|
|
357
|
-
old_content = File.read(
|
|
359
|
+
old_content = File.read(expanded_path)
|
|
358
360
|
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
359
361
|
end
|
|
360
362
|
nil
|
|
@@ -369,14 +371,17 @@ module Clacky
|
|
|
369
371
|
new_string = args[:new_string] || args['new_string'] || ""
|
|
370
372
|
replace_all = args[:replace_all] || args['replace_all'] || false
|
|
371
373
|
|
|
374
|
+
# Expand ~ to home directory so File.exist? and File.read work correctly
|
|
375
|
+
expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
|
|
376
|
+
|
|
372
377
|
@ui&.show_file_edit_preview(path)
|
|
373
378
|
|
|
374
|
-
if !
|
|
379
|
+
if !expanded_path || expanded_path.empty?
|
|
375
380
|
@ui&.show_file_error("No file path provided")
|
|
376
381
|
return { error: "No file path provided for edit operation" }
|
|
377
382
|
end
|
|
378
383
|
|
|
379
|
-
unless File.exist?(
|
|
384
|
+
unless File.exist?(expanded_path)
|
|
380
385
|
@ui&.show_file_error("File not found: #{path}")
|
|
381
386
|
return { error: "File not found: #{path}", path: path }
|
|
382
387
|
end
|
|
@@ -386,7 +391,7 @@ module Clacky
|
|
|
386
391
|
return { error: "No old_string provided (nothing to replace)" }
|
|
387
392
|
end
|
|
388
393
|
|
|
389
|
-
file_content = File.read(
|
|
394
|
+
file_content = File.read(expanded_path)
|
|
390
395
|
|
|
391
396
|
# Use the same find_match logic as Edit tool to handle fuzzy matching
|
|
392
397
|
# (trim, unescape, smart line matching) — prevents diff from being blank
|