openclacky 1.4.0 → 1.5.0
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 +55 -0
- data/lib/clacky/agent/llm_caller.rb +3 -3
- data/lib/clacky/agent/session_serializer.rb +50 -9
- data/lib/clacky/agent.rb +4 -5
- data/lib/clacky/brand_config.rb +102 -10
- data/lib/clacky/client.rb +22 -16
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
- data/lib/clacky/extension/api_extension.rb +5 -6
- data/lib/clacky/extension/loader.rb +16 -2
- data/lib/clacky/extension/packager.rb +10 -1
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/mcp/http_transport.rb +1 -1
- data/lib/clacky/platform_http_client.rb +9 -7
- data/lib/clacky/providers.rb +113 -5
- data/lib/clacky/server/http_server.rb +131 -38
- data/lib/clacky/server/session_registry.rb +9 -0
- data/lib/clacky/tools/grep.rb +6 -1
- data/lib/clacky/utils/environment_detector.rb +16 -9
- data/lib/clacky/utils/file_processor.rb +21 -24
- data/lib/clacky/utils/model_pricing.rb +85 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +441 -48
- data/lib/clacky/web/app.js +29 -6
- data/lib/clacky/web/components/onboard.js +21 -7
- data/lib/clacky/web/features/backup/view.js +4 -3
- data/lib/clacky/web/features/extensions/store.js +56 -4
- data/lib/clacky/web/features/extensions/view.js +54 -24
- data/lib/clacky/web/features/mcp/view.js +5 -2
- data/lib/clacky/web/features/new-session/store.js +8 -4
- data/lib/clacky/web/features/version/view.js +5 -1
- data/lib/clacky/web/features/workspace/store.js +11 -0
- data/lib/clacky/web/features/workspace/view.js +10 -4
- data/lib/clacky/web/i18n.js +58 -36
- data/lib/clacky/web/index.html +74 -7
- data/lib/clacky/web/sessions.js +72 -15
- data/lib/clacky/web/settings.js +22 -3
- data/lib/clacky/web/theme.js +27 -58
- data/scripts/uninstall.sh +1 -1
- metadata +2 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85707cf41b28f7283581fde4a77cbdf136ac525d9671743c13cfe268cf71c230
|
|
4
|
+
data.tar.gz: f2ccf13cf87f8aeb4e9ac2a27a7f7d3f1c8be53f196d52b39d412ae54bbd791a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33a52baaf3983e25e121d6e5200424d57b20d50af5226168df47b730e202add9ab296ecf9d480dc1380302d95b24273ad399b808290954875a139748c4879de8
|
|
7
|
+
data.tar.gz: c06c55d0765c8f657606dae02f214aeb938dc484522af5698bcd408b34379160e2bd850a9abdc91a82d4bf3f9925a20b698c3973a35839bb8886170f10d4215d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.0] - 2026-07-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Volcengine Ark (Doubao) provider with endpoint-aware model-id mapping
|
|
11
|
+
- Brand tab in extension marketplace for brand-private extensions
|
|
12
|
+
- Auto-install all brand skills on license activation
|
|
13
|
+
- Multi-background theme support with refactored theme engine
|
|
14
|
+
- Short hash suffix in session-info-bar session file label with tooltips
|
|
15
|
+
- Guide ext-develop skill to the host-API reference
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- Ext-developer skill prompt updated with engineering discipline guidelines
|
|
19
|
+
- ext-develop skill merged into a single consolidated guide
|
|
20
|
+
- Brand status fetch moved from view layer to store (cleaner architecture)
|
|
21
|
+
- README check prompted before publishing extension
|
|
22
|
+
- MCP page copy updated (removed Clacky brand name)
|
|
23
|
+
- Floating window border effect added to content area
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- CRLF normalization in SSE chunks for servers sending `\r\n\r\n` event separators
|
|
27
|
+
- Subagent tool-item completion with DOM fallback when element not yet in DOM
|
|
28
|
+
- AgentError now carries raw_message for LLM network/timeout errors
|
|
29
|
+
- Extension data persisted outside package dir (C-5729)
|
|
30
|
+
- Idle compression worker shutdown before process exit
|
|
31
|
+
- Install button shown for all extension origins when download_url is present
|
|
32
|
+
|
|
33
|
+
## [1.4.1] - 2026-07-20
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- Extension README edit/validate flow and screenshot upload in ext-studio publish
|
|
37
|
+
- Kimi k3 and k2.7-code models added to Kimi preset
|
|
38
|
+
- New session split-button with improved styling
|
|
39
|
+
- Rename-on-name-conflict flow in ext-studio publish
|
|
40
|
+
- Skip OpenClacky AI Keys card for branded onboarding
|
|
41
|
+
|
|
42
|
+
### Improved
|
|
43
|
+
- Secondary API host added with reduced failover latency
|
|
44
|
+
- Extensions default sort changed to most-installed (downloads first)
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- WSL file panel "Copy Path" now copies Windows-accessible UNC path via `wslpath` (C-5693)
|
|
48
|
+
- Edit button hidden on compressed user messages archived to chunk (C-5723)
|
|
49
|
+
- Message-edit Enter key blocked during IME composition (C-5742)
|
|
50
|
+
- Markdown images with `file://~/` tilde paths now render correctly (C-5747)
|
|
51
|
+
- Local `file://` path resolution unified for Windows drive letters (C-5746, C-5708)
|
|
52
|
+
- Brand extension installs under its real ID, not as unpacked (C-5735)
|
|
53
|
+
- Backup success status no longer stays displayed forever (C-5711)
|
|
54
|
+
- `grep` tool defaults to recursive behavior
|
|
55
|
+
- `display_name` fields included in installed extensions API response
|
|
56
|
+
- Published list reloads after setup entry point and publish
|
|
57
|
+
- Unpublish button size matches iterate button in ext-studio
|
|
58
|
+
- `device_token` correctly passed in screenshot upload multipart fields
|
|
59
|
+
- MCP tool list rendering fixed and display styling improved
|
|
60
|
+
- Uninstall script now removes all openclacky gem versions
|
|
61
|
+
|
|
7
62
|
## [1.4.0] - 2026-07-14
|
|
8
63
|
|
|
9
64
|
### Added
|
|
@@ -201,7 +201,7 @@ module Clacky
|
|
|
201
201
|
sleep retry_delay
|
|
202
202
|
retry
|
|
203
203
|
else
|
|
204
|
-
raise AgentError
|
|
204
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
|
|
205
205
|
end
|
|
206
206
|
|
|
207
207
|
rescue Faraday::ConnectionFailed, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE => e
|
|
@@ -236,7 +236,7 @@ module Clacky
|
|
|
236
236
|
else
|
|
237
237
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
238
238
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
239
|
-
raise AgentError
|
|
239
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
rescue RetryableError => e
|
|
@@ -274,7 +274,7 @@ module Clacky
|
|
|
274
274
|
else
|
|
275
275
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
276
276
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
277
|
-
raise AgentError
|
|
277
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
|
|
278
278
|
end
|
|
279
279
|
|
|
280
280
|
rescue Clacky::BadRequestError => e
|
|
@@ -68,6 +68,7 @@ module Clacky
|
|
|
68
68
|
}
|
|
69
69
|
end
|
|
70
70
|
backfill_task_meta_from_history!
|
|
71
|
+
heal_missing_chunk_paths!
|
|
71
72
|
|
|
72
73
|
# Check if the session ended with an error.
|
|
73
74
|
# We record the rollback intent here but do NOT truncate history immediately —
|
|
@@ -143,6 +144,31 @@ module Clacky
|
|
|
143
144
|
end
|
|
144
145
|
end
|
|
145
146
|
|
|
147
|
+
# Repair compressed_summary messages whose chunk_path is missing or points
|
|
148
|
+
# at a file that no longer exists. This happens when a hot restart's
|
|
149
|
+
# SIGKILL killed the worker after the chunk file was written to disk but
|
|
150
|
+
# before its path was persisted into session.json — the archived history
|
|
151
|
+
# then never renders on replay. Rediscover the chunk on disk from
|
|
152
|
+
# session_id + created_at and write the real path back, once, at load time.
|
|
153
|
+
private def heal_missing_chunk_paths!
|
|
154
|
+
return unless @session_id && @created_at
|
|
155
|
+
|
|
156
|
+
broken = @history.to_a.select do |m|
|
|
157
|
+
m[:compressed_summary] && !(m[:chunk_path] && File.exist?(m[:chunk_path].to_s))
|
|
158
|
+
end
|
|
159
|
+
return if broken.empty?
|
|
160
|
+
|
|
161
|
+
chunks = session_manager.chunks_for_current(@session_id, @created_at)
|
|
162
|
+
return if chunks.empty?
|
|
163
|
+
|
|
164
|
+
latest_path = chunks.last[:path]
|
|
165
|
+
broken.each { |m| m[:chunk_path] = latest_path }
|
|
166
|
+
|
|
167
|
+
session_manager.save(to_session_data(preserve_updated_at: true))
|
|
168
|
+
rescue => e
|
|
169
|
+
Clacky::Logger.warn("heal_missing_chunk_paths! failed: #{e.message}")
|
|
170
|
+
end
|
|
171
|
+
|
|
146
172
|
private def persisted_card_field(key)
|
|
147
173
|
card_id = @config.current_model_id
|
|
148
174
|
return nil unless card_id
|
|
@@ -257,12 +283,14 @@ module Clacky
|
|
|
257
283
|
end
|
|
258
284
|
|
|
259
285
|
if is_real_user_msg
|
|
260
|
-
# Start a new round at each real user message
|
|
261
|
-
|
|
286
|
+
# Start a new round at each real user message.
|
|
287
|
+
# editable: true — this message still lives in the active in-memory
|
|
288
|
+
# @history, so truncate_from_created_at can locate and truncate it.
|
|
289
|
+
current_round = { user_msg: msg, events: [], editable: true }
|
|
262
290
|
rounds << current_round
|
|
263
291
|
elsif current_round
|
|
264
292
|
current_round[:events] << msg
|
|
265
|
-
elsif msg[:compressed_summary]
|
|
293
|
+
elsif msg[:compressed_summary]
|
|
266
294
|
# Compressed summary sitting before any user rounds — expand ALL chunk
|
|
267
295
|
# MD files that belong to the same session (siblings of chunk_path),
|
|
268
296
|
# in chunk-index ascending order.
|
|
@@ -272,15 +300,23 @@ module Clacky
|
|
|
272
300
|
# points at the newest chunk). Older chunks (chunk-1..chunk-N-1) are
|
|
273
301
|
# referenced only as basenames inside the summary text. Expanding just
|
|
274
302
|
# msg[:chunk_path] would therefore lose all prior chunks on replay.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
#
|
|
304
|
+
# chunk_path may be blank when a hot restart's SIGKILL killed the
|
|
305
|
+
# worker after the chunk file was written but before its path was
|
|
306
|
+
# persisted into session.json. Fall back to discovering the chunks
|
|
307
|
+
# on disk from session_id + created_at so the history is not lost.
|
|
308
|
+
chunk_paths = if msg[:chunk_path].to_s.empty?
|
|
309
|
+
session_manager.chunks_for_current(@session_id, @created_at).map { |c| c[:path] }
|
|
310
|
+
else
|
|
311
|
+
sibling_chunks_of(msg[:chunk_path])
|
|
312
|
+
end
|
|
313
|
+
chunk_rounds = chunk_paths.flat_map { |p| parse_chunk_md_to_rounds(p) }
|
|
278
314
|
rounds.concat(chunk_rounds)
|
|
279
315
|
# After expanding, treat the last chunk round as the current round so that
|
|
280
316
|
# any orphaned assistant/tool messages that follow in session.json (belonging
|
|
281
317
|
# to the same task whose user message was compressed into the chunk) get
|
|
282
318
|
# appended here instead of being silently discarded.
|
|
283
|
-
current_round = rounds.last
|
|
319
|
+
current_round = rounds.last unless chunk_rounds.empty?
|
|
284
320
|
elsif rounds.last
|
|
285
321
|
# Orphaned non-user message with no current_round yet (e.g. recent_messages
|
|
286
322
|
# after compression started mid-task with no leading user message).
|
|
@@ -325,7 +361,8 @@ module Clacky
|
|
|
325
361
|
preview_path: f[:preview_path] || f["preview_path"] }
|
|
326
362
|
}
|
|
327
363
|
all_files = image_files + disk_files
|
|
328
|
-
ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files
|
|
364
|
+
ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
|
|
365
|
+
editable: round[:editable] != false)
|
|
329
366
|
|
|
330
367
|
round[:events].each do |ev|
|
|
331
368
|
# Skip system-injected messages (e.g. synthetic skill content, memory prompts)
|
|
@@ -480,7 +517,11 @@ module Clacky
|
|
|
480
517
|
created_at: synthetic_ts,
|
|
481
518
|
_from_chunk: true
|
|
482
519
|
},
|
|
483
|
-
events: []
|
|
520
|
+
events: [],
|
|
521
|
+
# editable: false — this message was archived into a chunk MD and no
|
|
522
|
+
# longer exists in the active in-memory @history, so it cannot be
|
|
523
|
+
# truncated/edited (truncate_from_created_at would silently no-op).
|
|
524
|
+
editable: false
|
|
484
525
|
}
|
|
485
526
|
rounds << current_round
|
|
486
527
|
elsif current_round
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -1862,11 +1862,10 @@ module Clacky
|
|
|
1862
1862
|
files = []
|
|
1863
1863
|
content.scan(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
|
|
1864
1864
|
inline = $1 == "!"
|
|
1865
|
-
#
|
|
1866
|
-
|
|
1867
|
-
name = File.basename(
|
|
1868
|
-
path
|
|
1869
|
-
Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} expanded=#{path.inspect} exist=#{File.exist?(path)}")
|
|
1865
|
+
# Resolve the AI-emitted path: decode, WSL drive-letter normalize, expand.
|
|
1866
|
+
path = Clacky::Utils::EnvironmentDetector.resolve_local_path($3)
|
|
1867
|
+
name = File.basename(path)
|
|
1868
|
+
Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} resolved=#{path.inspect} exist=#{File.exist?(path)}")
|
|
1870
1869
|
files << { name: name, path: path, inline: inline }
|
|
1871
1870
|
end
|
|
1872
1871
|
{ text: content, files: files }
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -622,7 +622,7 @@ module Clacky
|
|
|
622
622
|
#
|
|
623
623
|
# Returns { success: true, extension: {...} } or
|
|
624
624
|
# { success: false, error: "...", already_exists: Boolean }.
|
|
625
|
-
def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, origin: 'marketplace')
|
|
625
|
+
def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace')
|
|
626
626
|
identity = Clacky::Identity.load
|
|
627
627
|
return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
|
|
628
628
|
|
|
@@ -636,6 +636,7 @@ module Clacky
|
|
|
636
636
|
fields["origin"] = origin.to_s if origin
|
|
637
637
|
fields["status"] = status.to_s if status
|
|
638
638
|
fields["changelog"] = changelog.to_s if changelog
|
|
639
|
+
fields["readme"] = readme.to_s if readme
|
|
639
640
|
|
|
640
641
|
body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data)
|
|
641
642
|
|
|
@@ -700,6 +701,55 @@ module Clacky
|
|
|
700
701
|
end
|
|
701
702
|
end
|
|
702
703
|
|
|
704
|
+
# Update an extension's readme text without publishing a new version.
|
|
705
|
+
# Uses PATCH /api/v1/client/extensions/:id.
|
|
706
|
+
# Returns { success:, extension:, error: }
|
|
707
|
+
def update_extension_readme!(ext_id, readme)
|
|
708
|
+
identity = Clacky::Identity.load
|
|
709
|
+
return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
|
|
710
|
+
|
|
711
|
+
path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
|
|
712
|
+
payload = { "device_token" => identity.device_token, "readme" => readme.to_s }
|
|
713
|
+
result = platform_client.patch(path, payload)
|
|
714
|
+
|
|
715
|
+
if result[:success]
|
|
716
|
+
{ success: true, extension: result[:data]["extension"] }
|
|
717
|
+
else
|
|
718
|
+
body = result[:data] || {}
|
|
719
|
+
{ success: false, error: result[:error] || body["code"] || "Update readme failed" }
|
|
720
|
+
end
|
|
721
|
+
rescue StandardError => e
|
|
722
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Upload any file to the platform's model-agnostic upload endpoint.
|
|
726
|
+
# Returns an orphan blob URL — no extension record required.
|
|
727
|
+
# Uses POST /api/v1/client/uploads.
|
|
728
|
+
# Parameters:
|
|
729
|
+
# data (bytes) - raw file bytes
|
|
730
|
+
# filename (string) - filename including extension
|
|
731
|
+
# content_type (string) - MIME type
|
|
732
|
+
# Returns { success:, url:, filename:, blob_key:, error: }
|
|
733
|
+
def upload_file!(data, filename:, content_type: "image/png")
|
|
734
|
+
identity = Clacky::Identity.load
|
|
735
|
+
return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
|
|
736
|
+
|
|
737
|
+
path = "/api/v1/client/uploads"
|
|
738
|
+
fields = { "device_token" => identity.device_token }
|
|
739
|
+
body_bytes, boundary = build_multipart(fields, "file", filename, data, content_type: content_type)
|
|
740
|
+
result = platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60)
|
|
741
|
+
|
|
742
|
+
if result[:success]
|
|
743
|
+
d = result[:data] || {}
|
|
744
|
+
{ success: true, url: d["url"], filename: d["filename"], blob_key: d["blob_key"] }
|
|
745
|
+
else
|
|
746
|
+
body = result[:data] || {}
|
|
747
|
+
{ success: false, error: result[:error] || body["code"] || "File upload failed" }
|
|
748
|
+
end
|
|
749
|
+
rescue StandardError => e
|
|
750
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
751
|
+
end
|
|
752
|
+
|
|
703
753
|
# Search the public extension marketplace. Anonymous — no license required.
|
|
704
754
|
# Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
|
|
705
755
|
def search_extensions!(query: nil, sort: nil)
|
|
@@ -732,6 +782,41 @@ module Clacky
|
|
|
732
782
|
rescue StandardError => e
|
|
733
783
|
{ success: false, error: "Network error: #{e.message}" }
|
|
734
784
|
end
|
|
785
|
+
|
|
786
|
+
# Fetch detail for a single brand-private extension via the license-gated
|
|
787
|
+
# POST /api/v1/licenses/extension_detail endpoint.
|
|
788
|
+
# The public /api/v1/extensions/:id intentionally omits origin=self extensions,
|
|
789
|
+
# so activated brand users must use this signed request instead.
|
|
790
|
+
# Returns { success:, extension:, error: }.
|
|
791
|
+
def brand_extension_detail!(id)
|
|
792
|
+
return { success: false, error: "License not activated" } unless activated?
|
|
793
|
+
|
|
794
|
+
user_id = parse_user_id_from_key(@license_key)
|
|
795
|
+
ts = Time.now.utc.to_i.to_s
|
|
796
|
+
nonce = SecureRandom.hex(16)
|
|
797
|
+
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
798
|
+
|
|
799
|
+
payload = {
|
|
800
|
+
key_hash: Digest::SHA256.hexdigest(@license_key),
|
|
801
|
+
user_id: user_id.to_s,
|
|
802
|
+
device_id: @device_id,
|
|
803
|
+
timestamp: ts,
|
|
804
|
+
nonce: nonce,
|
|
805
|
+
signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, message),
|
|
806
|
+
id: id.to_s
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
response = api_post("/api/v1/licenses/extension", payload)
|
|
810
|
+
|
|
811
|
+
if response[:success]
|
|
812
|
+
{ success: true, extension: response[:data]["extension"] }
|
|
813
|
+
else
|
|
814
|
+
{ success: false, error: response[:error] || "Extension not found" }
|
|
815
|
+
end
|
|
816
|
+
rescue StandardError => e
|
|
817
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
818
|
+
end
|
|
819
|
+
|
|
735
820
|
# Extensions bundled into the activated license's distribution are free and
|
|
736
821
|
# unencrypted. They are fetched over the same license-HMAC scheme as brand
|
|
737
822
|
# skills and installed into the ExtensionLoader `installed` layer.
|
|
@@ -1163,7 +1248,10 @@ module Clacky
|
|
|
1163
1248
|
# @param on_complete [Proc, nil] Optional callback called with the sync
|
|
1164
1249
|
# results array once all downloads finish (useful for tests / UI feedback).
|
|
1165
1250
|
# @return [Thread, nil] The background thread, or nil if skipped.
|
|
1166
|
-
|
|
1251
|
+
# install_new: when true, install ALL remote skills regardless of whether they
|
|
1252
|
+
# were previously installed. Used on first activation so every brand skill is
|
|
1253
|
+
# available immediately without the user clicking Install one-by-one.
|
|
1254
|
+
def sync_brand_skills_async!(on_complete: nil, install_new: false)
|
|
1167
1255
|
return nil unless activated?
|
|
1168
1256
|
return nil if ENV["CLACKY_TEST"] == "1"
|
|
1169
1257
|
|
|
@@ -1182,13 +1270,17 @@ module Clacky
|
|
|
1182
1270
|
delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name)
|
|
1183
1271
|
end
|
|
1184
1272
|
|
|
1185
|
-
#
|
|
1186
|
-
#
|
|
1187
|
-
#
|
|
1188
|
-
# explicitly from the Brand Skills panel.
|
|
1273
|
+
# When install_new is true (e.g. on first activation) install every
|
|
1274
|
+
# remote skill. Otherwise limit auto-sync to skills already installed
|
|
1275
|
+
# locally that have a newer version available — new skills must be
|
|
1276
|
+
# installed explicitly by the user from the Brand Skills panel.
|
|
1189
1277
|
installed = installed_brand_skills
|
|
1190
|
-
|
|
1191
|
-
|
|
1278
|
+
skills_to_install = if install_new
|
|
1279
|
+
result[:skills].select { |s| s["needs_update"] || !installed.key?(s["name"]) }
|
|
1280
|
+
else
|
|
1281
|
+
result[:skills].select { |s| s["needs_update"] }
|
|
1282
|
+
end
|
|
1283
|
+
results = skills_to_install.map do |skill_info|
|
|
1192
1284
|
install_brand_skill!(skill_info)
|
|
1193
1285
|
end
|
|
1194
1286
|
|
|
@@ -1579,7 +1671,7 @@ module Clacky
|
|
|
1579
1671
|
|
|
1580
1672
|
# Assemble a binary multipart/form-data body: text fields + one file part.
|
|
1581
1673
|
# Kept binary-safe so null bytes in the ZIP survive. Returns [body, boundary].
|
|
1582
|
-
private def build_multipart(fields, file_field, filename, file_bytes)
|
|
1674
|
+
private def build_multipart(fields, file_field, filename, file_bytes, content_type: "application/zip")
|
|
1583
1675
|
boundary = "----ClackyMultipart#{SecureRandom.hex(8)}"
|
|
1584
1676
|
crlf = "\r\n"
|
|
1585
1677
|
parts = []
|
|
@@ -1591,7 +1683,7 @@ module Clacky
|
|
|
1591
1683
|
end
|
|
1592
1684
|
parts << "--#{boundary}#{crlf}"
|
|
1593
1685
|
parts << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"#{crlf}"
|
|
1594
|
-
parts << "Content-Type:
|
|
1686
|
+
parts << "Content-Type: #{content_type}#{crlf}#{crlf}"
|
|
1595
1687
|
parts << file_bytes.b
|
|
1596
1688
|
parts << "#{crlf}--#{boundary}--#{crlf}"
|
|
1597
1689
|
[parts.map(&:b).join, boundary]
|
data/lib/clacky/client.rb
CHANGED
|
@@ -55,17 +55,18 @@ module Clacky
|
|
|
55
55
|
# Test API connection by sending a minimal request.
|
|
56
56
|
# Returns { success: true } or { success: false, error: "..." }.
|
|
57
57
|
def test_connection(model:)
|
|
58
|
+
api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
|
|
58
59
|
if bedrock?
|
|
59
60
|
body = MessageFormat::Bedrock.build_request_body(
|
|
60
|
-
[{ role: :user, content: "hi" }],
|
|
61
|
+
[{ role: :user, content: "hi" }], api_model, [], 16
|
|
61
62
|
).to_json
|
|
62
|
-
response = bedrock_connection.post(bedrock_endpoint(
|
|
63
|
+
response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body }
|
|
63
64
|
elsif anthropic_format?
|
|
64
|
-
minimal_body = { model:
|
|
65
|
+
minimal_body = { model: api_model, max_tokens: 16,
|
|
65
66
|
messages: [{ role: "user", content: "hi" }] }.to_json
|
|
66
67
|
response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = minimal_body }
|
|
67
68
|
else
|
|
68
|
-
minimal_body = { model:
|
|
69
|
+
minimal_body = { model: api_model, max_tokens: 16,
|
|
69
70
|
messages: [{ role: "user", content: "hi" }] }.to_json
|
|
70
71
|
response = openai_connection.post("chat/completions") { |r| r.body = minimal_body }
|
|
71
72
|
end
|
|
@@ -87,16 +88,17 @@ module Clacky
|
|
|
87
88
|
|
|
88
89
|
# Send a messages array and return the reply text.
|
|
89
90
|
def send_messages(messages, model:, max_tokens:)
|
|
91
|
+
api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
|
|
90
92
|
if bedrock?
|
|
91
|
-
body = MessageFormat::Bedrock.build_request_body(messages,
|
|
92
|
-
response = bedrock_connection.post(bedrock_endpoint(
|
|
93
|
+
body = MessageFormat::Bedrock.build_request_body(messages, api_model, [], max_tokens)
|
|
94
|
+
response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body.to_json }
|
|
93
95
|
parse_simple_bedrock_response(response)
|
|
94
96
|
elsif anthropic_format?
|
|
95
|
-
body = MessageFormat::Anthropic.build_request_body(messages,
|
|
97
|
+
body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
|
|
96
98
|
response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
|
|
97
99
|
parse_simple_anthropic_response(response)
|
|
98
100
|
else
|
|
99
|
-
body = { model:
|
|
101
|
+
body = { model: api_model, max_tokens: max_tokens, messages: messages }
|
|
100
102
|
response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
|
|
101
103
|
parse_simple_openai_response(response)
|
|
102
104
|
end
|
|
@@ -127,6 +129,7 @@ module Clacky
|
|
|
127
129
|
# a single synthetic invocation is fired after the response is received,
|
|
128
130
|
# so UI plumbing can be exercised end-to-end without the proxy work.
|
|
129
131
|
def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false, reasoning_effort: nil, on_chunk: nil)
|
|
132
|
+
api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
|
|
130
133
|
caching_enabled = enable_caching && supports_prompt_caching?(model)
|
|
131
134
|
cloned = deep_clone(messages)
|
|
132
135
|
|
|
@@ -141,13 +144,13 @@ module Clacky
|
|
|
141
144
|
response =
|
|
142
145
|
if bedrock?
|
|
143
146
|
streaming_used = !on_chunk.nil?
|
|
144
|
-
send_bedrock_request(cloned,
|
|
147
|
+
send_bedrock_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
|
|
145
148
|
elsif anthropic_format?
|
|
146
149
|
streaming_used = !on_chunk.nil?
|
|
147
|
-
send_anthropic_request(cloned,
|
|
150
|
+
send_anthropic_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
|
|
148
151
|
else
|
|
149
152
|
streaming_used = !on_chunk.nil?
|
|
150
|
-
send_openai_request(cloned,
|
|
153
|
+
send_openai_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
|
|
151
154
|
end
|
|
152
155
|
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
156
|
|
|
@@ -333,17 +336,20 @@ module Clacky
|
|
|
333
336
|
|
|
334
337
|
# ── OpenAI request / response ─────────────────────────────────────────────
|
|
335
338
|
|
|
336
|
-
def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil)
|
|
339
|
+
def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
|
|
337
340
|
# Apply cache_control markers to messages when caching is enabled.
|
|
338
341
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
339
342
|
messages = apply_message_caching(messages) if caching_enabled
|
|
340
343
|
|
|
341
|
-
# Vision support is resolved against the
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
+
# Vision support is resolved against the display model name, which is the
|
|
345
|
+
# key our capability table is declared with. `model` may be an
|
|
346
|
+
# endpoint-specific API id (e.g. Ark payg's "glm-5-2-260617") that the
|
|
347
|
+
# table can't match — so the caller passes the display name separately
|
|
348
|
+
# via capability_model to keep the vision judgement accurate.
|
|
349
|
+
cap_model = capability_model || model
|
|
344
350
|
body = MessageFormat::OpenAI.build_request_body(
|
|
345
351
|
messages, model, tools, max_tokens, caching_enabled,
|
|
346
|
-
vision_supported: Providers.supports?(@provider_id, :vision, model_name:
|
|
352
|
+
vision_supported: Providers.supports?(@provider_id, :vision, model_name: cap_model),
|
|
347
353
|
reasoning_effort: reasoning_effort
|
|
348
354
|
)
|
|
349
355
|
return send_openai_stream_request(body, on_chunk) if on_chunk
|