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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -5
  3. data/lib/clacky/agent/message_compressor.rb +46 -8
  4. data/lib/clacky/agent/message_compressor_helper.rb +56 -22
  5. data/lib/clacky/agent/session_serializer.rb +23 -1
  6. data/lib/clacky/agent/skill_evolution.rb +21 -6
  7. data/lib/clacky/agent/skill_manager.rb +35 -1
  8. data/lib/clacky/agent/tool_executor.rb +14 -4
  9. data/lib/clacky/agent.rb +31 -0
  10. data/lib/clacky/agent_config.rb +16 -1
  11. data/lib/clacky/brand_config.rb +16 -8
  12. data/lib/clacky/client.rb +10 -1
  13. data/lib/clacky/default_skills/new/SKILL.md +13 -5
  14. data/lib/clacky/default_skills/recall-memory/SKILL.md +0 -1
  15. data/lib/clacky/message_format/open_ai.rb +80 -3
  16. data/lib/clacky/providers.rb +7 -18
  17. data/lib/clacky/server/browser_manager.rb +25 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +43 -3
  19. data/lib/clacky/server/channel/channel_ui_controller.rb +2 -2
  20. data/lib/clacky/server/web_ui_controller.rb +1 -1
  21. data/lib/clacky/session_manager.rb +105 -1
  22. data/lib/clacky/tools/browser.rb +0 -57
  23. data/lib/clacky/tools/file_reader.rb +26 -10
  24. data/lib/clacky/tools/security.rb +67 -38
  25. data/lib/clacky/tools/terminal/persistent_session.rb +16 -6
  26. data/lib/clacky/tools/terminal.rb +117 -12
  27. data/lib/clacky/tools/todo_manager.rb +117 -30
  28. data/lib/clacky/utils/login_shell.rb +72 -0
  29. data/lib/clacky/utils/model_pricing.rb +44 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +7 -0
  32. data/lib/clacky/web/index.html +7 -1
  33. data/lib/clacky/web/onboard.js +38 -0
  34. data/lib/clacky/web/sessions.js +2 -2
  35. data/lib/clacky.rb +1 -1
  36. data/scripts/install.ps1 +76 -68
  37. metadata +2 -2
  38. data/lib/clacky/tools/run_project.rb +0 -295
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6793e56434ea9d620acea58d78487dcee09a204ec1fd692dd6057397334b9fad
4
- data.tar.gz: 44e54de50aca54413f3668ee4f01b7c16673005dc61752cf2829d47c921a4734
3
+ metadata.gz: afc12c94c2b8b7580ca948625cc6c106004bbf385f341c783e36e1be9d93fd82
4
+ data.tar.gz: 95508d829f02270b3fce4849b21e29b6766a46d9c663d47e37df817aed456da5
5
5
  SHA512:
6
- metadata.gz: 53696e16fd895822b06b613edcbd7ca154e7f7c50f813ef203ace7626209c725a5a1560d7f19a4cc71685b480761c64598162fac96f1d9a7b4bc61aec28d4d35
7
- data.tar.gz: 7d5554d91399d9a07a4396c6400189f8a404691aa87fcc3f5c31a828d4044840741895003747932de2462162aef670996d30a487352bf6b2656ff08fa2e2ceb9
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
- - **首次配置 API Key 时报 JSON 解析错误。** 初始化向导(Onboard)保存 API 配置时调用了已废弃的旧接口 `POST /api/config`,该接口在 beta.2 重构后已不存在,服务器返回 404 导致前端报 `Unexpected token 'N', "Not Found" is not valid JSON`。现已修复,改为调用正确的新接口 `POST /api/config/models`。
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 `/model` or `/provider` switches, which could leave model-specific instructions out of sync. The agent now re-injects the correct system prompt on every model change.
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 (`/model`, `/provider`) now work seamlessly, server-side routing handles dynamic endpoints correctly, and switching between all provider types is more reliable.
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
- parsed_messages = parse_compressed_result(compressed_content, chunk_path: chunk_path)
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
- # Inject chunk anchor so AI knows where to find original conversation
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📁 **Original conversation archived at:** `#{chunk_path}`\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
- # Count existing compressed_summary messages in history to determine the next chunk index.
159
- # Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
160
- # chunk files, creating circular chunk references. Counting from history is always accurate.
161
- existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
162
- chunk_index = existing_chunk_count + 1
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: @message_compressor.parse_topics(compressed_content)
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
- # Save the messages being compressed to a chunk MD file for future recall
336
- # File path: ~/.clacky/sessions/{datetime}-{short_id}-chunk-{n}.md
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
- sessions_dir = Clacky::SessionManager::SESSIONS_DIR
361
- datetime = Time.parse(@created_at).strftime("%Y-%m-%d-%H-%M-%S")
362
- short_id = @session_id[0..7]
363
- base_name = "#{datetime}-#{short_id}"
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
- chunk_path
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
- # Scenario 2: Reflect on executed skill (if one just ran)
19
- maybe_reflect_on_skill if @skill_execution_context
20
-
21
- # Scenario 1: Auto-create new skill from complex task
22
- maybe_create_skill_from_task
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
- match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
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
- id: call[:id],
187
- content: content
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,
@@ -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] || 8192
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
@@ -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
- # Integrity check
970
- actual = Digest::SHA256.hexdigest(pt)
971
- expected = file_meta["original_checksum"]
972
- if expected && actual != expected
973
- raise "Checksum mismatch for #{rel_plain}: expected #{expected}, got #{actual}"
974
- end
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
- pt
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(messages, model, tools, max_tokens, caching_enabled)
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, use the run_project tool to start the server:
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
- run_project(action: "start")
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 run_project executes without errors, the server has started successfully.
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
- - run_project fails → Check logs with `run_project(action: "output")` and verify database status
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 with run_project...
235
+ 9. Starting development server via terminal (background)...
228
236
  10. ✨ Server running! Visit http://localhost:3000