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
|
@@ -24,17 +24,19 @@ module Clacky
|
|
|
24
24
|
# # result => { success: true, data: {...} }
|
|
25
25
|
# # or { success: false, error: "...", data: {} }
|
|
26
26
|
class PlatformHttpClient
|
|
27
|
-
# Primary
|
|
28
|
-
PRIMARY_HOST
|
|
27
|
+
# Primary endpoint
|
|
28
|
+
PRIMARY_HOST = "https://www.openclacky.com"
|
|
29
|
+
# Secondary CDN-accelerated endpoint (China mainland)
|
|
30
|
+
SECONDARY_HOST = "https://api.1024code.com"
|
|
29
31
|
# Direct fallback — bypasses EdgeOne, used when the primary times out
|
|
30
|
-
FALLBACK_HOST
|
|
32
|
+
FALLBACK_HOST = "https://openclacky.up.railway.app"
|
|
31
33
|
|
|
32
34
|
# Number of attempts per domain (1 = no retry within the same domain)
|
|
33
|
-
ATTEMPTS_PER_HOST =
|
|
35
|
+
ATTEMPTS_PER_HOST = 1
|
|
34
36
|
# Initial back-off between retries within the same domain (seconds)
|
|
35
37
|
INITIAL_BACKOFF = 0.5
|
|
36
38
|
# Connection / read timeouts (seconds) for API calls
|
|
37
|
-
OPEN_TIMEOUT =
|
|
39
|
+
OPEN_TIMEOUT = 5
|
|
38
40
|
READ_TIMEOUT = 15
|
|
39
41
|
# Read timeout for streaming large file downloads (seconds)
|
|
40
42
|
DOWNLOAD_READ_TIMEOUT = 120
|
|
@@ -53,12 +55,12 @@ module Clacky
|
|
|
53
55
|
|
|
54
56
|
# Auto-detects the target host(s):
|
|
55
57
|
# - When CLACKY_LICENSE_SERVER is set → single host (dev override, no failover)
|
|
56
|
-
# - Otherwise → [PRIMARY_HOST, FALLBACK_HOST]
|
|
58
|
+
# - Otherwise → [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
|
|
57
59
|
def initialize
|
|
58
60
|
if (override = ENV["CLACKY_LICENSE_SERVER"]) && !override.empty?
|
|
59
61
|
@hosts = [override]
|
|
60
62
|
else
|
|
61
|
-
@hosts = [PRIMARY_HOST, FALLBACK_HOST]
|
|
63
|
+
@hosts = [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -260,8 +260,8 @@ module Clacky
|
|
|
260
260
|
"name" => "Kimi (Moonshot)",
|
|
261
261
|
"base_url" => "https://api.moonshot.cn/v1",
|
|
262
262
|
"api" => "openai-completions",
|
|
263
|
-
"default_model" => "kimi-
|
|
264
|
-
"models" => ["kimi-k2.6", "kimi-k2.5"],
|
|
263
|
+
"default_model" => "kimi-k3",
|
|
264
|
+
"models" => ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"],
|
|
265
265
|
# Moonshot operates two regional endpoints with identical APIs & model
|
|
266
266
|
# lineup — mainland China (.cn) and international (.ai). These are the
|
|
267
267
|
# pay-as-you-go Open Platform endpoints; the subscription-billed
|
|
@@ -277,9 +277,9 @@ module Clacky
|
|
|
277
277
|
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
|
|
278
278
|
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
|
|
279
279
|
].freeze,
|
|
280
|
-
# k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
|
|
280
|
+
# k3 / k2.7-code / k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
|
|
281
281
|
"capabilities" => { "vision" => true }.freeze,
|
|
282
|
-
"default_ocr_model" => "kimi-
|
|
282
|
+
"default_ocr_model" => "kimi-k3",
|
|
283
283
|
"website_url" => "https://platform.moonshot.cn/console/api-keys"
|
|
284
284
|
}.freeze,
|
|
285
285
|
|
|
@@ -376,6 +376,70 @@ module Clacky
|
|
|
376
376
|
"website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
|
|
377
377
|
}.freeze,
|
|
378
378
|
|
|
379
|
+
# Volcengine Ark (Doubao) — ByteDance's model platform, OpenAI-compatible.
|
|
380
|
+
# Exposes three functionally-equivalent endpoints (Pay-as-you-go / Coding
|
|
381
|
+
# Plan / Agent Plan) that share the same model lineup and capability
|
|
382
|
+
# profile, so a single preset with endpoint_variants is the right shape.
|
|
383
|
+
# Without a preset match, base_urls like /api/coding/v3 fell through to the
|
|
384
|
+
# conservative "assume vision=true" default and inlined images into
|
|
385
|
+
# text-only models (e.g. glm-5.2), which Ark rejects. The vision matrix
|
|
386
|
+
# below is the source of truth so text-only models route through the OCR
|
|
387
|
+
# sidecar instead.
|
|
388
|
+
"volcengine-ark" => {
|
|
389
|
+
"name" => "Volcengine Ark (Doubao)",
|
|
390
|
+
"name_key" => "provider.name.volcengine_ark",
|
|
391
|
+
"base_url" => "https://ark.cn-beijing.volces.com/api/v3",
|
|
392
|
+
"api" => "openai-completions",
|
|
393
|
+
"default_model" => "doubao-seed-2.0-pro",
|
|
394
|
+
"models" => [
|
|
395
|
+
"doubao-seed-evolving",
|
|
396
|
+
"doubao-seed-2.1-pro",
|
|
397
|
+
"doubao-seed-2.1-turbo",
|
|
398
|
+
"doubao-seed-2.0-code",
|
|
399
|
+
"doubao-seed-2.0-pro",
|
|
400
|
+
"doubao-seed-2.0-lite",
|
|
401
|
+
"minimax-m3",
|
|
402
|
+
"minimax-m2.7",
|
|
403
|
+
"kimi-k2.7-code",
|
|
404
|
+
"kimi-k2.6",
|
|
405
|
+
"glm-5.2",
|
|
406
|
+
"deepseek-v4-pro",
|
|
407
|
+
"deepseek-v4-flash"
|
|
408
|
+
],
|
|
409
|
+
"endpoint_variants" => [
|
|
410
|
+
{ "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.ark_payg", "base_url" => "https://ark.cn-beijing.volces.com/api/v3", "alias_group" => "payg" }.freeze,
|
|
411
|
+
{ "label" => "Coding Plan", "label_key" => "settings.models.baseurl.variant.ark_coding", "base_url" => "https://ark.cn-beijing.volces.com/api/coding/v3" }.freeze,
|
|
412
|
+
{ "label" => "Agent Plan", "label_key" => "settings.models.baseurl.variant.ark_agent", "base_url" => "https://ark.cn-beijing.volces.com/api/plan/v3" }.freeze
|
|
413
|
+
].freeze,
|
|
414
|
+
# Pay-as-you-go billing endpoint requires versioned model ids, while the
|
|
415
|
+
# Coding/Agent Plan endpoints accept the short display names. Users pick
|
|
416
|
+
# the intuitive short name; on the payg endpoint we transparently swap
|
|
417
|
+
# it for the versioned id the billing API expects. Only models whose ids
|
|
418
|
+
# actually differ are listed here.
|
|
419
|
+
"api_model_aliases" => {
|
|
420
|
+
"payg" => {
|
|
421
|
+
"glm-5.2" => "glm-5-2-260617",
|
|
422
|
+
"deepseek-v4-pro" => "deepseek-v4-pro-260425",
|
|
423
|
+
"deepseek-v4-flash" => "deepseek-v4-flash-260425",
|
|
424
|
+
"doubao-seed-2.1-pro" => "doubao-seed-2-1-pro-260628",
|
|
425
|
+
"doubao-seed-2.1-turbo" => "doubao-seed-2-1-turbo-260628",
|
|
426
|
+
"doubao-seed-2.0-pro" => "doubao-seed-2-0-pro-260215",
|
|
427
|
+
"doubao-seed-2.0-lite" => "doubao-seed-2-0-lite-260428",
|
|
428
|
+
"doubao-seed-2.0-code" => "doubao-seed-2-0-code-preview-260215"
|
|
429
|
+
}.freeze
|
|
430
|
+
}.freeze,
|
|
431
|
+
# Most Doubao/multimodal models accept image input; GLM-5.2 and
|
|
432
|
+
# DeepSeek-V4 on Ark are text-only.
|
|
433
|
+
"capabilities" => { "vision" => true }.freeze,
|
|
434
|
+
"model_capabilities" => {
|
|
435
|
+
"glm-5.2" => { "vision" => false }.freeze,
|
|
436
|
+
"deepseek-v4-pro" => { "vision" => false }.freeze,
|
|
437
|
+
"deepseek-v4-flash" => { "vision" => false }.freeze
|
|
438
|
+
}.freeze,
|
|
439
|
+
"default_ocr_model" => "doubao-seed-2.0-lite",
|
|
440
|
+
"website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
|
|
441
|
+
}.freeze,
|
|
442
|
+
|
|
379
443
|
"openai" => {
|
|
380
444
|
"name" => "OpenAI (GPT)",
|
|
381
445
|
"base_url" => "https://api.openai.com/v1",
|
|
@@ -788,7 +852,51 @@ module Clacky
|
|
|
788
852
|
nil
|
|
789
853
|
end
|
|
790
854
|
|
|
791
|
-
#
|
|
855
|
+
# Translate a display model name into the real model id the target
|
|
856
|
+
# endpoint expects. Some providers expose the same model under different
|
|
857
|
+
# ids per billing endpoint (e.g. Volcengine Ark's pay-as-you-go API needs
|
|
858
|
+
# versioned ids like "doubao-seed-2-0-pro-260215" while the Coding/Agent
|
|
859
|
+
# Plan endpoints accept the short "doubao-seed-2.0-pro"). Users always see
|
|
860
|
+
# and pick the short name; this swaps it just before the request goes out.
|
|
861
|
+
#
|
|
862
|
+
# Returns the original model unchanged when no alias applies (unknown
|
|
863
|
+
# provider, endpoint without an alias group, or model not in the map).
|
|
864
|
+
#
|
|
865
|
+
# @param base_url [String, nil] the configured base_url (identifies endpoint)
|
|
866
|
+
# @param api_key [String, nil] the configured api_key (provider fallback)
|
|
867
|
+
# @param model [String, nil] the display model name
|
|
868
|
+
# @return [String, nil] the real model id to send, or the input unchanged
|
|
869
|
+
def resolve_api_model(base_url:, api_key: nil, model:)
|
|
870
|
+
return model if model.nil?
|
|
871
|
+
|
|
872
|
+
provider_id = resolve_provider(base_url: base_url, api_key: api_key)
|
|
873
|
+
return model unless provider_id
|
|
874
|
+
|
|
875
|
+
preset = PRESETS[provider_id]
|
|
876
|
+
aliases = preset && preset["api_model_aliases"]
|
|
877
|
+
return model unless aliases.is_a?(Hash)
|
|
878
|
+
|
|
879
|
+
group = alias_group_for_base_url(preset, base_url)
|
|
880
|
+
return model unless group
|
|
881
|
+
|
|
882
|
+
aliases.dig(group, model.to_s) || model
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# Find which alias group the given base_url belongs to by matching it
|
|
886
|
+
# against the preset's endpoint_variants. Uses exact base_url equality
|
|
887
|
+
# (after normalising trailing slash) so "/api/v3" never leaks into the
|
|
888
|
+
# "/api/coding/v3" match.
|
|
889
|
+
def alias_group_for_base_url(preset, base_url)
|
|
890
|
+
return nil if base_url.nil?
|
|
891
|
+
variants = preset["endpoint_variants"]
|
|
892
|
+
return nil unless variants.is_a?(Array)
|
|
893
|
+
|
|
894
|
+
normalized = base_url.to_s.chomp("/")
|
|
895
|
+
variant = variants.find do |v|
|
|
896
|
+
v.is_a?(Hash) && v["base_url"].to_s.chomp("/") == normalized
|
|
897
|
+
end
|
|
898
|
+
variant && variant["alias_group"]
|
|
899
|
+
end
|
|
792
900
|
#
|
|
793
901
|
# Resolution order (most specific wins):
|
|
794
902
|
# 1. PRESETS[provider_id]["model_capabilities"][model_name] — per-model
|
|
@@ -36,9 +36,10 @@ module Clacky
|
|
|
36
36
|
@events = events
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def show_user_message(content, created_at: nil, files: [])
|
|
39
|
+
def show_user_message(content, created_at: nil, files: [], editable: true)
|
|
40
40
|
ev = { type: "history_user_message", session_id: @session_id, content: content }
|
|
41
41
|
ev[:created_at] = created_at if created_at
|
|
42
|
+
ev[:editable] = false unless editable
|
|
42
43
|
rendered = Array(files).filter_map do |f|
|
|
43
44
|
url = f[:data_url] || f["data_url"]
|
|
44
45
|
name = f[:name] || f["name"]
|
|
@@ -545,6 +546,7 @@ module Clacky
|
|
|
545
546
|
when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
|
|
546
547
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
547
548
|
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
549
|
+
when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(res)
|
|
548
550
|
when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
|
|
549
551
|
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
550
552
|
when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
|
|
@@ -964,11 +966,13 @@ module Clacky
|
|
|
964
966
|
# Phase "key_setup" → no API key configured yet
|
|
965
967
|
# Phase "soul_setup" → key configured, but ~/.clacky/agents/SOUL.md missing
|
|
966
968
|
# needs_onboard: false → fully set up
|
|
969
|
+
# branded: true → running under a brand; hide the OpenClacky AI Keys block
|
|
967
970
|
def api_onboard_status(res)
|
|
971
|
+
branded = Clacky::BrandConfig.load.branded?
|
|
968
972
|
if !@agent_config.models_configured?
|
|
969
|
-
json_response(res, 200, { needs_onboard: true, phase: "key_setup" })
|
|
973
|
+
json_response(res, 200, { needs_onboard: true, phase: "key_setup", branded: branded })
|
|
970
974
|
else
|
|
971
|
-
json_response(res, 200, { needs_onboard: false })
|
|
975
|
+
json_response(res, 200, { needs_onboard: false, branded: branded })
|
|
972
976
|
end
|
|
973
977
|
end
|
|
974
978
|
|
|
@@ -2309,6 +2313,9 @@ module Clacky
|
|
|
2309
2313
|
# Refresh skill_loader with the now-activated brand config so brand
|
|
2310
2314
|
# skills are loadable from this point forward (e.g. after sync).
|
|
2311
2315
|
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
|
|
2316
|
+
# Install all brand skills in the background on first activation so
|
|
2317
|
+
# they are available immediately without manual user action.
|
|
2318
|
+
brand.sync_brand_skills_async!(install_new: true)
|
|
2312
2319
|
json_response(res, 200, {
|
|
2313
2320
|
ok: true,
|
|
2314
2321
|
product_name: result[:product_name] || brand.product_name,
|
|
@@ -2359,10 +2366,9 @@ module Clacky
|
|
|
2359
2366
|
|
|
2360
2367
|
# GET /api/store/extensions?q=&sort=
|
|
2361
2368
|
#
|
|
2362
|
-
# Public extension marketplace catalog
|
|
2363
|
-
#
|
|
2364
|
-
#
|
|
2365
|
-
# — they ship inside license-gated brand packages (path B distribution).
|
|
2369
|
+
# Public extension marketplace catalog — always returns the full public
|
|
2370
|
+
# catalog regardless of brand status. Both brand and non-brand users see
|
|
2371
|
+
# the same public marketplace here.
|
|
2366
2372
|
def api_store_extensions(req, res)
|
|
2367
2373
|
brand = Clacky::BrandConfig.load
|
|
2368
2374
|
result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
|
|
@@ -2370,7 +2376,7 @@ module Clacky
|
|
|
2370
2376
|
if result[:success]
|
|
2371
2377
|
installed = installed_extension_containers
|
|
2372
2378
|
extensions = Array(result[:extensions]).map do |ext|
|
|
2373
|
-
slug
|
|
2379
|
+
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2374
2380
|
container = installed[slug]
|
|
2375
2381
|
ext.merge(
|
|
2376
2382
|
"installed" => !container.nil?,
|
|
@@ -2387,6 +2393,49 @@ module Clacky
|
|
|
2387
2393
|
end
|
|
2388
2394
|
end
|
|
2389
2395
|
|
|
2396
|
+
# GET /api/store/extensions/brand
|
|
2397
|
+
#
|
|
2398
|
+
# Brand-private extension catalog — only available to users with an
|
|
2399
|
+
# activated brand license. Returns extensions belonging to this brand via
|
|
2400
|
+
# BrandConfig#fetch_brand_extensions!.
|
|
2401
|
+
# Returns 403 when the license is not activated.
|
|
2402
|
+
def api_store_extensions_brand(res)
|
|
2403
|
+
brand = Clacky::BrandConfig.load
|
|
2404
|
+
|
|
2405
|
+
unless brand.activated?
|
|
2406
|
+
json_response(res, 403, { ok: false, error: "Brand license not activated." })
|
|
2407
|
+
return
|
|
2408
|
+
end
|
|
2409
|
+
|
|
2410
|
+
result = brand.fetch_brand_extensions!
|
|
2411
|
+
|
|
2412
|
+
if result[:success]
|
|
2413
|
+
installed = installed_extension_containers
|
|
2414
|
+
extensions = Array(result[:extensions]).map do |ext|
|
|
2415
|
+
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2416
|
+
container = installed[slug]
|
|
2417
|
+
# Merge local container data (e.g. emoji) for self-authored extensions.
|
|
2418
|
+
local_overrides = {}
|
|
2419
|
+
if container && ext["emoji"].to_s.empty?
|
|
2420
|
+
local_emoji = container.dig(:raw, "emoji") || container[:emoji]
|
|
2421
|
+
local_overrides["emoji"] = local_emoji if local_emoji.to_s.strip != ""
|
|
2422
|
+
end
|
|
2423
|
+
ext.merge(
|
|
2424
|
+
"installed" => !ext["installed_version"].nil?,
|
|
2425
|
+
"installed_version" => ext["installed_version"],
|
|
2426
|
+
**local_overrides
|
|
2427
|
+
)
|
|
2428
|
+
end
|
|
2429
|
+
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2430
|
+
else
|
|
2431
|
+
json_response(res, 200, {
|
|
2432
|
+
ok: true,
|
|
2433
|
+
extensions: [],
|
|
2434
|
+
warning: result[:error] || "Could not reach the extension store."
|
|
2435
|
+
})
|
|
2436
|
+
end
|
|
2437
|
+
end
|
|
2438
|
+
|
|
2390
2439
|
# GET /api/store/extensions/installed
|
|
2391
2440
|
#
|
|
2392
2441
|
# Returns all locally installed extensions (all layers: builtin, installed,
|
|
@@ -2405,23 +2454,35 @@ module Clacky
|
|
|
2405
2454
|
|
|
2406
2455
|
extensions = local_entries.map do |ext_id, container|
|
|
2407
2456
|
market = market_by_slug[ext_id]
|
|
2457
|
+
# For self-authored (origin: self) extensions market is nil.
|
|
2458
|
+
# Fall back to local ext.yml data so name/description/author are populated.
|
|
2459
|
+
local_name = container[:name].to_s.then { |n| n.empty? ? ext_id : n }
|
|
2460
|
+
local_desc = container.dig(:raw, "description").to_s
|
|
2461
|
+
local_author = container[:author].to_s
|
|
2462
|
+
local_units = units_from_container(container)
|
|
2408
2463
|
{
|
|
2409
2464
|
"id" => ext_id,
|
|
2410
|
-
"name" => market ? (market["name"] || ext_id) :
|
|
2465
|
+
"name" => market ? (market["name"] || ext_id) : local_name,
|
|
2466
|
+
"display_name" => market&.dig("display_name"),
|
|
2467
|
+
"display_name_zh" => market&.dig("display_name_zh"),
|
|
2411
2468
|
"name_zh" => market&.dig("name_zh"),
|
|
2412
2469
|
"name_en" => market&.dig("name_en"),
|
|
2413
2470
|
"slug" => ext_id,
|
|
2414
2471
|
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2415
2472
|
"installed_version" => container[:version],
|
|
2416
|
-
"description" => market
|
|
2417
|
-
"author" => market
|
|
2473
|
+
"description" => market ? market["description"] : local_desc,
|
|
2474
|
+
"author" => market ? market["author"] : local_author,
|
|
2418
2475
|
"icon_url" => market&.dig("icon_url"),
|
|
2419
|
-
"units" => market
|
|
2420
|
-
"homepage" => market ? (market["homepage"] || "") :
|
|
2476
|
+
"units" => market ? market["units"] : local_units,
|
|
2477
|
+
"homepage" => market ? (market["homepage"] || "") : container[:homepage].to_s,
|
|
2421
2478
|
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2422
2479
|
"hub_active" => market&.dig("hub_active"),
|
|
2423
2480
|
"download_count" => market&.dig("download_count").to_i,
|
|
2424
|
-
|
|
2481
|
+
# Only mark as unlisted when the extension was published to the
|
|
2482
|
+
# marketplace (origin != "self") but is no longer listed there.
|
|
2483
|
+
# Self-authored extensions (origin: self) have never been published
|
|
2484
|
+
# and should never show the "unlisted" badge.
|
|
2485
|
+
"unlisted" => market.nil? && container[:origin].to_s != "self",
|
|
2425
2486
|
"layer" => container[:layer].to_s,
|
|
2426
2487
|
"installed" => true,
|
|
2427
2488
|
"removable" => true,
|
|
@@ -2465,6 +2526,21 @@ module Clacky
|
|
|
2465
2526
|
{}
|
|
2466
2527
|
end
|
|
2467
2528
|
|
|
2529
|
+
# Build a units summary hash from local ext.yml contributes data,
|
|
2530
|
+
# mirroring the format platform's unit_summary returns: { "api" => n, ... }
|
|
2531
|
+
private def units_from_container(container)
|
|
2532
|
+
contributes = container[:contributes] || {}
|
|
2533
|
+
units = {}
|
|
2534
|
+
units["api"] = 1 unless (contributes["api"] || contributes[:api]).to_s.empty?
|
|
2535
|
+
panels = Array(contributes["panels"] || contributes[:panels])
|
|
2536
|
+
units["panel"] = panels.size if panels.any?
|
|
2537
|
+
agents = Array(contributes["agents"] || contributes[:agents])
|
|
2538
|
+
units["agent"] = agents.size if agents.any?
|
|
2539
|
+
skills = Array(contributes["skills"] || contributes[:skills])
|
|
2540
|
+
units["skill"] = skills.size if skills.any?
|
|
2541
|
+
units
|
|
2542
|
+
end
|
|
2543
|
+
|
|
2468
2544
|
# GET /api/store/extension?id=<slug-or-id>
|
|
2469
2545
|
#
|
|
2470
2546
|
# Public detail for a single marketplace extension (contributes + version
|
|
@@ -2477,7 +2553,10 @@ module Clacky
|
|
|
2477
2553
|
end
|
|
2478
2554
|
|
|
2479
2555
|
brand = Clacky::BrandConfig.load
|
|
2480
|
-
|
|
2556
|
+
# Brand-private extensions (origin=self) are not visible via the public
|
|
2557
|
+
# /api/v1/extensions/:id endpoint. Activated brand users must use the
|
|
2558
|
+
# license-gated POST /api/v1/licenses/extension instead.
|
|
2559
|
+
result = brand.activated? ? brand.brand_extension_detail!(id) : brand.extension_detail!(id)
|
|
2481
2560
|
|
|
2482
2561
|
if result[:success] && result[:extension]
|
|
2483
2562
|
ext = result[:extension]
|
|
@@ -2494,22 +2573,29 @@ module Clacky
|
|
|
2494
2573
|
container = extension_container(id)
|
|
2495
2574
|
if container && container[:layer] == :installed
|
|
2496
2575
|
market = fetch_batch_market_data([id])[id]
|
|
2576
|
+
# Fall back to local ext.yml for self-authored extensions.
|
|
2577
|
+
local_name = container[:name].to_s.then { |n| n.empty? ? id : n }
|
|
2578
|
+
local_desc = container.dig(:raw, "description").to_s
|
|
2579
|
+
local_author = container[:author].to_s
|
|
2580
|
+
local_units = units_from_container(container)
|
|
2497
2581
|
ext = {
|
|
2498
2582
|
"id" => id,
|
|
2499
|
-
"name" => market ? (market["name"] || id) :
|
|
2583
|
+
"name" => market ? (market["name"] || id) : local_name,
|
|
2500
2584
|
"name_zh" => market&.dig("name_zh"),
|
|
2501
2585
|
"name_en" => market&.dig("name_en"),
|
|
2502
2586
|
"slug" => id,
|
|
2503
2587
|
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2504
2588
|
"installed_version" => container[:version],
|
|
2505
|
-
"description" => market
|
|
2506
|
-
"author" => market
|
|
2589
|
+
"description" => market ? market["description"] : local_desc,
|
|
2590
|
+
"author" => market ? market["author"] : local_author,
|
|
2507
2591
|
"icon_url" => market&.dig("icon_url"),
|
|
2508
|
-
"units" => market
|
|
2509
|
-
"homepage" => market ? (market["homepage"] || "") :
|
|
2592
|
+
"units" => market ? market["units"] : local_units,
|
|
2593
|
+
"homepage" => market ? (market["homepage"] || "") : container[:homepage].to_s,
|
|
2510
2594
|
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2511
2595
|
"hub_active" => market&.dig("hub_active"),
|
|
2512
|
-
|
|
2596
|
+
# Only mark as unlisted when the extension was published to the
|
|
2597
|
+
# marketplace (origin != "self") but is no longer listed there.
|
|
2598
|
+
"unlisted" => market.nil? && container[:origin].to_s != "self",
|
|
2513
2599
|
"installed" => true,
|
|
2514
2600
|
"removable" => true,
|
|
2515
2601
|
"disabled" => container[:disabled] == true,
|
|
@@ -2577,7 +2663,9 @@ module Clacky
|
|
|
2577
2663
|
end
|
|
2578
2664
|
|
|
2579
2665
|
def api_store_extension_uninstall(req, res)
|
|
2580
|
-
|
|
2666
|
+
body = parse_json_body(req)
|
|
2667
|
+
id = body["id"].to_s
|
|
2668
|
+
purge_data = body["purge_data"] == true
|
|
2581
2669
|
container = extension_container(id)
|
|
2582
2670
|
if container.nil?
|
|
2583
2671
|
json_response(res, 404, { ok: false, error: "Not installed." })
|
|
@@ -2588,7 +2676,7 @@ module Clacky
|
|
|
2588
2676
|
return
|
|
2589
2677
|
end
|
|
2590
2678
|
|
|
2591
|
-
if Clacky::ExtensionLoader.uninstall!(id)
|
|
2679
|
+
if Clacky::ExtensionLoader.uninstall!(id, purge_data: purge_data)
|
|
2592
2680
|
json_response(res, 200, { ok: true, id: id })
|
|
2593
2681
|
else
|
|
2594
2682
|
json_response(res, 404, { ok: false, error: "Not installed." })
|
|
@@ -3691,13 +3779,11 @@ module Clacky
|
|
|
3691
3779
|
|
|
3692
3780
|
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
3693
3781
|
|
|
3694
|
-
#
|
|
3695
|
-
#
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
3699
|
-
# Convert it to the Linux-side path so File.exist? works.
|
|
3782
|
+
# Path arrives already percent-decoded by the click handler; normalize
|
|
3783
|
+
# Windows drive letters (WSL) BEFORE expand_path or the drive is treated
|
|
3784
|
+
# as relative and corrupted. No-op on macOS/Linux.
|
|
3700
3785
|
linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
3786
|
+
linux_path = File.expand_path(linux_path)
|
|
3701
3787
|
|
|
3702
3788
|
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
3703
3789
|
|
|
@@ -3711,8 +3797,11 @@ module Clacky
|
|
|
3711
3797
|
json_response(res, 200, { ok: true })
|
|
3712
3798
|
when "download"
|
|
3713
3799
|
serve_file_download(res, linux_path)
|
|
3800
|
+
when "display-path"
|
|
3801
|
+
display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
|
|
3802
|
+
json_response(res, 200, { ok: true, path: display })
|
|
3714
3803
|
else
|
|
3715
|
-
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or '
|
|
3804
|
+
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download' or 'display-path'" })
|
|
3716
3805
|
end
|
|
3717
3806
|
rescue => e
|
|
3718
3807
|
json_response(res, 500, { ok: false, error: e.message })
|
|
@@ -3742,14 +3831,9 @@ module Clacky
|
|
|
3742
3831
|
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
3743
3832
|
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
3744
3833
|
|
|
3745
|
-
# Strip file
|
|
3746
|
-
|
|
3747
|
-
path =
|
|
3748
|
-
path = File.expand_path(path)
|
|
3749
|
-
|
|
3750
|
-
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
3751
|
-
# Convert it to the Linux-side path so File.exist? works.
|
|
3752
|
-
path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
3834
|
+
# Strip file://, decode, WSL drive-letter normalize, then expand — in
|
|
3835
|
+
# this order (normalize must precede expand_path). No-op on macOS/Linux.
|
|
3836
|
+
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
3753
3837
|
|
|
3754
3838
|
# Security: only serve media files (images + videos)
|
|
3755
3839
|
ext = File.extname(path).downcase
|
|
@@ -5724,6 +5808,9 @@ module Clacky
|
|
|
5724
5808
|
{
|
|
5725
5809
|
id: id,
|
|
5726
5810
|
name: preset["name"],
|
|
5811
|
+
# Optional i18n key for the display name (localised per UI language);
|
|
5812
|
+
# frontend falls back to `name` when absent or untranslated.
|
|
5813
|
+
name_key: preset["name_key"],
|
|
5727
5814
|
base_url: preset["base_url"],
|
|
5728
5815
|
default_model: preset["default_model"],
|
|
5729
5816
|
models: preset["models"] || [],
|
|
@@ -6423,6 +6510,12 @@ module Clacky
|
|
|
6423
6510
|
private def interrupt_all_agents
|
|
6424
6511
|
return unless @registry && @session_manager
|
|
6425
6512
|
|
|
6513
|
+
# Stop idle compression first: an in-flight compression must fully roll
|
|
6514
|
+
# back (or complete) before the worker is killed, otherwise a hot
|
|
6515
|
+
# restart's SIGKILL can leave a chunk file on disk whose chunk_path
|
|
6516
|
+
# never made it into session.json (history vanishes on replay).
|
|
6517
|
+
@registry.shutdown_all_idle_timers
|
|
6518
|
+
|
|
6426
6519
|
@registry.each_live_agent do |id, agent, thread|
|
|
6427
6520
|
next unless thread&.alive?
|
|
6428
6521
|
begin
|
|
@@ -496,6 +496,15 @@ module Clacky
|
|
|
496
496
|
snapshot.each { |id, agent, thread| yield id, agent, thread }
|
|
497
497
|
end
|
|
498
498
|
|
|
499
|
+
# Shut down every session's idle-compression timer, waiting for any
|
|
500
|
+
# in-flight compression to roll back cleanly. Called on worker shutdown
|
|
501
|
+
# so a hot restart's SIGKILL cannot tear a compression apart between
|
|
502
|
+
# writing the chunk file and persisting its chunk_path into session.json.
|
|
503
|
+
def shutdown_all_idle_timers
|
|
504
|
+
timers = @mutex.synchronize { @sessions.values.filter_map { |s| s[:idle_timer] } }
|
|
505
|
+
timers.each { |t| t.shutdown rescue nil }
|
|
506
|
+
end
|
|
507
|
+
|
|
499
508
|
private def persist_and_release(id, session)
|
|
500
509
|
agent = session[:agent]
|
|
501
510
|
@session_manager&.save(agent.to_session_data(status: :success, preserve_updated_at: true)) if agent
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -121,12 +121,17 @@ module Clacky
|
|
|
121
121
|
files = if File.file?(expanded_path)
|
|
122
122
|
[expanded_path]
|
|
123
123
|
else
|
|
124
|
+
# Auto-expand bare extension patterns (e.g. "*.ts") to recursive
|
|
125
|
+
# (e.g. "**/*.ts"), matching ripgrep's default behaviour.
|
|
126
|
+
# Patterns already containing "/" are left as-is — the user is
|
|
127
|
+
# intentionally scoping to a specific directory.
|
|
128
|
+
effective_pattern = file_pattern.include?("/") ? file_pattern : "**/#{file_pattern}"
|
|
124
129
|
fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
125
130
|
collected = []
|
|
126
131
|
walk_status = {}
|
|
127
132
|
Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |f|
|
|
128
133
|
relative = f[(expanded_path.length + 1)..]
|
|
129
|
-
collected << f if File.fnmatch(
|
|
134
|
+
collected << f if File.fnmatch(effective_pattern, relative, fnmatch_flags)
|
|
130
135
|
end
|
|
131
136
|
if walk_status[:truncated]
|
|
132
137
|
truncation_reason ||= "walk #{walk_status[:truncation_reason]}"
|
|
@@ -52,17 +52,24 @@ module Clacky
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Convert a Windows-style path
|
|
56
|
-
#
|
|
57
|
-
# Returns the original path unchanged on non-WSL or if already a Linux path.
|
|
58
|
-
# @param path [String]
|
|
59
|
-
# @return [String]
|
|
55
|
+
# Convert a Windows-style path (incl. the "/C:/" three-slash-URL form) to
|
|
56
|
+
# a WSL /mnt path. No-op on non-WSL or non-drive-letter paths.
|
|
60
57
|
def self.win_to_linux_path(path)
|
|
61
|
-
return path unless os_type == :wsl
|
|
58
|
+
return path unless os_type == :wsl
|
|
59
|
+
|
|
60
|
+
m = path.match(%r{\A/?([A-Za-z]):[/\\](.*)}m)
|
|
61
|
+
return path unless m
|
|
62
|
+
|
|
63
|
+
"/mnt/#{m[1].downcase}/#{m[2].gsub("\\", "/")}"
|
|
64
|
+
end
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
# Resolve a "file://" URL or bare path into a real local path:
|
|
67
|
+
# strip "file://", percent-decode, WSL drive-letter normalize, then expand.
|
|
68
|
+
def self.resolve_local_path(href)
|
|
69
|
+
path = href.to_s.sub(%r{\Afile://}, "")
|
|
70
|
+
path = CGI.unescape(path)
|
|
71
|
+
path = win_to_linux_path(path)
|
|
72
|
+
File.expand_path(path)
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
# Convert a Linux-side path to a Windows-style path via wslpath.
|