openclacky 0.9.4 → 0.9.6

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +19 -3
  5. data/lib/clacky/agent/memory_updater.rb +3 -3
  6. data/lib/clacky/agent/message_compressor_helper.rb +12 -12
  7. data/lib/clacky/agent/session_serializer.rb +70 -68
  8. data/lib/clacky/agent/skill_manager.rb +27 -20
  9. data/lib/clacky/agent/time_machine.rb +8 -8
  10. data/lib/clacky/agent/tool_executor.rb +10 -5
  11. data/lib/clacky/agent.rb +202 -87
  12. data/lib/clacky/brand_config.rb +6 -4
  13. data/lib/clacky/cli.rb +20 -12
  14. data/lib/clacky/client.rb +12 -2
  15. data/lib/clacky/default_agents/base_prompt.md +1 -0
  16. data/lib/clacky/default_skills/product-help/SKILL.md +91 -0
  17. data/lib/clacky/default_skills/skill-add/SKILL.md +24 -24
  18. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +49 -20
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +5 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -3
  21. data/lib/clacky/message_history.rb +196 -0
  22. data/lib/clacky/plain_ui_controller.rb +3 -4
  23. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +40 -28
  24. data/lib/clacky/server/channel/adapters/feishu/file_processor.rb +14 -7
  25. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +22 -10
  26. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +173 -13
  27. data/lib/clacky/server/channel/channel_manager.rb +150 -63
  28. data/lib/clacky/server/channel/channel_ui_controller.rb +29 -14
  29. data/lib/clacky/server/http_server.rb +36 -37
  30. data/lib/clacky/server/web_ui_controller.rb +4 -4
  31. data/lib/clacky/skill.rb +8 -4
  32. data/lib/clacky/tools/glob.rb +3 -2
  33. data/lib/clacky/tools/safe_shell.rb +21 -6
  34. data/lib/clacky/tools/web_fetch.rb +3 -1
  35. data/lib/clacky/ui2/components/input_area.rb +33 -38
  36. data/lib/clacky/ui2/components/message_component.rb +10 -11
  37. data/lib/clacky/ui2/ui_controller.rb +4 -4
  38. data/lib/clacky/ui2/view_renderer.rb +3 -3
  39. data/lib/clacky/ui_interface.rb +3 -1
  40. data/lib/clacky/utils/environment_detector.rb +94 -0
  41. data/lib/clacky/utils/file_parser/docx_parser.rb +156 -0
  42. data/lib/clacky/utils/file_parser/pptx_parser.rb +116 -0
  43. data/lib/clacky/utils/file_parser/xlsx_parser.rb +95 -0
  44. data/lib/clacky/utils/file_parser/zip_parser.rb +60 -0
  45. data/lib/clacky/utils/file_processor.rb +243 -203
  46. data/lib/clacky/version.rb +1 -1
  47. data/lib/clacky/web/app.css +202 -16
  48. data/lib/clacky/web/app.js +103 -25
  49. data/lib/clacky/web/brand.js +30 -31
  50. data/lib/clacky/web/i18n.js +22 -12
  51. data/lib/clacky/web/index.html +42 -14
  52. data/lib/clacky/web/sessions.js +16 -2
  53. data/lib/clacky/web/settings.js +11 -2
  54. data/lib/clacky/web/skills.js +161 -123
  55. data/lib/clacky/web/version.js +16 -4
  56. data/lib/clacky.rb +3 -1
  57. data/scripts/install.sh +19 -35
  58. metadata +8 -3
  59. data/lib/clacky/default_skills/activate-license/SKILL.md +0 -118
  60. data/lib/clacky/utils/file_attachment.rb +0 -105
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9c12ba4b5e244e6dbbec7d723dc6d0f9c7e4e4523a82bafbb96f668e7b1a0f4
4
- data.tar.gz: 5d6e7d71829aa2bafbe68dc825b6ec0e7bd4c1ad004804d43ef9121a46fa6671
3
+ metadata.gz: a499294341fb7b3fd0f4884ecc672705317c1f33d4ead1531ebdd34465a8f5f8
4
+ data.tar.gz: a2c023146c5ed2b91c0777e31266800ff933fd053bee146430c0587cc8fc1999
5
5
  SHA512:
