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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -3
  4. data/lib/clacky/agent/session_serializer.rb +50 -9
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +102 -10
  7. data/lib/clacky/client.rb +22 -16
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
  13. data/lib/clacky/extension/api_extension.rb +5 -6
  14. data/lib/clacky/extension/loader.rb +16 -2
  15. data/lib/clacky/extension/packager.rb +10 -1
  16. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  17. data/lib/clacky/mcp/http_transport.rb +1 -1
  18. data/lib/clacky/platform_http_client.rb +9 -7
  19. data/lib/clacky/providers.rb +113 -5
  20. data/lib/clacky/server/http_server.rb +131 -38
  21. data/lib/clacky/server/session_registry.rb +9 -0
  22. data/lib/clacky/tools/grep.rb +6 -1
  23. data/lib/clacky/utils/environment_detector.rb +16 -9
  24. data/lib/clacky/utils/file_processor.rb +21 -24
  25. data/lib/clacky/utils/model_pricing.rb +85 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +441 -48
  28. data/lib/clacky/web/app.js +29 -6
  29. data/lib/clacky/web/components/onboard.js +21 -7
  30. data/lib/clacky/web/features/backup/view.js +4 -3
  31. data/lib/clacky/web/features/extensions/store.js +56 -4
  32. data/lib/clacky/web/features/extensions/view.js +54 -24
  33. data/lib/clacky/web/features/mcp/view.js +5 -2
  34. data/lib/clacky/web/features/new-session/store.js +8 -4
  35. data/lib/clacky/web/features/version/view.js +5 -1
  36. data/lib/clacky/web/features/workspace/store.js +11 -0
  37. data/lib/clacky/web/features/workspace/view.js +10 -4
  38. data/lib/clacky/web/i18n.js +58 -36
  39. data/lib/clacky/web/index.html +74 -7
  40. data/lib/clacky/web/sessions.js +72 -15
  41. data/lib/clacky/web/settings.js +22 -3
  42. data/lib/clacky/web/theme.js +27 -58
  43. data/scripts/uninstall.sh +1 -1
  44. metadata +2 -5
  45. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
  46. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
  47. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
  48. 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 CDN-accelerated endpoint
28
- PRIMARY_HOST = "https://www.openclacky.com"
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 = "https://openclacky.up.railway.app"
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 = 2
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 = 8
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
 
@@ -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-k2.6",
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-k2.5",
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
- # Resolve the capabilities hash for a given provider+model.
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. Anonymous (no license needed).
2363
- # Proxies BrandConfig#search_extensions! which hits the platform's public
2364
- # /api/v1/extensions endpoint. Extension archives are NOT downloadable here
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 = ext["name"] || ext[:name] || ext["slug"] || ext[: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) : 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&.dig("description"),
2417
- "author" => market&.dig("author"),
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&.dig("units"),
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
- "unlisted" => market.nil?,
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
- result = brand.extension_detail!(id)
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) : 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&.dig("description"),
2506
- "author" => market&.dig("author"),
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&.dig("units"),
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
- "unlisted" => market.nil?,
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
- id = parse_json_body(req)["id"].to_s
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
- # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
3695
- # Ruby's File.exist? does NOT automatically expand ~ that's a shell feature.
3696
- path = File.expand_path(path)
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 'download'" })
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:// prefix if present
3746
- path = raw_path.sub(%r{\Afile://}, "")
3747
- path = CGI.unescape(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
@@ -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(file_pattern, relative, fnmatch_flags)
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 to a WSL/Linux-side path.
56
- # e.g. "C:/Users/foo/file.txt" "/mnt/c/Users/foo/file.txt"
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 && path.match?(/\A[A-Za-z]:[\/\\]/)
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
- drive = path[0].downcase
64
- rest = path[2..].gsub("\\", "/")
65
- "/mnt/#{drive}#{rest}"
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.