openclacky 1.0.0.beta.4 → 1.0.0.beta.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 +41 -5
- data/lib/clacky/agent/message_compressor.rb +46 -8
- data/lib/clacky/agent/message_compressor_helper.rb +56 -22
- data/lib/clacky/agent/session_serializer.rb +23 -1
- data/lib/clacky/agent/skill_evolution.rb +21 -6
- data/lib/clacky/agent/skill_manager.rb +35 -1
- data/lib/clacky/agent/tool_executor.rb +14 -4
- data/lib/clacky/agent.rb +31 -0
- data/lib/clacky/agent_config.rb +16 -1
- data/lib/clacky/brand_config.rb +16 -8
- data/lib/clacky/client.rb +10 -1
- data/lib/clacky/default_skills/new/SKILL.md +13 -5
- data/lib/clacky/default_skills/recall-memory/SKILL.md +0 -1
- data/lib/clacky/message_format/open_ai.rb +80 -3
- data/lib/clacky/providers.rb +7 -18
- data/lib/clacky/server/browser_manager.rb +25 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +43 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +2 -2
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +105 -1
- data/lib/clacky/tools/browser.rb +0 -57
- data/lib/clacky/tools/file_reader.rb +26 -10
- data/lib/clacky/tools/security.rb +67 -38
- data/lib/clacky/tools/terminal/persistent_session.rb +16 -6
- data/lib/clacky/tools/terminal.rb +117 -12
- data/lib/clacky/tools/todo_manager.rb +117 -30
- data/lib/clacky/utils/login_shell.rb +72 -0
- data/lib/clacky/utils/model_pricing.rb +44 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +7 -0
- data/lib/clacky/web/index.html +7 -1
- data/lib/clacky/web/onboard.js +38 -0
- data/lib/clacky/web/sessions.js +2 -2
- data/lib/clacky.rb +1 -1
- data/scripts/install.ps1 +76 -68
- metadata +2 -2
- data/lib/clacky/tools/run_project.rb +0 -295
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afc12c94c2b8b7580ca948625cc6c106004bbf385f341c783e36e1be9d93fd82
|
|
4
|
+
data.tar.gz: 95508d829f02270b3fce4849b21e29b6766a46d9c663d47e37df817aed456da5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f44be2b9d9bf26f97490f5ddf2525a6cad937c5152b8486bb2840a263ab104cacfa5838600236b3a38a6806e69cd717fbce982838f2c2a65664158b0b4ed238
|
|
7
|
+
data.tar.gz: aecb14f4b6f345d190e52de0c0816f380b4e6c3213453c9e69a04b78944f757115e8a1ac042b0a78398e79d27de65190f4c0cb61d1efe3c224416b6a2f55f6c6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,15 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.0.beta.6] - 2026-04-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Compression chunk indexing now uses disk-based discovery.** Chunk files are no longer incorrectly overwritten after the second compression. Previously, chunk index was counted from compressed_summary messages in history — which caps at 1 after rebuild — causing chunk-2.md to be overwritten on every subsequent compression. Now uses durable disk-based chunk discovery via SessionManager, ensuring all compressed chunks are preserved.
|
|
14
|
+
- **Skill evolution no longer creates duplicate skills.** The reflect and auto-create scenarios in skill evolution are now mutually exclusive: when a skill was just used, only reflection runs; when no skill was used, only auto-creation is considered. This prevents near-duplicate "auto-*" skills from being extracted from tasks already served by an existing skill.
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
- **Slash commands no longer misinterpret filesystem paths.** Pasted paths like `/Users/alice/foo` or `/tmp/bar` are no longer mistaken for slash commands, avoiding confusing "skill not found" notices.
|
|
18
|
+
|
|
19
|
+
## [1.0.0.beta.5] - 2026-04-29
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **WSL2 mirrored networking mode for localhost access.** Windows users running under WSL2 can now configure mirrored networking, allowing the Clacky server to be reached at `localhost` from the Windows host instead of needing to look up the WSL IP address.
|
|
23
|
+
- **Message compressor preserves chunk order.** Compression chunks are now consistently ordered with `chunk-nn` naming, making it easier to browse and understand compressed conversation history.
|
|
24
|
+
- **Session model is now saved.** The currently active model selection is persisted in session data, so it survives page refreshes and server restarts.
|
|
25
|
+
- **Feedback button styling in Web UI.** The feedback interface now has improved CSS styling for a better user experience.
|
|
26
|
+
|
|
27
|
+
### Improved
|
|
28
|
+
- **Fewer LLM turns for common tool operations.** The file reader, security tool, and todo manager have been optimized to require fewer round-trips with the AI model, making tasks faster and cheaper.
|
|
29
|
+
- **Terminal now supports mise-based Node.js.** The terminal tool correctly resolves Node.js when installed through `mise` version manager, not just `nvm` or system paths.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Browser MCP connection recovers from crashes.** The browser tool's MCP daemon handles process restarts more gracefully, and stale Node.js detection code has been cleaned up.
|
|
33
|
+
- **Brand configuration no longer crashes on empty data.** When brand config data is empty or missing, the system now handles it gracefully instead of raising an error.
|
|
34
|
+
- **Kimi K2.5 and K2.6 models now show correct pricing.** These models are now in the pricing table, so cost tracking reflects actual usage costs.
|
|
35
|
+
- **Feishu messages with images no longer silently dropped.** Image markdown syntax in Feishu messages is now sanitized before sending, preventing the Feishu API from silently rejecting them.
|
|
36
|
+
- **Onboarding model selector and provider presets fixed.** The model combobox in the onboarding flow now works correctly, and provider presets are properly updated.
|
|
37
|
+
- **File reader now works correctly with OpenAI provider.** Files attached to sessions are now properly read and processed when using the OpenAI API format.
|
|
38
|
+
- **Image URLs with special tokens no longer mis-handled.** The message formatter no longer mis-handles image URLs containing special tokens (e.g., `bong`).
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **`run_project` tool removed.** This deprecated tool has been removed. Use the terminal tool to run commands in projects instead.
|
|
42
|
+
|
|
43
|
+
### More
|
|
44
|
+
- Improved WSL2 detection on Windows PowerShell installer
|
|
45
|
+
- Minor test and documentation fixes
|
|
46
|
+
|
|
10
47
|
## [1.0.0.beta.4] - 2026-04-28
|
|
11
48
|
|
|
12
49
|
### Fixed
|
|
13
|
-
-
|
|
50
|
+
- **Fix**: onboard.js was calling defunct `POST /api/config` → now calls `POST /api/config/models`
|
|
14
51
|
|
|
15
52
|
## [1.0.0.beta.3] - 2026-04-28
|
|
16
53
|
|
|
17
54
|
### Added
|
|
18
|
-
- **Gemini 2.5 Pro support.** The new `gemini2.5-pro` model is now available as a selectable option, giving you access to Google's latest flagship model.
|
|
19
55
|
- **File attachments now support Markdown, plain text, and `.tar.gz` archives.** When you attach `.md`, `.txt`, or `.tar.gz` files to a session, the agent can read and reason over their contents directly.
|
|
20
56
|
- **Image type auto-detection.** Image files are now correctly identified by their binary content (magic bytes), not just their file extension — preventing misclassified images from causing upload or vision errors.
|
|
21
57
|
|
|
@@ -33,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
33
69
|
- **New session creation supports model & working-directory options.** The Web UI "new session" dialog now lets you pick the model and starting directory up front, instead of having to adjust them after the session opens.
|
|
34
70
|
|
|
35
71
|
### Fixed
|
|
36
|
-
- **System prompt now refreshes when you switch models.** Previously the system prompt captured at session start stuck around even after
|
|
72
|
+
- **System prompt now refreshes when you switch models.** Previously the system prompt captured at session start stuck around even after model switches, which could leave model-specific instructions out of sync. The agent now re-injects the correct system prompt on every model change.
|
|
37
73
|
- **Port 7070 properly released when the terminal tool exits.** A lingering listener on port 7070 could block subsequent runs; the terminal tool now cleans it up on shutdown.
|
|
38
74
|
- **Windows installer uses `[IO.Path]::GetTempPath()` for the temp directory** (#58) — more reliable than `$env:TEMP` on systems where the env var is unset or points to a non-ASCII path.
|
|
39
75
|
|
|
@@ -41,7 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
41
77
|
|
|
42
78
|
### Added
|
|
43
79
|
- **Vision support — agents can now "see" images.** When you attach image files (PNG, JPG, GIF, WebP), the agent can analyze them visually with vision-capable models. Non-vision models automatically fall back to disk references instead of breaking.
|
|
44
|
-
- **DeepSeek V4 (Clacky-DS) provider.** New `deepseekv4` provider preset with native DeepSeek API endpoint, supporting `deepseek-v4-pro` and `deepseek-v4-flash` models with accurate pricing.
|
|
80
|
+
- **DeepSeek V4 (Clacky-DS) provider.** New `deepseekv4` provider preset with native DeepSeek API endpoint, supporting `dsk-deepseek-v4-pro` and `dsk-deepseek-v4-flash` models with accurate pricing.
|
|
45
81
|
- **Memory subagent.** Long-term memory management now runs as a dedicated background subagent — writes memories when the task reaches meaningful completion, instead of on every turn.
|
|
46
82
|
- **Usage telemetry.** Anonymous usage data collection helps us understand how the product is used and prioritize improvements. No personal or conversation data is collected.
|
|
47
83
|
- **Brand configuration auto-refresh.** White-label brand settings now refresh automatically when the WebUI starts up, no manual restart needed.
|
|
@@ -49,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
49
85
|
### Improved
|
|
50
86
|
- **Progress handles revamped.** Nested progress handles now hide/show automatically, ticker threads keep animations smooth, and fast-completing tasks no longer flash a pointless "done" message.
|
|
51
87
|
- **Todo manager tool upgrades.** Batch add/remove multiple todos at once, and completed todos auto-clear when you add new ones.
|
|
52
|
-
- **Model switching more robust.** CLI slash commands (
|
|
88
|
+
- **Model switching more robust.** CLI slash commands (/config) now work seamlessly, server-side routing handles dynamic endpoints correctly, and switching between all provider types is more reliable.
|
|
53
89
|
|
|
54
90
|
### Fixed
|
|
55
91
|
- **Access key now persists via cookies.** The WebUI login key was stored only in `localStorage`, causing WebSocket connections to lose authentication. Now also written to a `clacky_access_key` cookie for consistent auth across all connection types.
|
|
@@ -94,12 +94,18 @@ module Clacky
|
|
|
94
94
|
# @param recent_messages [Array<Hash>] Recent messages to preserve
|
|
95
95
|
# @param chunk_path [String, nil] Path to the archived chunk MD file (if saved)
|
|
96
96
|
# @return [Array<Hash>] Rebuilt message list: system + compressed + recent
|
|
97
|
-
def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil)
|
|
97
|
+
def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [])
|
|
98
98
|
# Find and preserve system message
|
|
99
99
|
system_msg = original_messages.find { |m| m[:role] == "system" }
|
|
100
100
|
|
|
101
|
-
# Parse the compressed result
|
|
102
|
-
|
|
101
|
+
# Parse the compressed result, embedding previous chunk references so the
|
|
102
|
+
# new summary carries a complete index of all older archives. This avoids
|
|
103
|
+
# keeping all prior compressed_summary messages in active history while
|
|
104
|
+
# still giving the AI a path to find old conversations via file_reader.
|
|
105
|
+
parsed_messages = parse_compressed_result(compressed_content,
|
|
106
|
+
chunk_path: chunk_path,
|
|
107
|
+
topics: topics,
|
|
108
|
+
previous_chunks: previous_chunks)
|
|
103
109
|
|
|
104
110
|
# If parsing fails or returns empty, raise error
|
|
105
111
|
if parsed_messages.nil? || parsed_messages.empty?
|
|
@@ -124,7 +130,7 @@ module Clacky
|
|
|
124
130
|
m ? m[1].strip : nil
|
|
125
131
|
end
|
|
126
132
|
|
|
127
|
-
def parse_compressed_result(result, chunk_path: nil)
|
|
133
|
+
def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
|
|
128
134
|
# Return the compressed result as a single user message (role: "user").
|
|
129
135
|
#
|
|
130
136
|
# Why role:"user" instead of "assistant":
|
|
@@ -144,6 +150,10 @@ module Clacky
|
|
|
144
150
|
# The `compressed_summary: true` flag is preserved so that replay_history still
|
|
145
151
|
# routes this message through the chunk-expansion path (which keys off that flag,
|
|
146
152
|
# not the role).
|
|
153
|
+
#
|
|
154
|
+
# @param topics [String, nil] Short topic description extracted from <topics> tag
|
|
155
|
+
# @param previous_chunks [Array<Hash>] Info about older chunk files
|
|
156
|
+
# Each hash: { basename:, path:, topics: }
|
|
147
157
|
content = result.to_s.strip
|
|
148
158
|
|
|
149
159
|
if content.empty?
|
|
@@ -152,22 +162,50 @@ module Clacky
|
|
|
152
162
|
# Strip out the <topics> block — it's metadata for the chunk file, not for AI context
|
|
153
163
|
content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
|
|
154
164
|
|
|
155
|
-
#
|
|
165
|
+
# Build previous chunks index section — links to older chunk files so the AI
|
|
166
|
+
# can find earlier conversations without keeping all prior compressed_summary
|
|
167
|
+
# messages in the active history. Shows newest chunks first (reverse order),
|
|
168
|
+
# capped at 10 to keep the message size bounded.
|
|
169
|
+
previous_chunks_section = ""
|
|
170
|
+
if previous_chunks.any?
|
|
171
|
+
max_visible = 10
|
|
172
|
+
visible = previous_chunks.last(max_visible).reverse
|
|
173
|
+
older_count = previous_chunks.size - visible.size
|
|
174
|
+
|
|
175
|
+
previous_chunks_section = "\n\n---\n📁 **Previous chunks (newest first):**\n"
|
|
176
|
+
visible.each do |pc|
|
|
177
|
+
topic_str = pc[:topics] ? " — #{pc[:topics]}" : ""
|
|
178
|
+
previous_chunks_section += "- `#{pc[:basename]}`#{topic_str}\n"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if older_count > 0
|
|
182
|
+
oldest = previous_chunks.first
|
|
183
|
+
previous_chunks_section += "- ... and #{older_count} older chunks back to `#{oldest[:basename]}`\n"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
previous_chunks_section += "_Use `file_reader` to recall details from these chunks._"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Inject chunk anchor so AI knows where to find original conversation for THIS chunk
|
|
190
|
+
anchor = ""
|
|
156
191
|
if chunk_path
|
|
157
|
-
anchor = "\n\n---\n📁 **
|
|
192
|
+
anchor = "\n\n---\n📁 **Current chunk archived at:** `#{chunk_path}`\n" \
|
|
158
193
|
"_Use `file_reader` tool to recall details from this chunk._"
|
|
159
|
-
content_without_topics = content_without_topics + anchor
|
|
160
194
|
end
|
|
161
195
|
|
|
162
196
|
# Prefix lets the model recognise this is injected context, not a user utterance.
|
|
197
|
+
# Order: summary → previous chunks → current anchor (chronological)
|
|
163
198
|
framed_content = "[Compressed conversation summary — previous turns archived]\n\n" \
|
|
164
|
-
"#{content_without_topics}"
|
|
199
|
+
"#{content_without_topics}" \
|
|
200
|
+
"#{previous_chunks_section}" \
|
|
201
|
+
"#{anchor}"
|
|
165
202
|
|
|
166
203
|
[{
|
|
167
204
|
role: "user",
|
|
168
205
|
content: framed_content,
|
|
169
206
|
compressed_summary: true,
|
|
170
207
|
chunk_path: chunk_path,
|
|
208
|
+
topics: topics,
|
|
171
209
|
system_injected: true
|
|
172
210
|
}]
|
|
173
211
|
end
|
|
@@ -154,25 +154,50 @@ module Clacky
|
|
|
154
154
|
# Note: we need to remove the compression instruction message we just added
|
|
155
155
|
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
156
156
|
|
|
157
|
-
# Archive compressed messages to a chunk MD file before discarding them
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
# Archive compressed messages to a chunk MD file before discarding them.
|
|
158
|
+
#
|
|
159
|
+
# IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
|
|
160
|
+
# message history. Each compression's rebuild_with_compression keeps only
|
|
161
|
+
# ONE compressed_summary message (the new one), dropping older summaries
|
|
162
|
+
# and embedding their references into the new summary's content. So
|
|
163
|
+
# counting compressed_summary messages in history caps at 1 from the
|
|
164
|
+
# second compression onward — causing chunk-2.md to be overwritten on
|
|
165
|
+
# every subsequent compression, and losing references to chunk-1.md.
|
|
166
|
+
#
|
|
167
|
+
# Disk is the only durable source of truth: chunk files survive process
|
|
168
|
+
# restarts, session reloads, and message rebuilds. SessionManager owns
|
|
169
|
+
# all chunk file I/O (naming, writing, discovery) — we just ask it.
|
|
170
|
+
sm = session_manager
|
|
171
|
+
existing_chunks = sm.chunks_for_current(@session_id, @created_at)
|
|
172
|
+
chunk_index = sm.next_chunk_index(@session_id, @created_at)
|
|
173
|
+
|
|
174
|
+
# Extract topics from the LLM response to store in both the chunk MD front
|
|
175
|
+
# matter and the compressed_summary message hash (for future chunk indexing).
|
|
176
|
+
topics = @message_compressor.parse_topics(compressed_content)
|
|
177
|
+
|
|
163
178
|
chunk_path = save_compressed_chunk(
|
|
164
179
|
original_messages,
|
|
165
180
|
compression_context[:recent_messages],
|
|
166
181
|
chunk_index: chunk_index,
|
|
167
182
|
compression_level: compression_context[:compression_level],
|
|
168
|
-
topics:
|
|
183
|
+
topics: topics
|
|
169
184
|
)
|
|
170
185
|
|
|
186
|
+
# Build previous_chunks index from the disk-discovered chunks (already
|
|
187
|
+
# sorted by index ascending). This gives the new summary a complete
|
|
188
|
+
# chronological index of all older archives so the AI can recall any
|
|
189
|
+
# past chunk via file_reader, not just the most recent one.
|
|
190
|
+
previous_chunks = existing_chunks.map do |c|
|
|
191
|
+
{ basename: c[:basename], path: c[:path], topics: c[:topics] }
|
|
192
|
+
end
|
|
193
|
+
|
|
171
194
|
@history.replace_all(@message_compressor.rebuild_with_compression(
|
|
172
195
|
compressed_content,
|
|
173
196
|
original_messages: original_messages,
|
|
174
197
|
recent_messages: compression_context[:recent_messages],
|
|
175
|
-
chunk_path: chunk_path
|
|
198
|
+
chunk_path: chunk_path,
|
|
199
|
+
topics: topics,
|
|
200
|
+
previous_chunks: previous_chunks
|
|
176
201
|
))
|
|
177
202
|
|
|
178
203
|
# Reset to the estimated size of the rebuilt (small) history.
|
|
@@ -332,8 +357,22 @@ module Clacky
|
|
|
332
357
|
end
|
|
333
358
|
end
|
|
334
359
|
|
|
335
|
-
#
|
|
336
|
-
#
|
|
360
|
+
# Lazy accessor for a SessionManager instance used by compression chunk I/O.
|
|
361
|
+
# We keep this local to the helper rather than threading a manager instance
|
|
362
|
+
# through the Agent constructor — Agent itself doesn't persist sessions
|
|
363
|
+
# (CLI / HTTP server do that), but the compression archive lives in the
|
|
364
|
+
# same directory under SessionManager's ownership.
|
|
365
|
+
#
|
|
366
|
+
# NOTE: Uses Clacky::SessionManager::SESSIONS_DIR by default. Tests can
|
|
367
|
+
# stub that constant to point at a tmpdir.
|
|
368
|
+
private def session_manager
|
|
369
|
+
@session_manager ||= Clacky::SessionManager.new
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Save the messages being compressed to a chunk MD file for future recall.
|
|
373
|
+
# The filesystem concerns (path, write, chmod) are delegated to SessionManager;
|
|
374
|
+
# this method is responsible only for the business rules of WHAT gets archived.
|
|
375
|
+
#
|
|
337
376
|
# @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
|
|
338
377
|
# @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
|
|
339
378
|
# @param chunk_index [Integer] Sequential chunk number
|
|
@@ -357,19 +396,14 @@ module Clacky
|
|
|
357
396
|
|
|
358
397
|
return nil if messages_to_archive.empty?
|
|
359
398
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
chunk_filename = "#{base_name}-chunk-#{chunk_index}.md"
|
|
365
|
-
chunk_path = File.join(sessions_dir, chunk_filename)
|
|
366
|
-
|
|
367
|
-
md_content = build_chunk_md(messages_to_archive, chunk_index: chunk_index, compression_level: compression_level, topics: topics)
|
|
368
|
-
|
|
369
|
-
File.write(chunk_path, md_content)
|
|
370
|
-
FileUtils.chmod(0o600, chunk_path)
|
|
399
|
+
md_content = build_chunk_md(messages_to_archive,
|
|
400
|
+
chunk_index: chunk_index,
|
|
401
|
+
compression_level: compression_level,
|
|
402
|
+
topics: topics)
|
|
371
403
|
|
|
372
|
-
|
|
404
|
+
# Delegate filesystem concerns (path assembly, write, chmod) to SessionManager —
|
|
405
|
+
# it owns the on-disk layout for sessions and their chunk archives.
|
|
406
|
+
session_manager.write_chunk(@session_id, @created_at, chunk_index, md_content)
|
|
373
407
|
rescue => e
|
|
374
408
|
@ui&.log("Failed to save chunk MD: #{e.message}", level: :warn)
|
|
375
409
|
nil
|
|
@@ -54,6 +54,20 @@ module Clacky
|
|
|
54
54
|
@pending_error_rollback = true
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Restore the session's original model if it still exists in the current
|
|
58
|
+
# config. This prevents all sessions from silently switching to the new
|
|
59
|
+
# default model when the user changes it and restarts. Falls back to the
|
|
60
|
+
# current default if the model was deleted/renamed since the session was
|
|
61
|
+
# last saved.
|
|
62
|
+
saved_model_name = session_data.dig(:config, :model_name)
|
|
63
|
+
if saved_model_name
|
|
64
|
+
saved_base_url = session_data.dig(:config, :model_base_url)
|
|
65
|
+
model_entry = @config.find_model_by_name_and_url(saved_model_name, saved_base_url)
|
|
66
|
+
if model_entry && model_entry["id"]
|
|
67
|
+
switch_model_by_id(model_entry["id"])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
57
71
|
# Rebuild and refresh the system prompt so any newly installed skills
|
|
58
72
|
# (or other configuration changes since the session was saved) are
|
|
59
73
|
# reflected immediately — without requiring the user to create a new session.
|
|
@@ -98,11 +112,19 @@ module Clacky
|
|
|
98
112
|
config: {
|
|
99
113
|
# NOTE: api_key and other sensitive credentials are intentionally excluded
|
|
100
114
|
# to prevent leaking secrets into session files on disk.
|
|
115
|
+
# model_name is saved so the session can restore its original model on restart
|
|
116
|
+
# (falling back to the current default if the model no longer exists).
|
|
101
117
|
permission_mode: @config.permission_mode.to_s,
|
|
102
118
|
enable_compression: @config.enable_compression,
|
|
103
119
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
104
120
|
max_tokens: @config.max_tokens,
|
|
105
|
-
verbose: @config.verbose
|
|
121
|
+
verbose: @config.verbose,
|
|
122
|
+
# Persist the current model identity so the session can restore its
|
|
123
|
+
# original model on restart. model_name + model_base_url form a
|
|
124
|
+
# composite key to avoid matching a different provider's model of
|
|
125
|
+
# the same name. Falls back to default if the model no longer exists.
|
|
126
|
+
model_name: @config.current_model&.dig("model"),
|
|
127
|
+
model_base_url: @config.current_model&.dig("base_url")
|
|
106
128
|
},
|
|
107
129
|
stats: stats_data,
|
|
108
130
|
messages: @history.to_a
|
|
@@ -10,16 +10,31 @@ module Clacky
|
|
|
10
10
|
# Triggered at the end of Agent#run (post-run hooks), only for main agents.
|
|
11
11
|
module SkillEvolution
|
|
12
12
|
# Main entry point - runs all skill evolution checks
|
|
13
|
-
# Called from Agent#run after the main loop completes
|
|
13
|
+
# Called from Agent#run after the main loop completes.
|
|
14
|
+
#
|
|
15
|
+
# The two scenarios are mutually exclusive by design:
|
|
16
|
+
#
|
|
17
|
+
# * If a skill just ran (@skill_execution_context is set), the user's
|
|
18
|
+
# need was already served by an existing skill. Run Scenario 2
|
|
19
|
+
# (reflect + possibly improve that skill) and skip Scenario 1 —
|
|
20
|
+
# otherwise we would auto-extract a near-duplicate "auto-*" skill
|
|
21
|
+
# from the same task, polluting the skills directory.
|
|
22
|
+
#
|
|
23
|
+
# * If no skill ran, the task was solved with raw tools. That is the
|
|
24
|
+
# signal for Scenario 1: if the pattern is complex/repeatable enough,
|
|
25
|
+
# consider extracting it into a new skill.
|
|
14
26
|
def run_skill_evolution_hooks
|
|
15
27
|
return unless skill_evolution_enabled?
|
|
16
28
|
return if @is_subagent
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
if @skill_execution_context
|
|
31
|
+
# Scenario 2: Reflect on executed skill (may invoke skill-creator
|
|
32
|
+
# to UPDATE the existing skill, but will not create a new one).
|
|
33
|
+
maybe_reflect_on_skill
|
|
34
|
+
else
|
|
35
|
+
# Scenario 1: Auto-create new skill from complex task.
|
|
36
|
+
maybe_create_skill_from_task
|
|
37
|
+
end
|
|
23
38
|
end
|
|
24
39
|
|
|
25
40
|
# Check if skill evolution is enabled in config
|
|
@@ -33,12 +33,46 @@ module Clacky
|
|
|
33
33
|
def parse_skill_command(input)
|
|
34
34
|
return { matched: false } unless input.start_with?("/")
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# Split off the first whitespace-delimited token after the leading "/".
|
|
37
|
+
# Shape of a slash command:
|
|
38
|
+
# /<command>
|
|
39
|
+
# /<command> <arguments...>
|
|
40
|
+
#
|
|
41
|
+
# The key distinction we need to make is "slash command" vs. "filesystem
|
|
42
|
+
# path starting with /". Paths look like "/xxx/yyy", "/Users/alice/foo",
|
|
43
|
+
# "/tmp/bar" — what they all share is a *second* "/" inside the first
|
|
44
|
+
# token. Slash commands, on the other hand, may legitimately contain
|
|
45
|
+
# non-slug characters like ':' or '.' (e.g. "/guizang-ppt-skill:create"),
|
|
46
|
+
# so we deliberately DO NOT require the command to be a clean slug here —
|
|
47
|
+
# find_by_command handles the lookup, and a pilot-error like "/foo.bar"
|
|
48
|
+
# should still surface a friendly "skill not found" notice.
|
|
49
|
+
#
|
|
50
|
+
# Rejected as slash commands (treated as plain user messages):
|
|
51
|
+
# - "/", "//", "/*.rb" — token is empty or begins with a separator/glob
|
|
52
|
+
# - "/ leading space" — whitespace immediately after /
|
|
53
|
+
# - "/Users/alice/foo" — second "/" inside the first token ⇒ a path
|
|
54
|
+
# - "/xxxx/zzzz/" — same
|
|
55
|
+
#
|
|
56
|
+
# Accepted (routed to find_by_command, may yield :not_found notice):
|
|
57
|
+
# - "/commit"
|
|
58
|
+
# - "/skill-add https://…" — "/" appears only in arguments, fine
|
|
59
|
+
# - "/guizang-ppt-skill:create", "/foo.bar" — non-slug but no path shape
|
|
60
|
+
match = input.match(%r{^/(\S+?)(?:\s+(.*))?$})
|
|
37
61
|
return { matched: false } unless match
|
|
38
62
|
|
|
39
63
|
skill_name = match[1]
|
|
40
64
|
arguments = match[2] || ""
|
|
41
65
|
|
|
66
|
+
# Reject path-like first tokens: anything containing a "/" after the
|
|
67
|
+
# leading one belongs to the filesystem, not the command namespace.
|
|
68
|
+
# This also naturally rejects "" (from "/" alone) and "*…" / ".…" style
|
|
69
|
+
# tokens because they won't be registered as a command — but those edge
|
|
70
|
+
# cases fall through to :not_found which is acceptable. The main goal is
|
|
71
|
+
# to stop pasted paths like "/Users/foo/bar" from producing a bogus
|
|
72
|
+
# "skill /Users/foo/bar not found" reply.
|
|
73
|
+
return { matched: false } if skill_name.include?("/")
|
|
74
|
+
return { matched: false } if skill_name.empty?
|
|
75
|
+
|
|
42
76
|
skill = @skill_loader.find_by_command("/#{skill_name}")
|
|
43
77
|
return { matched: true, found: false, skill_name: skill_name, reason: :not_found } unless skill
|
|
44
78
|
|
|
@@ -169,6 +169,17 @@ module Clacky
|
|
|
169
169
|
# Inject TODO reminder for non-todo_manager tools
|
|
170
170
|
formatted_result = inject_todo_reminder(call[:name], formatted_result)
|
|
171
171
|
|
|
172
|
+
# Extract image_inject sidecar before building the tool content string.
|
|
173
|
+
# image_inject carries the base64 payload that must be delivered as a
|
|
174
|
+
# follow-up `role:"user"` message (OpenAI/OpenRouter/Gemini only accept
|
|
175
|
+
# image_url blocks in user messages, not in tool messages).
|
|
176
|
+
# Strip it from the content sent to the API so it isn't tokenised as text.
|
|
177
|
+
image_inject = nil
|
|
178
|
+
if formatted_result.is_a?(Hash) && formatted_result[:image_inject]
|
|
179
|
+
image_inject = formatted_result[:image_inject]
|
|
180
|
+
formatted_result = formatted_result.reject { |k, _| k == :image_inject }
|
|
181
|
+
end
|
|
182
|
+
|
|
172
183
|
# If the tool returned a plain string, use it directly (avoids double-escaping).
|
|
173
184
|
# If it returned an Array (e.g. multipart vision blocks with image + text),
|
|
174
185
|
# pass it through as-is so format_tool_results can send it to the API.
|
|
@@ -182,10 +193,9 @@ module Clacky
|
|
|
182
193
|
JSON.generate(formatted_result)
|
|
183
194
|
end
|
|
184
195
|
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
196
|
+
result = { id: call[:id], content: content }
|
|
197
|
+
result[:image_inject] = image_inject if image_inject
|
|
198
|
+
result
|
|
189
199
|
end
|
|
190
200
|
|
|
191
201
|
# Build error result for tool execution
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -883,6 +883,36 @@ module Clacky
|
|
|
883
883
|
|
|
884
884
|
formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
|
|
885
885
|
formatted_messages.each { |msg| @history.append(msg.merge(task_id: @current_task_id)) }
|
|
886
|
+
|
|
887
|
+
# Append a follow-up `role:"user"` message for any image payloads that
|
|
888
|
+
# could not be delivered inside the tool message.
|
|
889
|
+
#
|
|
890
|
+
# Background: OpenAI-compatible APIs (OpenRouter, Gemini, GPT-4o, etc.)
|
|
891
|
+
# only accept image_url content blocks in `role:"user"` messages. Putting
|
|
892
|
+
# base64 data in a `role:"tool"` message causes it to be JSON-encoded as
|
|
893
|
+
# plain text, inflating token counts by 20-40x. The tool result carries a
|
|
894
|
+
# plain-text description for the LLM; the actual image is delivered here.
|
|
895
|
+
tool_results.each do |tr|
|
|
896
|
+
inject = tr[:image_inject]
|
|
897
|
+
next unless inject
|
|
898
|
+
|
|
899
|
+
mime_type = inject[:mime_type]
|
|
900
|
+
base64_data = inject[:base64_data]
|
|
901
|
+
path = inject[:path]
|
|
902
|
+
next unless mime_type && base64_data
|
|
903
|
+
|
|
904
|
+
data_url = "data:#{mime_type};base64,#{base64_data}"
|
|
905
|
+
image_content = [
|
|
906
|
+
{ type: "text", text: "[Image from file_reader: #{File.basename(path.to_s)}]" },
|
|
907
|
+
{ type: "image_url", image_url: { url: data_url } }
|
|
908
|
+
]
|
|
909
|
+
@history.append({
|
|
910
|
+
role: "user",
|
|
911
|
+
content: image_content,
|
|
912
|
+
system_injected: true,
|
|
913
|
+
task_id: @current_task_id
|
|
914
|
+
})
|
|
915
|
+
end
|
|
886
916
|
end
|
|
887
917
|
|
|
888
918
|
# Interrupt the agent's current run
|
|
@@ -1397,6 +1427,7 @@ module Clacky
|
|
|
1397
1427
|
].compact.join(". ")
|
|
1398
1428
|
|
|
1399
1429
|
content = "[Session context: #{parts}]"
|
|
1430
|
+
|
|
1400
1431
|
@history.append({
|
|
1401
1432
|
role: "user",
|
|
1402
1433
|
content: content,
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -158,7 +158,7 @@ module Clacky
|
|
|
158
158
|
|
|
159
159
|
def initialize(options = {})
|
|
160
160
|
@permission_mode = validate_permission_mode(options[:permission_mode])
|
|
161
|
-
@max_tokens = options[:max_tokens] ||
|
|
161
|
+
@max_tokens = options[:max_tokens] || 16384
|
|
162
162
|
@verbose = options[:verbose] || false
|
|
163
163
|
@enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
|
|
164
164
|
# Enable prompt caching by default for cost savings
|
|
@@ -549,6 +549,21 @@ module Clacky
|
|
|
549
549
|
@models.find { |m| m["type"] == type }
|
|
550
550
|
end
|
|
551
551
|
|
|
552
|
+
# Find model by composite key (model name + base_url).
|
|
553
|
+
# Used when restoring a session to match its original model without relying
|
|
554
|
+
# on the runtime-only id (which changes on every process restart).
|
|
555
|
+
# base_url is optional for backward compatibility with sessions saved
|
|
556
|
+
# before base_url was persisted.
|
|
557
|
+
# @param model_name [String] the model's "model" field (e.g. "dsk-deepseek-v4-pro")
|
|
558
|
+
# @param base_url [String, nil] the model's "base_url" field
|
|
559
|
+
# @return [Hash, nil] the matching model entry or nil
|
|
560
|
+
def find_model_by_name_and_url(model_name, base_url = nil)
|
|
561
|
+
@models.find do |m|
|
|
562
|
+
m["model"] == model_name &&
|
|
563
|
+
(base_url.nil? || m["base_url"] == base_url)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
552
567
|
# Get the default model (type: default)
|
|
553
568
|
# Falls back to current_model for backward compatibility
|
|
554
569
|
def default_model
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -964,16 +964,24 @@ module Clacky
|
|
|
964
964
|
key = fetch_decryption_key(skill_id: skill_id, skill_version_id: skill_version_id)
|
|
965
965
|
|
|
966
966
|
ciphertext = File.binread(enc_path)
|
|
967
|
-
pt = aes_gcm_decrypt(key, ciphertext, file_meta["iv"], file_meta["tag"])
|
|
968
967
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
968
|
+
if ciphertext.nil? || ciphertext.empty?
|
|
969
|
+
# AES-GCM of empty data still produces 16+ bytes (auth tag + IV).
|
|
970
|
+
# A 0-byte file means the skill package is corrupted; skip
|
|
971
|
+
# decryption and produce an empty output so the skill can still run.
|
|
972
|
+
""
|
|
973
|
+
else
|
|
974
|
+
pt = aes_gcm_decrypt(key, ciphertext, file_meta["iv"], file_meta["tag"])
|
|
975
|
+
|
|
976
|
+
# Integrity check
|
|
977
|
+
actual = Digest::SHA256.hexdigest(pt)
|
|
978
|
+
expected = file_meta["original_checksum"]
|
|
979
|
+
if expected && actual != expected
|
|
980
|
+
raise "Checksum mismatch for #{rel_plain}: expected #{expected}, got #{actual}"
|
|
981
|
+
end
|
|
975
982
|
|
|
976
|
-
|
|
983
|
+
pt
|
|
984
|
+
end
|
|
977
985
|
else
|
|
978
986
|
# Mock/plain skill: raw bytes
|
|
979
987
|
File.binread(enc_path).force_encoding("UTF-8")
|
data/lib/clacky/client.rb
CHANGED
|
@@ -15,6 +15,12 @@ module Clacky
|
|
|
15
15
|
@use_anthropic_format = anthropic_format
|
|
16
16
|
# Detect Bedrock: ABSK key prefix (native AWS) or abs- model prefix (Clacky AI proxy)
|
|
17
17
|
@use_bedrock = MessageFormat::Bedrock.bedrock_api_key?(api_key, model)
|
|
18
|
+
|
|
19
|
+
# Determine vision support once at construction time.
|
|
20
|
+
# Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
|
|
21
|
+
# content blocks; the conversion layer strips them when this is false.
|
|
22
|
+
provider_id = Providers.resolve_provider(base_url: @base_url, api_key: @api_key)
|
|
23
|
+
@vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
# Returns true when the client is using the AWS Bedrock Converse API.
|
|
@@ -185,7 +191,10 @@ module Clacky
|
|
|
185
191
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
186
192
|
messages = apply_message_caching(messages) if caching_enabled
|
|
187
193
|
|
|
188
|
-
body = MessageFormat::OpenAI.build_request_body(
|
|
194
|
+
body = MessageFormat::OpenAI.build_request_body(
|
|
195
|
+
messages, model, tools, max_tokens, caching_enabled,
|
|
196
|
+
vision_supported: @vision_supported
|
|
197
|
+
)
|
|
189
198
|
response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
|
|
190
199
|
|
|
191
200
|
raise_error(response) unless response.status == 200
|
|
@@ -183,12 +183,20 @@ Print a success summary:
|
|
|
183
183
|
```
|
|
184
184
|
|
|
185
185
|
### 4. Start Development Server
|
|
186
|
-
After the script completes,
|
|
186
|
+
After the script completes, read the `.1024` config file in the project root
|
|
187
|
+
to find the `run_command`, then start it in the background via the terminal tool:
|
|
188
|
+
|
|
187
189
|
```
|
|
188
|
-
|
|
190
|
+
# First, read .1024 to get the run_command (usually `bin/dev` for Rails):
|
|
191
|
+
file_reader(path: ".1024")
|
|
192
|
+
|
|
193
|
+
# Then start the server in the background:
|
|
194
|
+
terminal(command: "<run_command from .1024>", background: true)
|
|
189
195
|
```
|
|
190
196
|
|
|
191
|
-
**Important**: If
|
|
197
|
+
**Important**: If the terminal call returns a session_id (and no error), the
|
|
198
|
+
server has started successfully. You can inspect logs later by polling the
|
|
199
|
+
same session_id with an empty input.
|
|
192
200
|
|
|
193
201
|
Then inform the user and ask what to develop next:
|
|
194
202
|
```
|
|
@@ -210,7 +218,7 @@ What would you like to develop next?
|
|
|
210
218
|
- bin/setup fails → Show error, suggest running `./bin/setup` manually
|
|
211
219
|
- Cloud project creation fails → Soft-fail with warning, continue to start server
|
|
212
220
|
- workspace_key missing → Ask user interactively; skip cloud init if user declines
|
|
213
|
-
-
|
|
221
|
+
- Dev server fails to start → Poll the terminal session (empty input) to check logs, verify database status
|
|
214
222
|
|
|
215
223
|
## Example Interaction
|
|
216
224
|
User: "/new"
|
|
@@ -224,5 +232,5 @@ Response:
|
|
|
224
232
|
6. Project setup complete!
|
|
225
233
|
7. Initializing cloud project binding...
|
|
226
234
|
8. ✅ Cloud project created and config injected into config/application.yml!
|
|
227
|
-
9. Starting development server
|
|
235
|
+
9. Starting development server via terminal (background)...
|
|
228
236
|
10. ✨ Server running! Visit http://localhost:3000
|