6
- metadata.gz: 1dd7ede95260c506b7dfb89cd5596ea4ba9ac4e6949e17067b84ec6a9a2686892a27fafd1e25bf80a6c0ade211f398c3f6b8ab87d3e7167ea808fe88398f46be
7
- data.tar.gz: 38771322fb7651494b4595d82e315a3a6ddf902ae3bf4141d4ea01a63a65ffd1c9adb1dcf91da43d29ead77f673025e1559d0ad688fafbfa7673738dcf393052
6
+ metadata.gz: 32640a8ff88ebfe3c37f69c9362c6935a876515a30a9fbff14d6496af6dbc2ec3c0df227db4a62d362c80d44a117f63e5c0ce48dc96f4229d1a03c1761b01eae
7
+ data.tar.gz: 72ed3bf45504176b76904cbeb90bcf32e5606c65405deb4b26fa3e6923e1e977c99615f5221d27a318e7585c5831523770ddae97b23affc2306efc2ef00fa9e4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.6] - 2026-03-18
11
+
12
+ ### Added
13
+ - **Environment-aware context injection**: the agent now automatically detects your OS, desktop environment, and screen info and includes it in every session — so it can give OS-specific advice without you having to explain your setup
14
+ - **File attachments via IM channels**: you can now send images and documents directly through Feishu or WeCom to the agent, which processes them just like files sent via the Web UI
15
+ - **Unified file attachment pipeline for Web UI**: images and Office/PDF documents can now be attached in the web chat interface with automatic image compression before upload
16
+ - **Skills can now be installed from local zip files**: `skill-add` now accepts a local file path (not just a URL), so you can install skills from a downloaded zip without hosting it anywhere
17
+ - **Skill import bar in Web UI**: the Skills settings page now has an import bar where you can paste a URL or upload a local zip file directly — no terminal needed to install new skills
18
+ - **`$SKILL_DIR` available in skill instructions**: skill files can now reference `$SKILL_DIR` to get the absolute path to their own directory, making it easy to reference supporting files with correct paths
19
+ - **`product-help` built-in skill**: the agent can now answer questions about Clacky's own features, configuration, and usage through a dedicated built-in skill
20
+
21
+ ### Fixed
22
+ - **PDF and Office files now appear in glob results**: file discovery tools no longer skip `.pdf`, `.docx`, and other document formats — they show up correctly in file listings
23
+ - **Chat history visible after message compression**: sessions where all user messages were compressed no longer show a blank history — prior conversation is now correctly replayed
24
+ - **Stale message reference in task history**: an internal bug (`@messages` vs `@history`) that could cause incorrect task history in compressed sessions is fixed
25
+ - **File-only messages handled correctly in channel UI**: sending a file without text via IM channels no longer causes a display issue in the channel UI
26
+ - **WeCom WebSocket client stability**: fixed async dispatch and frame acknowledgment in the WeCom WS client to reduce dropped messages and connection issues
27
+ - **Session serializer variable fix**: corrected a stale variable reference in session replay that could cause errors when restoring sessions
28
+ - **`web_fetch` compatibility improved**: better request headers make web page fetching more reliable across more sites
29
+ - **Reasoning content preserved in API messages**: `reasoning_content` fields are no longer stripped from messages, fixing potential issues with reasoning-capable models
30
+
31
+ ### More
32
+ - Markdown links in chat now open in a new tab
33
+ - Removed public skill store tab from the Skills panel (store content is now integrated differently)
34
+ - Reduce WebSocket ping log noise in HTTP server
35
+ - Centralize message cleanup logic in `MessageHistory`
36
+
37
+ ## [0.9.5] - 2026-03-17
38
+
39
+ ### Added
40
+ - **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
41
+ - **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
42
+
43
+ ### Improved
44
+ - **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
45
+ - **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
46
+ - **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
47
+
48
+ ### Fixed
49
+ - **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
50
+ - **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
51
+ - **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
52
+
53
+ ### More
54
+ - Simplify error messages in brand config decryption
55
+ - Update test matchers to match simplified error messages
56
+
10
57
  ## [0.9.4] - 2026-03-16
11
58
 
12
59
  ### 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,
@@ -40,6 +45,17 @@ module Clacky
40
45
  @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
41
46
  raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
42
47
  end
48
+ rescue RetryableError => e
49
+ @ui&.clear_progress
50
+ retries += 1
51
+ if retries <= max_retries
52
+ @ui&.show_warning("#{e.message} (#{retries}/#{max_retries})")
53
+ sleep retry_delay
54
+ retry
55
+ else
56
+ @ui&.show_error("LLM service unavailable after #{max_retries} retries. Please try again later.")
57
+ raise AgentError, "LLM service unavailable after #{max_retries} retries"
58
+ end
43
59
  ensure
44
60
  @ui&.clear_progress
45
61
  end
@@ -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,11 @@ 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.rollback_before(compression_message)
38
38
  false
39
39
  rescue => e
40
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]
41
+ @history.rollback_before(compression_message)
43
42
  false
44
43
  end
45
44
  end
@@ -53,7 +52,7 @@ module Clacky
53
52
 
54
53
  # Calculate total tokens and message count
