openclacky 1.5.1 → 1.5.2
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 +24 -0
- data/lib/clacky/agent/llm_caller.rb +48 -0
- data/lib/clacky/agent/message_compressor_helper.rb +7 -0
- data/lib/clacky/agent/session_serializer.rb +42 -0
- data/lib/clacky/agent/skill_manager.rb +7 -0
- data/lib/clacky/agent.rb +58 -1
- data/lib/clacky/agent_config.rb +42 -0
- data/lib/clacky/brand_config.rb +88 -59
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +5 -0
- data/lib/clacky/extension/packager.rb +17 -0
- data/lib/clacky/locales/en.rb +4 -2
- data/lib/clacky/locales/zh.rb +5 -3
- data/lib/clacky/message_format/anthropic.rb +29 -0
- data/lib/clacky/message_history.rb +15 -1
- data/lib/clacky/providers.rb +22 -0
- data/lib/clacky/server/http_server.rb +41 -17
- data/lib/clacky/server/session_registry.rb +4 -2
- data/lib/clacky/skill.rb +4 -0
- data/lib/clacky/tools/invoke_skill.rb +21 -0
- data/lib/clacky/tools/security.rb +11 -13
- data/lib/clacky/ui_interface.rb +2 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +61 -14
- data/lib/clacky/web/features/billing/view.js +5 -0
- data/lib/clacky/web/features/extensions/store.js +9 -2
- data/lib/clacky/web/features/new-session/view.js +61 -0
- data/lib/clacky/web/i18n.js +10 -0
- data/lib/clacky/web/index.html +3 -0
- data/lib/clacky/web/settings.js +41 -12
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb5f68cdd24e5d619fa22b79c643953e6c00f54400d7e89d95a9c99373328a55
|
|
4
|
+
data.tar.gz: 5bc17c114482d78e13d59d5f6b194caffbf9456015bce6082d3bbc63bdf951a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55044dd2a193d5ed5e0b64aefee284fec18cb036369482d55936e7c3c01d8740aecee16e0ca75f1e5d1cc643a4d70d66005f1f5eb61d3bfc02a6152ef3b4155d
|
|
7
|
+
data.tar.gz: 86de30d32d826921245ea60abd5229665eb82445b68f68e6a68f90b519d4b0dd900b077a738c82b65a1d78a3bc974b3d938986445e9149f074b94095ead044c9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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.2] - 2026-07-27
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Starter prompts for ext-developer agent on new session page
|
|
11
|
+
- Secondary gateway endpoint for OpenClacky provider with auto-fallback after all retries exhausted
|
|
12
|
+
- Subagent save support and invoke_skill format improvements
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
- Curly quotes wrapping for starter prompt text
|
|
16
|
+
- Brand license badge now distinguishes unreachable vs expiring states
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- HTTP 429 quota_exceeded now shows correct "quota exceeded" error instead of "rate limit" and stops retrying
|
|
20
|
+
- Search query filter now applies correctly to brand extensions in marketplace
|
|
21
|
+
- Billing page colors unified to warm palette
|
|
22
|
+
- Sub-model switching and card display now use saved provider_id correctly
|
|
23
|
+
- Device revoked error message now includes 15-minute wait hint
|
|
24
|
+
- Extension detail page routes by source param for brand/public distinction
|
|
25
|
+
- Non-slug skill names preserved as name_zh fallback for Chinese matching
|
|
26
|
+
- Sudo and .env writes unblocked in subagent; double-compression skipped
|
|
27
|
+
- Claude tool call ID handling fixed
|
|
28
|
+
- Brand skill preserved when auto-update download fails (C-5754)
|
|
29
|
+
- Extension packing rejected when folder name diverges from ext.yml id (C-5752)
|
|
30
|
+
|
|
7
31
|
## [1.5.1] - 2026-07-24
|
|
8
32
|
|
|
9
33
|
### Fixed
|
|
@@ -201,6 +201,20 @@ module Clacky
|
|
|
201
201
|
sleep retry_delay
|
|
202
202
|
retry
|
|
203
203
|
else
|
|
204
|
+
# All retries exhausted for a timeout. Try the secondary gateway URL if available.
|
|
205
|
+
if !@config.url_fallback_active?
|
|
206
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
207
|
+
if fallback_url
|
|
208
|
+
@config.activate_url_fallback!(fallback_url)
|
|
209
|
+
rebuild_client_for_current_model!
|
|
210
|
+
retries = 0
|
|
211
|
+
@ui&.show_warning(
|
|
212
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
213
|
+
)
|
|
214
|
+
retry
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
204
218
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
|
|
205
219
|
end
|
|
206
220
|
|
|
@@ -234,6 +248,21 @@ module Clacky
|
|
|
234
248
|
sleep retry_delay
|
|
235
249
|
retry
|
|
236
250
|
else
|
|
251
|
+
# All retries on the primary endpoint exhausted for a network-level
|
|
252
|
+
# failure (DNS/TCP/TLS). Try the secondary gateway URL if available.
|
|
253
|
+
if !@config.url_fallback_active?
|
|
254
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
255
|
+
if fallback_url
|
|
256
|
+
@config.activate_url_fallback!(fallback_url)
|
|
257
|
+
rebuild_client_for_current_model!
|
|
258
|
+
retries = 0
|
|
259
|
+
@ui&.show_warning(
|
|
260
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
261
|
+
)
|
|
262
|
+
retry
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
237
266
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
238
267
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
239
268
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
|
|
@@ -272,6 +301,25 @@ module Clacky
|
|
|
272
301
|
sleep retry_delay
|
|
273
302
|
retry
|
|
274
303
|
else
|
|
304
|
+
# All retries on the current endpoint exhausted.
|
|
305
|
+
# If a secondary gateway URL is configured and not yet tried, switch
|
|
306
|
+
# to it now, rebuild the client, and reset the retry counter so the
|
|
307
|
+
# same request gets a fresh MAX_RETRIES_ON_FALLBACK budget on the
|
|
308
|
+
# new endpoint. This is a one-shot switch — url_fallback_active?
|
|
309
|
+
# prevents a second switch if the fallback URL also fails.
|
|
310
|
+
if !@config.url_fallback_active?
|
|
311
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
312
|
+
if fallback_url
|
|
313
|
+
@config.activate_url_fallback!(fallback_url)
|
|
314
|
+
rebuild_client_for_current_model!
|
|
315
|
+
retries = 0
|
|
316
|
+
@ui&.show_warning(
|
|
317
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
318
|
+
)
|
|
319
|
+
retry
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
275
323
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
276
324
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
277
325
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
|
|
@@ -122,6 +122,13 @@ module Clacky
|
|
|
122
122
|
# handle_compression_response, so recent task progress is preserved.
|
|
123
123
|
# @return [Hash, nil] Compression context or nil if not needed
|
|
124
124
|
def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
|
|
125
|
+
# Subagents must not proactively compress — their history is a clone of the
|
|
126
|
+
# parent's and will be discarded when the subagent exits. The parent will
|
|
127
|
+
# compress its own history after the subagent returns, so doing it here is
|
|
128
|
+
# pure waste (same data compressed twice). Context-overflow recovery
|
|
129
|
+
# (force: true) is still allowed as a safety net for long-running subagents.
|
|
130
|
+
return nil if @is_subagent && !force
|
|
131
|
+
|
|
125
132
|
# Check if compression is enabled
|
|
126
133
|
return nil unless @config.enable_compression
|
|
127
134
|
|
|
@@ -641,13 +641,55 @@ module Clacky
|
|
|
641
641
|
|
|
642
642
|
ui.show_tool_result(blk[:content].to_s)
|
|
643
643
|
end
|
|
644
|
+
replay_subagent_transcript(msg, ui)
|
|
644
645
|
|
|
645
646
|
when "tool"
|
|
646
647
|
# OpenAI-format tool result
|
|
647
648
|
ui.show_tool_result(msg[:content].to_s)
|
|
649
|
+
replay_subagent_transcript(msg, ui)
|
|
648
650
|
end
|
|
649
651
|
end
|
|
650
652
|
|
|
653
|
+
# Replay a subagent transcript stored on a tool result message. Emits a
|
|
654
|
+
# bracketed sequence of UI events the frontend can render as a collapsible
|
|
655
|
+
# sub-process block: subagent_start → (assistant_message / tool_call /
|
|
656
|
+
# tool_result)* → subagent_end. No-op when the message carries no transcript.
|
|
657
|
+
def replay_subagent_transcript(msg, ui)
|
|
658
|
+
transcript = msg[:subagent_transcript]
|
|
659
|
+
return unless transcript.is_a?(Hash)
|
|
660
|
+
|
|
661
|
+
events = transcript[:events] || transcript["events"] || []
|
|
662
|
+
return if events.empty?
|
|
663
|
+
|
|
664
|
+
ui.show_subagent_start(
|
|
665
|
+
skill: transcript[:skill] || transcript["skill"],
|
|
666
|
+
iterations: transcript[:iterations] || transcript["iterations"],
|
|
667
|
+
cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
events.each do |ev|
|
|
671
|
+
role = (ev[:role] || ev["role"]).to_s
|
|
672
|
+
content = ev[:content] || ev["content"]
|
|
673
|
+
tool_calls = ev[:tool_calls] || ev["tool_calls"]
|
|
674
|
+
|
|
675
|
+
case role
|
|
676
|
+
when "assistant"
|
|
677
|
+
text = extract_text_from_content(content).to_s.strip
|
|
678
|
+
ui.show_assistant_message(text, files: []) unless text.empty?
|
|
679
|
+
Array(tool_calls).each do |tc|
|
|
680
|
+
name = tc[:name] || tc["name"] || ""
|
|
681
|
+
args_raw = tc[:arguments] || tc["arguments"] || {}
|
|
682
|
+
args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
|
|
683
|
+
ui.show_tool_call(name, args)
|
|
684
|
+
end
|
|
685
|
+
when "tool", "user"
|
|
686
|
+
ui.show_tool_result(extract_text_from_content(content).to_s)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
ui.show_subagent_end
|
|
691
|
+
end
|
|
692
|
+
|
|
651
693
|
# Replace the system message in @messages with a freshly built system prompt.
|
|
652
694
|
# Called after restore_session so newly installed skills and any other
|
|
653
695
|
# configuration changes since the session was saved take effect immediately.
|
|
@@ -639,6 +639,13 @@ module Clacky
|
|
|
639
639
|
# Generate summary
|
|
640
640
|
summary = generate_subagent_summary(subagent)
|
|
641
641
|
|
|
642
|
+
# Capture the subagent's own message trail (everything it appended after
|
|
643
|
+
# the fork) so the parent session can persist and later replay the full
|
|
644
|
+
# subagent process — not just the collapsed summary. Stored transiently
|
|
645
|
+
# on the parent agent; observe() attaches it to the invoke_skill tool
|
|
646
|
+
# result message. Kept out of the LLM payload via INTERNAL_FIELDS.
|
|
647
|
+
@pending_subagent_transcript = extract_subagent_transcript(subagent, skill.identifier)
|
|
648
|
+
|
|
642
649
|
# Mutate the subagent_instructions message in-place to become the result summary
|
|
643
650
|
@history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
|
|
644
651
|
m[:content] = summary
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -105,6 +105,7 @@ module Clacky
|
|
|
105
105
|
@ui = ui # UIController for direct UI interaction
|
|
106
106
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
107
107
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
108
|
+
@pending_subagent_transcript = nil # Subagent trail to attach to the next tool result (observe)
|
|
108
109
|
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
|
|
109
110
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
110
111
|
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
@@ -212,7 +213,7 @@ module Clacky
|
|
|
212
213
|
private def rebuild_client_for_current_model!
|
|
213
214
|
@client = Clacky::Client.new(
|
|
214
215
|
@config.api_key,
|
|
215
|
-
base_url: @config.
|
|
216
|
+
base_url: @config.effective_base_url,
|
|
216
217
|
model: @config.model_name,
|
|
217
218
|
anthropic_format: @config.anthropic_format?
|
|
218
219
|
)
|
|
@@ -249,6 +250,7 @@ module Clacky
|
|
|
249
250
|
id: model["id"],
|
|
250
251
|
model: model["model"],
|
|
251
252
|
base_url: model["base_url"],
|
|
253
|
+
provider_id: model["provider_id"],
|
|
252
254
|
card_model: base_entry&.dig("model"),
|
|
253
255
|
sub_model: sub_model
|
|
254
256
|
}
|
|
@@ -1138,6 +1140,8 @@ module Clacky
|
|
|
1138
1140
|
@history.append(truncated.merge(task_id: @current_task_id))
|
|
1139
1141
|
end
|
|
1140
1142
|
|
|
1143
|
+
attach_pending_subagent_transcript(response)
|
|
1144
|
+
|
|
1141
1145
|
# Append a follow-up `role:"user"` message for any image payloads that
|
|
1142
1146
|
# could not be delivered inside the tool message.
|
|
1143
1147
|
#
|
|
@@ -1181,6 +1185,24 @@ module Clacky
|
|
|
1181
1185
|
end
|
|
1182
1186
|
end
|
|
1183
1187
|
|
|
1188
|
+
# Attach the captured subagent transcript (set by execute_skill_with_subagent)
|
|
1189
|
+
# to the invoke_skill tool result message just appended by observe(). The
|
|
1190
|
+
# transcript rides on the tool message as :subagent_transcript — an internal
|
|
1191
|
+
# field stripped before the LLM call but persisted to session.json and
|
|
1192
|
+
# replayed to the WebUI. Cleared after attaching so it fires exactly once.
|
|
1193
|
+
private def attach_pending_subagent_transcript(response)
|
|
1194
|
+
transcript = @pending_subagent_transcript
|
|
1195
|
+
return unless transcript
|
|
1196
|
+
|
|
1197
|
+
@pending_subagent_transcript = nil
|
|
1198
|
+
|
|
1199
|
+
skill_call = Array(response[:tool_calls]).find { |tc| (tc[:name] || tc.dig(:function, :name)) == "invoke_skill" }
|
|
1200
|
+
target_id = skill_call && skill_call[:id]
|
|
1201
|
+
return unless target_id
|
|
1202
|
+
|
|
1203
|
+
@history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1184
1206
|
# Cap oversized tool result content to keep a single tool message from
|
|
1185
1207
|
# blowing up the prompt budget (issue #218: a 7350-path glob produced a
|
|
1186
1208
|
# ~890k-char result that pushed history past the model context window
|
|
@@ -1541,6 +1563,41 @@ module Clacky
|
|
|
1541
1563
|
parts.join("\n")
|
|
1542
1564
|
end
|
|
1543
1565
|
|
|
1566
|
+
# Extract the subagent's own message trail for persistence/replay.
|
|
1567
|
+
# Returns a trimmed, LLM-free array of {role, content, tool_calls} hashes
|
|
1568
|
+
# capturing only what the subagent did after the fork — system-injected
|
|
1569
|
+
# scaffolding (fork instructions, ack) is dropped. Stored on the parent's
|
|
1570
|
+
# invoke_skill tool result under :subagent_transcript so the WebUI can
|
|
1571
|
+
# render a collapsible sub-process without polluting the main thread.
|
|
1572
|
+
def extract_subagent_transcript(subagent, skill_identifier)
|
|
1573
|
+
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
1574
|
+
new_messages = subagent.history.to_a[parent_count..] || []
|
|
1575
|
+
|
|
1576
|
+
events = new_messages.filter_map do |m|
|
|
1577
|
+
next if m[:system_injected]
|
|
1578
|
+
role = m[:role].to_s
|
|
1579
|
+
next unless %w[assistant tool user].include?(role)
|
|
1580
|
+
|
|
1581
|
+
entry = { role: role }
|
|
1582
|
+
entry[:content] = m[:content] if m[:content] && !m[:content].to_s.empty?
|
|
1583
|
+
if m[:tool_calls].is_a?(Array) && !m[:tool_calls].empty?
|
|
1584
|
+
entry[:tool_calls] = m[:tool_calls].map do |tc|
|
|
1585
|
+
func = tc[:function] || tc
|
|
1586
|
+
{ name: func[:name] || tc[:name], arguments: func[:arguments] || tc[:arguments] || {} }
|
|
1587
|
+
end
|
|
1588
|
+
end
|
|
1589
|
+
entry[:tool_call_id] = m[:tool_call_id] if m[:tool_call_id]
|
|
1590
|
+
entry.key?(:content) || entry.key?(:tool_calls) ? entry : nil
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
{
|
|
1594
|
+
skill: skill_identifier,
|
|
1595
|
+
iterations: subagent.iterations,
|
|
1596
|
+
cost_usd: subagent.total_cost.round(4),
|
|
1597
|
+
events: events
|
|
1598
|
+
}
|
|
1599
|
+
end
|
|
1600
|
+
|
|
1544
1601
|
# Deep clone helper for messages using Marshal
|
|
1545
1602
|
# @param obj [Object] Object to clone
|
|
1546
1603
|
# @return [Object] Deep cloned object
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -1053,6 +1053,48 @@ module Clacky
|
|
|
1053
1053
|
end
|
|
1054
1054
|
end
|
|
1055
1055
|
|
|
1056
|
+
# ── URL-level fallback ─────────────────────────────────────────────────
|
|
1057
|
+
# Independent from the model-name fallback above. When all max_retries
|
|
1058
|
+
# on the primary endpoint are exhausted, the caller may switch to a
|
|
1059
|
+
# secondary gateway URL (same model, different host) via these methods.
|
|
1060
|
+
# The URL fallback is intentionally one-shot per session — we do not
|
|
1061
|
+
# probe or reset it automatically, keeping the logic simple.
|
|
1062
|
+
|
|
1063
|
+
# Look up the fallback base URL for the current model's provider.
|
|
1064
|
+
# Returns nil if the provider has no secondary gateway configured.
|
|
1065
|
+
# @return [String, nil]
|
|
1066
|
+
def fallback_base_url_for_current_provider
|
|
1067
|
+
m = current_model
|
|
1068
|
+
return nil unless m
|
|
1069
|
+
|
|
1070
|
+
provider_id = Clacky::Providers.resolve_provider(
|
|
1071
|
+
base_url: m["base_url"],
|
|
1072
|
+
api_key: m["api_key"]
|
|
1073
|
+
)
|
|
1074
|
+
return nil unless provider_id
|
|
1075
|
+
|
|
1076
|
+
Clacky::Providers.fallback_base_url(provider_id)
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# Activate the URL fallback: override base_url with the secondary gateway.
|
|
1080
|
+
# Idempotent — safe to call multiple times.
|
|
1081
|
+
# @param fallback_url [String] the secondary gateway URL
|
|
1082
|
+
def activate_url_fallback!(fallback_url)
|
|
1083
|
+
@url_fallback_active = true
|
|
1084
|
+
@url_fallback_base_url = fallback_url
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
# Returns true when the secondary gateway URL is in use.
|
|
1088
|
+
def url_fallback_active?
|
|
1089
|
+
@url_fallback_active == true
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# The effective base URL for API calls.
|
|
1093
|
+
# Returns the URL-fallback override when active, otherwise the model's configured base_url.
|
|
1094
|
+
def effective_base_url
|
|
1095
|
+
@url_fallback_active ? @url_fallback_base_url : base_url
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1056
1098
|
# Get current model configuration.
|
|
1057
1099
|
#
|
|
1058
1100
|
# Resolution order:
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -40,6 +40,16 @@ module Clacky
|
|
|
40
40
|
# Grace period for offline heartbeat failures (3 days)
|
|
41
41
|
HEARTBEAT_GRACE_PERIOD = 3 * 86_400
|
|
42
42
|
|
|
43
|
+
# Per-slug locks serializing concurrent brand-skill installs. Each Agent
|
|
44
|
+
# spawns its own background sync thread, so several may try to install the
|
|
45
|
+
# same skill at once; a shared per-slug mutex collapses that to one download.
|
|
46
|
+
@install_locks = {}
|
|
47
|
+
@install_locks_guard = Mutex.new
|
|
48
|
+
|
|
49
|
+
def self.install_lock_for(slug)
|
|
50
|
+
@install_locks_guard.synchronize { @install_locks[slug] ||= Mutex.new }
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
attr_reader :product_name, :package_name, :license_key, :license_activated_at,
|
|
44
54
|
:license_expires_at, :license_last_heartbeat, :device_id,
|
|
45
55
|
:logo_url, :support_contact, :license_user_id,
|
|
@@ -1106,72 +1116,91 @@ module Clacky
|
|
|
1106
1116
|
|
|
1107
1117
|
require "zip"
|
|
1108
1118
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1119
|
+
# Serialize installs of the same slug so concurrent background syncs (one
|
|
1120
|
+
# per Agent) don't redundantly download the same ZIP or race on the same
|
|
1121
|
+
# destination directory.
|
|
1122
|
+
install_lock = self.class.install_lock_for(slug)
|
|
1123
|
+
install_lock.synchronize do
|
|
1124
|
+
dest_dir = File.join(brand_skills_dir, slug)
|
|
1125
|
+
|
|
1126
|
+
# Download and extract into a unique staging directory, never touching
|
|
1127
|
+
# dest_dir until everything succeeds. A failed or corrupt download must
|
|
1128
|
+
# never destroy the already-installed version.
|
|
1129
|
+
stage_id = "#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
1130
|
+
stage_dir = File.join(brand_skills_dir, ".staging-#{slug}-#{stage_id}")
|
|
1131
|
+
tmp_zip = File.join(brand_skills_dir, ".#{slug}-#{stage_id}.zip")
|
|
1132
|
+
FileUtils.mkdir_p(stage_dir)
|
|
1133
|
+
|
|
1134
|
+
begin
|
|
1135
|
+
# Download the zip file to a temp path via PlatformHttpClient so the
|
|
1136
|
+
# primary → fallback host failover applies uniformly to every download.
|
|
1137
|
+
dl = platform_client.download_file(url, tmp_zip)
|
|
1138
|
+
raise dl[:error].to_s unless dl[:success]
|
|
1139
|
+
|
|
1140
|
+
zip_size = File.size?(tmp_zip).to_i
|
|
1141
|
+
raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
|
|
1142
|
+
|
|
1143
|
+
# Extract into stage_dir.
|
|
1144
|
+
# Auto-detect whether the zip has a single root folder to strip.
|
|
1145
|
+
# Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
|
|
1146
|
+
# path-safety restrictions on absolute destination paths.
|
|
1147
|
+
# Uses chunked read + size verification for robustness.
|
|
1148
|
+
Zip::File.open(tmp_zip) do |zip|
|
|
1149
|
+
entries = zip.entries.reject(&:directory?)
|
|
1150
|
+
top_dirs = entries.map { |e| e.name.split("/").first }.uniq
|
|
1151
|
+
has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
|
|
1152
|
+
|
|
1153
|
+
entries.each do |entry|
|
|
1154
|
+
rel_path = if has_root
|
|
1155
|
+
parts = entry.name.split("/")
|
|
1156
|
+
parts[1..].join("/")
|
|
1157
|
+
else
|
|
1158
|
+
entry.name
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
next if rel_path.nil? || rel_path.empty?
|
|
1162
|
+
|
|
1163
|
+
out = File.join(stage_dir, rel_path)
|
|
1164
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
1165
|
+
|
|
1166
|
+
# Chunked copy with size verification
|
|
1167
|
+
written = 0
|
|
1168
|
+
File.open(out, "wb") do |f|
|
|
1169
|
+
entry.get_input_stream do |input|
|
|
1170
|
+
while (chunk = input.read(65536))
|
|
1171
|
+
f.write(chunk)
|
|
1172
|
+
written += chunk.bytesize
|
|
1173
|
+
end
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1111
1176
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
dl = platform_client.download_file(url, tmp_zip)
|
|
1116
|
-
raise dl[:error].to_s unless dl[:success]
|
|
1117
|
-
|
|
1118
|
-
zip_size = File.size?(tmp_zip).to_i
|
|
1119
|
-
raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
|
|
1120
|
-
|
|
1121
|
-
# Extract into dest_dir (overwrite existing files).
|
|
1122
|
-
# Auto-detect whether the zip has a single root folder to strip.
|
|
1123
|
-
# Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
|
|
1124
|
-
# path-safety restrictions on absolute destination paths.
|
|
1125
|
-
# Uses chunked read + size verification for robustness.
|
|
1126
|
-
Zip::File.open(tmp_zip) do |zip|
|
|
1127
|
-
entries = zip.entries.reject(&:directory?)
|
|
1128
|
-
top_dirs = entries.map { |e| e.name.split("/").first }.uniq
|
|
1129
|
-
has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
|
|
1130
|
-
|
|
1131
|
-
entries.each do |entry|
|
|
1132
|
-
rel_path = if has_root
|
|
1133
|
-
parts = entry.name.split("/")
|
|
1134
|
-
parts[1..].join("/")
|
|
1135
|
-
else
|
|
1136
|
-
entry.name
|
|
1137
|
-
end
|
|
1138
|
-
|
|
1139
|
-
next if rel_path.nil? || rel_path.empty?
|
|
1140
|
-
|
|
1141
|
-
out = File.join(dest_dir, rel_path)
|
|
1142
|
-
FileUtils.mkdir_p(File.dirname(out))
|
|
1143
|
-
|
|
1144
|
-
# Chunked copy with size verification
|
|
1145
|
-
written = 0
|
|
1146
|
-
File.open(out, "wb") do |f|
|
|
1147
|
-
entry.get_input_stream do |input|
|
|
1148
|
-
while (chunk = input.read(65536))
|
|
1149
|
-
f.write(chunk)
|
|
1150
|
-
written += chunk.bytesize
|
|
1177
|
+
# Verify file size matches ZIP entry declaration
|
|
1178
|
+
if written != entry.size
|
|
1179
|
+
raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}"
|
|
1151
1180
|
end
|
|
1152
1181
|
end
|
|
1153
1182
|
end
|
|
1154
1183
|
|
|
1155
|
-
#
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1184
|
+
# Everything extracted successfully — atomically swap staging into
|
|
1185
|
+
# place. Only now is the previous version removed.
|
|
1186
|
+
FileUtils.rm_rf(dest_dir)
|
|
1187
|
+
FileUtils.mv(stage_dir, dest_dir)
|
|
1188
|
+
|
|
1189
|
+
record_installed_skill(slug, version, skill_info["description"],
|
|
1190
|
+
encrypted: encrypted,
|
|
1191
|
+
description_zh: skill_info["description_zh"],
|
|
1192
|
+
name_zh: skill_info["name_zh"])
|
|
1193
|
+
|
|
1194
|
+
{ success: true, name: slug, version: version }
|
|
1195
|
+
rescue StandardError, ScriptError => e
|
|
1196
|
+
# Only clean up our own staging artifacts; the installed version in
|
|
1197
|
+
# dest_dir is left untouched so a failed update never loses a skill.
|
|
1198
|
+
{ success: false, error: e.message }
|
|
1199
|
+
ensure
|
|
1200
|
+
FileUtils.rm_f(tmp_zip)
|
|
1201
|
+
FileUtils.rm_rf(stage_dir) if Dir.exist?(stage_dir)
|
|
1159
1202
|
end
|
|
1160
1203
|
end
|
|
1161
|
-
|
|
1162
|
-
FileUtils.rm_f(tmp_zip)
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
record_installed_skill(slug, version, skill_info["description"],
|
|
1166
|
-
encrypted: encrypted,
|
|
1167
|
-
description_zh: skill_info["description_zh"],
|
|
1168
|
-
name_zh: skill_info["name_zh"])
|
|
1169
|
-
|
|
1170
|
-
{ success: true, name: slug, version: version }
|
|
1171
|
-
rescue StandardError, ScriptError => e
|
|
1172
|
-
FileUtils.rm_f(tmp_zip) if defined?(tmp_zip) && tmp_zip
|
|
1173
|
-
FileUtils.rm_rf(dest_dir) if defined?(dest_dir) && dest_dir
|
|
1174
|
-
{ success: false, error: e.message }
|
|
1175
1204
|
end
|
|
1176
1205
|
|
|
1177
1206
|
# Install a mock brand skill for brand-test mode.
|
data/lib/clacky/client.rb
CHANGED
|
@@ -616,7 +616,7 @@ module Clacky
|
|
|
616
616
|
when 401 then I18n.t("llm.error.invalid_api_key")
|
|
617
617
|
when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
|
|
618
618
|
when 404 then I18n.t("llm.error.endpoint_not_found")
|
|
619
|
-
when 429 then I18n.t("llm.error.rate_limit_429")
|
|
619
|
+
when 429 then error_code == "quota_exceeded" ? I18n.t("llm.error.quota_exhausted") : I18n.t("llm.error.rate_limit_429")
|
|
620
620
|
when 500..599 then I18n.t("llm.error.server_error", status: response.status)
|
|
621
621
|
else extract_error_message(error_body, response.body)
|
|
622
622
|
end
|
|
@@ -670,7 +670,11 @@ module Clacky
|
|
|
670
670
|
raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
|
|
671
671
|
when 404
|
|
672
672
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
|
|
673
|
-
when 429
|
|
673
|
+
when 429
|
|
674
|
+
if error_code == "quota_exceeded"
|
|
675
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.quota_exhausted")}", raw_message: error_message)
|
|
676
|
+
end
|
|
677
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
674
678
|
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
675
679
|
else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
|
|
676
680
|
end
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
|
|
52
52
|
"err.invalid_version": "Invalid version. Use format like 1.0.0.",
|
|
53
53
|
"err.name_taken": "The name \"{{id}}\" is already taken by another user.",
|
|
54
|
+
"err.id_folder_mismatch": "Extension ID mismatch: the folder is named \"{{folder}}\" but ext.yml declares id \"{{id}}\". Rename the folder or the id so they match before publishing.",
|
|
54
55
|
"rename.label": "Choose a new ID for your extension:",
|
|
55
56
|
"rename.placeholder": "e.g. my-ext-yourname",
|
|
56
57
|
"rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
|
|
@@ -235,6 +236,7 @@
|
|
|
235
236
|
"err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
|
|
236
237
|
"err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
|
|
237
238
|
"err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
|
|
239
|
+
"err.id_folder_mismatch": "扩展 ID 不一致:文件夹名为「{{folder}}」,但 ext.yml 声明的 id 是「{{id}}」。请先把文件夹名或 id 改成一致再发布。",
|
|
238
240
|
"rename.label": "请为你的扩展选择一个新 ID:",
|
|
239
241
|
"rename.placeholder": "例如 my-ext-yourname",
|
|
240
242
|
"rename.hint": "只能使用小写字母、数字和连字符。",
|
|
@@ -726,6 +728,7 @@
|
|
|
726
728
|
} catch (e) {
|
|
727
729
|
const msg = e.message || "";
|
|
728
730
|
const isConflict = /must be greater than/i.test(msg);
|
|
731
|
+
const mismatch = msg.match(/folder is '([^']+)' but ext\.yml declares id '([^']+)'/);
|
|
729
732
|
if (isConflict) {
|
|
730
733
|
setProgress(t("err.version_conflict", { ver }), true);
|
|
731
734
|
verInput.classList.add("studio-input-error");
|
|
@@ -733,6 +736,8 @@
|
|
|
733
736
|
verInput.focus();
|
|
734
737
|
verInput.select();
|
|
735
738
|
verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
|
|
739
|
+
} else if (mismatch) {
|
|
740
|
+
setProgress(t("err.id_folder_mismatch", { folder: mismatch[1], id: mismatch[2] }), true);
|
|
736
741
|
} else {
|
|
737
742
|
setProgress(t("err.generic", { msg }), true);
|
|
738
743
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
require "open-uri"
|
|
6
|
+
require "psych"
|
|
6
7
|
require "zip"
|
|
7
8
|
|
|
8
9
|
module Clacky
|
|
@@ -38,6 +39,7 @@ module Clacky
|
|
|
38
39
|
raise Error, "no container found at #{container_dir} (expected #{MANIFEST})"
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
refuse_id_folder_mismatch!(slug, container_dir)
|
|
41
43
|
verify_container!(slug, container_dir)
|
|
42
44
|
refuse_protected!(slug, container_dir)
|
|
43
45
|
|
|
@@ -96,6 +98,21 @@ module Clacky
|
|
|
96
98
|
Clacky::ExtensionLoader.invalidate_cache!
|
|
97
99
|
end
|
|
98
100
|
|
|
101
|
+
# The container's identity must be the same on both sides of the publish
|
|
102
|
+
# boundary: the client keys extensions by folder name, the marketplace
|
|
103
|
+
# keys them by ext.yml `id`. If they diverge, a rename on one side alone
|
|
104
|
+
# lets a container masquerade under a name the uniqueness check never saw.
|
|
105
|
+
private def refuse_id_folder_mismatch!(slug, container_dir)
|
|
106
|
+
manifest = File.join(container_dir, MANIFEST)
|
|
107
|
+
data = Psych.safe_load(File.read(manifest), permitted_classes: [], aliases: true) || {}
|
|
108
|
+
declared = data["id"].to_s.strip
|
|
109
|
+
return if declared == slug
|
|
110
|
+
|
|
111
|
+
raise Error,
|
|
112
|
+
"extension id mismatch: folder is '#{slug}' but #{MANIFEST} declares id '#{declared}'. " \
|
|
113
|
+
"Rename the folder or the id so they match before packing."
|
|
114
|
+
end
|
|
115
|
+
|
|
99
116
|
# Packing is for authored (open) containers. Anything already protected or
|
|
100
117
|
# carrying encrypted skills is produced by the platform pipeline; refuse
|
|
101
118
|
# to re-pack it so we never smuggle marketplace artifacts out of band.
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -9,11 +9,12 @@ module Clacky
|
|
|
9
9
|
"llm.error.403.model_not_allowed" => "This model is not available on your current plan",
|
|
10
10
|
"llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
|
|
11
11
|
"llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
|
|
12
|
-
"llm.error.403.quota_exceeded" => "
|
|
12
|
+
"llm.error.403.quota_exceeded" => "API key quota exceeded. Please update the quota or disable the limit in the admin console",
|
|
13
13
|
"llm.error.403.access_denied" => "Access denied, please check your API key permissions",
|
|
14
14
|
"llm.error.403.default" => "Access denied",
|
|
15
15
|
"llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
|
|
16
16
|
"llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
|
|
17
|
+
"llm.error.quota_exhausted" => "API quota exhausted (key usage limit reached). Please raise the quota or upgrade your plan in the console",
|
|
17
18
|
"llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
|
|
18
19
|
"llm.error.unexpected" => "Unexpected error (%<status>d)",
|
|
19
20
|
"llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
|
|
@@ -21,6 +22,7 @@ module Clacky
|
|
|
21
22
|
"llm.error.request_timeout" => "Request timed out after %<retries>d retries",
|
|
22
23
|
"llm.error.network_failed" => "Network connection failed after %<retries>d retries",
|
|
23
24
|
"llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
|
|
25
|
+
"llm.warn.switching_to_fallback_url" => "Primary endpoint unreachable. Switching to fallback gateway: %<url>s",
|
|
24
26
|
"platform.error.invalid_proof" => "Invalid license key — please check and try again.",
|
|
25
27
|
"platform.error.invalid_signature" => "Invalid request signature.",
|
|
26
28
|
"platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
|
|
@@ -28,7 +30,7 @@ module Clacky
|
|
|
28
30
|
"platform.error.license_revoked" => "This license has been revoked. Please contact support.",
|
|
29
31
|
"platform.error.license_expired" => "This license has expired. Please renew to continue.",
|
|
30
32
|
"platform.error.device_limit_reached" => "Device limit reached for this license.",
|
|
31
|
-
"platform.error.device_revoked" => "This device has been revoked from the license.",
|
|
33
|
+
"platform.error.device_revoked" => "This device has been revoked from the license. To re-activate, please wait 15 minutes and try again.",
|
|
32
34
|
"platform.error.invalid_license" => "License key not found. Please verify the key.",
|
|
33
35
|
"platform.error.device_not_found" => "Device not registered. Please re-activate.",
|
|
34
36
|
"platform.error.contributor_required" => "Publishing extensions requires becoming a contributor. Sign in, open \"My Extensions\", and click \"Become a contributor\" (no review needed).",
|