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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +19 -3
- data/lib/clacky/agent/memory_updater.rb +3 -3
- data/lib/clacky/agent/message_compressor_helper.rb +12 -12
- data/lib/clacky/agent/session_serializer.rb +70 -68
- data/lib/clacky/agent/skill_manager.rb +27 -20
- data/lib/clacky/agent/time_machine.rb +8 -8
- data/lib/clacky/agent/tool_executor.rb +10 -5
- data/lib/clacky/agent.rb +202 -87
- data/lib/clacky/brand_config.rb +6 -4
- data/lib/clacky/cli.rb +20 -12
- data/lib/clacky/client.rb +12 -2
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_skills/product-help/SKILL.md +91 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +24 -24
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +49 -20
- data/lib/clacky/default_skills/skill-creator/SKILL.md +5 -2
- data/lib/clacky/json_ui_controller.rb +5 -3
- data/lib/clacky/message_history.rb +196 -0
- data/lib/clacky/plain_ui_controller.rb +3 -4
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +40 -28
- data/lib/clacky/server/channel/adapters/feishu/file_processor.rb +14 -7
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +22 -10
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +173 -13
- data/lib/clacky/server/channel/channel_manager.rb +150 -63
- data/lib/clacky/server/channel/channel_ui_controller.rb +29 -14
- data/lib/clacky/server/http_server.rb +36 -37
- data/lib/clacky/server/web_ui_controller.rb +4 -4
- data/lib/clacky/skill.rb +8 -4
- data/lib/clacky/tools/glob.rb +3 -2
- data/lib/clacky/tools/safe_shell.rb +21 -6
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +33 -38
- data/lib/clacky/ui2/components/message_component.rb +10 -11
- data/lib/clacky/ui2/ui_controller.rb +4 -4
- data/lib/clacky/ui2/view_renderer.rb +3 -3
- data/lib/clacky/ui_interface.rb +3 -1
- data/lib/clacky/utils/environment_detector.rb +94 -0
- data/lib/clacky/utils/file_parser/docx_parser.rb +156 -0
- data/lib/clacky/utils/file_parser/pptx_parser.rb +116 -0
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +95 -0
- data/lib/clacky/utils/file_parser/zip_parser.rb +60 -0
- data/lib/clacky/utils/file_processor.rb +243 -203
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +202 -16
- data/lib/clacky/web/app.js +103 -25
- data/lib/clacky/web/brand.js +30 -31
- data/lib/clacky/web/i18n.js +22 -12
- data/lib/clacky/web/index.html +42 -14
- data/lib/clacky/web/sessions.js +16 -2
- data/lib/clacky/web/settings.js +11 -2
- data/lib/clacky/web/skills.js +161 -123
- data/lib/clacky/web/version.js +16 -4
- data/lib/clacky.rb +3 -1
- data/scripts/install.sh +19 -35
- metadata +8 -3
- data/lib/clacky/default_skills/activate-license/SKILL.md +0 -118
- data/lib/clacky/utils/file_attachment.rb +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a499294341fb7b3fd0f4884ecc672705317c1f33d4ead1531ebdd34465a8f5f8
|
|
4
|
+
data.tar.gz: a2c023146c5ed2b91c0777e31266800ff933fd053bee146430c0587cc8fc1999
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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,
|
|
@@ -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
|
-
@
|
|
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,11 @@ module Clacky
|
|
|
33
34
|
true
|
|
34
35
|
rescue Clacky::AgentInterrupted => e
|
|
35
36
|
@ui&.log("Idle compression canceled: #{e.message}", level: :info)
|
|
36
|
-
|
|
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
|
-
|
|
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 = @
|
|
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
|
-
|
|
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(
|
|
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: @
|
|
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 = @
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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,
|
|
@@ -92,7 +90,8 @@ module Clacky
|
|
|
92
90
|
active_task_id: @active_task_id || 0
|
|
93
91
|
},
|
|
94
92
|
config: {
|
|
95
|
-
|
|
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: @
|
|
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
|
-
|
|
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 @
|
|
125
|
+
# Split @history into rounds, each starting at a real user message
|
|
133
126
|
rounds = []
|
|
134
127
|
current_round = nil
|
|
135
128
|
|
|
136
|
-
@
|
|
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
|
-
|
|
173
|
-
#
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
|
@@ -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 = @
|
|
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 = !(
|
|
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
|