55
54
  total_tokens = total_message_tokens[:total]
56
- message_count = @messages.length
55
+ message_count = @history.size
57
56
 
58
57
  # Force compression (for idle compression) - use lower threshold
59
58
  if force
@@ -90,11 +89,12 @@ module Clacky
90
89
  @compression_level += 1
91
90
 
92
91
  # 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)
92
+ all_messages = @history.to_a
93
+ recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
94
94
  recent_messages = [] if recent_messages.nil?
95
95
 
96
96
  # Build compression instruction message (to be inserted into conversation)
97
- compression_message = @message_compressor.build_compression_message(@messages, recent_messages: recent_messages)
97
+ compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
98
98
 
99
99
  return nil if compression_message.nil?
100
100
 
@@ -103,7 +103,7 @@ module Clacky
103
103
  compression_message: compression_message,
104
104
  recent_messages: recent_messages,
105
105
  original_token_count: total_tokens,
106
- original_message_count: @messages.length,
106
+ original_message_count: @history.size,
107
107
  compression_level: @compression_level
108
108
  }
109
109
  end
@@ -117,7 +117,7 @@ module Clacky
117
117
 
118
118
  # Rebuild message list with compression
119
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)
120
+ original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
121
121
 
122
122
  # Archive compressed messages to a chunk MD file before discarding them
123
123
  chunk_index = @compressed_summaries.size + 1
@@ -128,12 +128,12 @@ module Clacky
128
128
  compression_level: compression_context[:compression_level]
129
129
  )
130
130
 
131
- @messages = @message_compressor.rebuild_with_compression(
131
+ @history.replace_all(@message_compressor.rebuild_with_compression(
132
132
  compressed_content,
133
133
  original_messages: original_messages,
134
134
  recent_messages: compression_context[:recent_messages],
135
135
  chunk_path: chunk_path
136
- )
136
+ ))
137
137
 
138
138
  # Track this compression
139
139
  @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,
@@ -92,7 +90,8 @@ module Clacky
92
90
  active_task_id: @active_task_id || 0
93
91
  },
94
92
  config: {
95
- models: @config.models,
93
+ # NOTE: api_key and other sensitive credentials are intentionally excluded
94
+ # to prevent leaking secrets into session files on disk.
96
95
  permission_mode: @config.permission_mode.to_s,
97
96
  enable_compression: @config.enable_compression,
98
97
  enable_prompt_caching: @config.enable_prompt_caching,
@@ -100,7 +99,7 @@ module Clacky
100
99
  verbose: @config.verbose
101
100
  },
102
101
  stats: stats_data,
103
- messages: @messages
102
+ messages: @history.to_a
104
103
  }
105
104
  end
106
105
 
@@ -108,13 +107,7 @@ module Clacky
108
107
  # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
109
108
  # @return [Array<String>] Array of recent user message contents
110
109
  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|
110
+ @history.real_user_messages.last(limit).map do |msg|
118
111
  extract_text_from_content(msg[:content])
119
112
  end
120
113
  end
@@ -129,11 +122,11 @@ module Clacky
129
122
  # created_at < before. Pass nil to get the most recent rounds.
130
123
  # @return [Hash] { has_more: Boolean } — whether older rounds exist beyond this page
131
124
  def replay_history(ui, limit: 20, before: nil)
132
- # Split @messages into rounds, each starting at a real user message
125
+ # Split @history into rounds, each starting at a real user message
133
126
  rounds = []
134
127
  current_round = nil
135
128
 
136
- @messages.each do |msg|
129
+ @history.to_a.each do |msg|
137
130
  role = msg[:role].to_s
138
131
 
139
132
  # A real user message can have either a String content or an Array content
@@ -163,62 +156,31 @@ module Clacky
163
156
  rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
164
157
  end
165
158
 
159
+ # Fallback: when the conversation was compressed and no user messages remain in the
160
+ # kept slice, render the surviving assistant/tool messages directly so the user can
161
+ # still see the last visible state of the chat (e.g. compressed summary + recent work).
162
+ if rounds.empty?
163
+ visible = @history.to_a.reject { |m| m[:role].to_s == "system" || m[:system_injected] }
164
+ visible.each { |msg| _replay_single_message(msg, ui) }
165
+ return { has_more: false }
166
+ end
167
+
166
168
  has_more = rounds.size > limit
167
169
  # Take the most recent `limit` rounds
168
170
  page = rounds.last(limit)
169
171
 
170
172
  page.each do |round|
171
173
  msg = round[:user_msg]
