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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9c12ba4b5e244e6dbbec7d723dc6d0f9c7e4e4523a82bafbb96f668e7b1a0f4
4
- data.tar.gz: 5d6e7d71829aa2bafbe68dc825b6ec0e7bd4c1ad004804d43ef9121a46fa6671
3
+ metadata.gz: a50886ecfabfb60ea86a139a0180fc64803d1853d49aee14b201aa4e1d14a907
4
+ data.tar.gz: 7979255d8dc2113189a5934081a8b5cd24cce0e84aad9ab3deda97116924c6a5
5
5
  SHA512:
6
- metadata.gz: 1dd7ede95260c506b7dfb89cd5596ea4ba9ac4e6949e17067b84ec6a9a2686892a27fafd1e25bf80a6c0ade211f398c3f6b8ab87d3e7167ea808fe88398f46be
7
- data.tar.gz: 38771322fb7651494b4595d82e315a3a6ddf902ae3bf4141d4ea01a63a65ffd1c9adb1dcf91da43d29ead77f673025e1559d0ad688fafbfa7673738dcf393052
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
@@ -125,7 +125,7 @@ module Clacky
125
125
  tool_tokens = 0
126
126
  summary_tokens = 0
127
127
 
128
- @messages.each do |msg|
128
+ @history.to_a.each do |msg|
129
129
  tokens = estimate_tokens(msg[:content])
130
130
  case msg[:role]
131
131
  when "system"
@@ -19,9 +19,14 @@ module Clacky
19
19
  retries = 0
20
20
 
21
21
  begin
22
- # Use active_messages to filter out "future" messages after undo
23
- messages_to_send = respond_to?(:active_messages) ? active_messages : @messages
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
- @messages << {
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
- @messages.reject! { |m| m[:memory_update] }
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
- @messages << compression_context[:compression_message]
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
- # Remove the compression message we added
37
- @messages.pop if @messages.last == compression_context[:compression_message]
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
- # Remove the compression message we added
42
- @messages.pop if @messages.last == compression_context[:compression_message]
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 = @messages.length
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
- recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
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(@messages, recent_messages: recent_messages)
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: @messages.length,
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 = @messages[0..-2] # All except the last (compression instruction)
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
- @messages = @message_compressor.rebuild_with_compression(
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
- @messages = session_data[:messages]
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
- # Find and remove the last user message that caused the error
43
- # This allows the user to retry with a different prompt
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
- @messages = @messages[0...last_user_index]
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: @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
- # Filter messages to only include real user messages (exclude system-injected ones)
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
- @messages.each do |msg|
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
- system_index = @messages.index { |m| m[:role] == "system" }
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
- # Encrypted brand skills and fork-agent skills must run in an isolated subagent.
159
- # Injecting their plaintext into @messages would expose confidential content to the LLM.
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
- @messages << {
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
- @messages << {
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
- result = subagent.run(arguments)
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
- # Insert summary back to parent agent messages (replacing the instruction message)
303
- # Find and replace the last message with subagent_instructions flag
304
- messages_with_instructions = @messages.select { |m| m[:subagent_instructions] }
305
- if messages_with_instructions.any?
306
- instruction_msg = messages_with_instructions.last
307
- instruction_msg[:content] = summary
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 @messages if @active_task_id == @current_task_id
98
-
99
- @messages.select do |msg|
100
- msg_task_id = msg[:task_id] || 0
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 = !(path && File.exist?(path))
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(path)
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 !path || path.empty?
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?(path)
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(path)
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