172
- display_text = extract_text_from_content(msg[:content])
173
- # Extract image data URLs from multipart content (for history replay rendering)
174
- images = extract_images_from_content(msg[:content])
175
- # Emit user message with its timestamp for dedup on the frontend
176
- ui.show_user_message(display_text, created_at: msg[:created_at], images: images)
174
+ raw_text = extract_text_from_content(msg[:content])
175
+ # Files are stored as system_injected messages (skipped below), not embedded in user text.
176
+ ui.show_user_message(raw_text, created_at: msg[:created_at])
177
177
 
178
178
  round[:events].each do |ev|
179
179
  # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
180
180
  # — they are internal scaffolding and must not be shown to the user.
181
181
  next if ev[:system_injected]
182
182
 
183
- case ev[:role].to_s
184
- when "assistant"
185
- # Text content
186
- text = extract_text_from_content(ev[:content]).to_s.strip
187
- ui.show_assistant_message(text) unless text.empty?
188
-
189
- # Tool calls embedded in assistant message
190
- Array(ev[:tool_calls]).each do |tc|
191
- name = tc[:name] || tc.dig(:function, :name) || ""
192
- args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {}
193
- args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
194
-
195
- # Special handling: request_user_feedback question is shown as an
196
- # assistant message (matching real-time behavior), not as a tool call.
197
- if name == "request_user_feedback"
198
- question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : ""
199
- ui.show_assistant_message(question) unless question.empty?
200
- else
201
- ui.show_tool_call(name, args)
202
- end
203
- end
204
-
205
- # Emit token usage stored on this message (for history replay display)
206
- ui.show_token_usage(ev[:token_usage]) if ev[:token_usage]
207
-
208
- when "user"
209
- # Anthropic-format tool results (role: user, content: array of tool_result blocks)
210
- next unless ev[:content].is_a?(Array)
211
-
212
- ev[:content].each do |blk|
213
- next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
214
-
215
- ui.show_tool_result(blk[:content].to_s)
216
- end
217
-
218
- when "tool"
219
- # OpenAI-format tool result
220
- ui.show_tool_result(ev[:content].to_s)
221
- end
183
+ _replay_single_message(ev, ui)
222
184
  end
223
185
  end
224
186
 
@@ -227,6 +189,52 @@ module Clacky
227
189
 
228
190
  private
229
191
 
192
+ # Render a single non-user message into the UI.
193
+ # Used by both the normal round-based replay and the compressed-session fallback.
194
+ def _replay_single_message(msg, ui)
195
+ return if msg[:system_injected]
196
+
197
+ case msg[:role].to_s
198
+ when "assistant"
199
+ # Text content
200
+ text = extract_text_from_content(msg[:content]).to_s.strip
201
+ ui.show_assistant_message(text, files: []) unless text.empty?
202
+
203
+ # Tool calls embedded in assistant message
204
+ Array(msg[:tool_calls]).each do |tc|
205
+ name = tc[:name] || tc.dig(:function, :name) || ""
206
+ args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {}
207
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
208
+
209
+ # Special handling: request_user_feedback question is shown as an
210
+ # assistant message (matching real-time behavior), not as a tool call.
211
+ if name == "request_user_feedback"
212
+ question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : ""
213
+ ui.show_assistant_message(question, files: []) unless question.empty?
214
+ else
215
+ ui.show_tool_call(name, args)
216
+ end
217
+ end
218
+
219
+ # Emit token usage stored on this message (for history replay display)
220
+ ui.show_token_usage(msg[:token_usage]) if msg[:token_usage]
221
+
222
+ when "user"
223
+ # Anthropic-format tool results (role: user, content: array of tool_result blocks)
224
+ return unless msg[:content].is_a?(Array)
225
+
226
+ msg[:content].each do |blk|
227
+ next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
228
+
229
+ ui.show_tool_result(blk[:content].to_s)
230
+ end
231
+
232
+ when "tool"
233
+ # OpenAI-format tool result
234
+ ui.show_tool_result(msg[:content].to_s)
235
+ end
236
+ end
237
+
230
238
  # Replace the system message in @messages with a freshly built system prompt.
231
239
  # Called after restore_session so newly installed skills and any other
232
240
  # configuration changes since the session was saved take effect immediately.
@@ -237,13 +245,7 @@ module Clacky
237
245
  @skill_loader.load_all
238
246
 
239
247
  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
248
+ @history.replace_system_prompt(fresh_prompt)
247
249
  rescue StandardError => e
248
250
  # Log and continue — a stale system prompt is better than a broken restore
249
251
  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
 
@@ -147,7 +147,7 @@ module Clacky
147
147
  tasks = []
148
148
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
149
149
  # Find first user message for this task
150
- first_user_msg = @messages.find do |msg|
150
+ first_user_msg = @history.to_a.find do |msg|
151
151
  msg[:task_id] == task_id && msg[:role] == "user"
152
152
  end
153
153
 
@@ -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