openclacky 1.5.4 → 1.5.5

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/lib/clacky/agent/message_compressor_helper.rb +4 -1
  4. data/lib/clacky/agent/session_serializer.rb +1 -0
  5. data/lib/clacky/agent.rb +1 -1
  6. data/lib/clacky/agent_config.rb +4 -2
  7. data/lib/clacky/agent_profile.rb +25 -3
  8. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  9. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +42 -33
  10. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +4 -2
  11. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  12. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  13. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  14. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  15. data/lib/clacky/extension/api_extension.rb +2 -0
  16. data/lib/clacky/extension/loader.rb +1 -0
  17. data/lib/clacky/extension/packager.rb +60 -15
  18. data/lib/clacky/extension/verifier.rb +1 -1
  19. data/lib/clacky/message_format/anthropic.rb +1 -1
  20. data/lib/clacky/message_format/bedrock.rb +1 -1
  21. data/lib/clacky/message_format/open_ai.rb +8 -0
  22. data/lib/clacky/providers.rb +61 -0
  23. data/lib/clacky/server/http_server.rb +154 -15
  24. data/lib/clacky/server/web_ui_controller.rb +12 -2
  25. data/lib/clacky/session_manager.rb +1 -0
  26. data/lib/clacky/tools/terminal.rb +30 -3
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +301 -171
  29. data/lib/clacky/web/app.js +16 -2
  30. data/lib/clacky/web/features/extensions/store.js +214 -9
  31. data/lib/clacky/web/features/extensions/view.js +156 -11
  32. data/lib/clacky/web/features/new-session/store.js +25 -3
  33. data/lib/clacky/web/features/new-session/view.js +337 -140
  34. data/lib/clacky/web/features/skills/view.js +1 -1
  35. data/lib/clacky/web/i18n.js +29 -11
  36. data/lib/clacky/web/index.html +36 -64
  37. data/lib/clacky/web/sessions.js +2 -1
  38. data/lib/clacky/web/utils.js +1 -0
  39. data/lib/clacky/web/ws-dispatcher.js +39 -3
  40. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10e414552c9ae0e3bb7a0b547f2e4da0709c83e0a0925f3b708820848e1a4cf3
4
- data.tar.gz: 6caa29ccd3048e95912500be99000ae7ca883c9a5ad473ebc1bb2f243aef5cc3
3
+ metadata.gz: 5803d9775474800f6f61d6771837ebe90597080e0f068a9dff49d5d44fdc90d1
4
+ data.tar.gz: f111f2b083635876cd2e4477e5a64da14245684c5c18a5f5f23860541ac9def0
5
5
  SHA512:
6
- metadata.gz: 26f220973c6821a411362df35ca281ef53ae58e6ab36d26651db393b31713340ca9bcf131f69bf4e6aa391eec3f4f21fc7d0d12cb4bbbdc368b6fe8068fa2dfa
7
- data.tar.gz: 84faf59b60486d29a9168f847c97d7fd5670dac293f967bd713c08049612adab7f947fa38af3a7e89c4a5acc2cc8c460e9b8ebbf53c189c19a52bf7e89a44388
6
+ metadata.gz: a0cc2a8397b5165b941a5aec4f1fc72770d47fdee4b24b72d862b12154d6a9b8114563b2e977d64e43a39c86d278fb5bbb97b045357c0a00c2f818f6466dbbf8
7
+ data.tar.gz: '07913be97ae9966164649f88a8e8abc60346c80038aec65886c51d9552e75eaf1050e3a9cc6fc6e28d1137d00d9fabcf735a8ac195bc3c5554890a6a8c0a073d'
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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.5] - 2026-08-04
8
+
9
+ ### Added
10
+ - Ollama (Cloud) provider preset — connect to self-hosted Ollama instances with one click
11
+ - Extension WebSocket bridge — extensions can now subscribe to session events via `Clacky.ext` bus
12
+ - Hidden field support for extension agent profiles
13
+ - Max reasoning effort level beyond `xhigh` (supports `max` tier)
14
+ - Smart terminal output truncation — keeps head and tail of long output instead of cutting at the end
15
+ - Time machine panel now attached to all views
16
+ - Persist sort and installed filter selection across sessions in extensions panel
17
+ - New extension button added to extensions management page header
18
+ - Auto cleanup of trash sessions after 7 days
19
+
20
+ ### Improved
21
+ - Extensions panel redesigned with system/market tabs and toggle switch
22
+ - New-session page: replaced advanced modal with inline context bar chips; agents refresh on open with localized badges, ordered by most-recently-used
23
+ - Overflow tooltip shown on truncated skill descriptions
24
+ - Compression: idle and threshold switches are now separate controls
25
+ - Extension install is now async with progress polling
26
+
27
+ ### Fixed
28
+ - Ollama base URL corrected from `/api` to root domain
29
+ - Extension WebSocket bridge included `hidden` field in verifier AGENT_KEYS
30
+ - User message files render immediately in Web UI; local disk images proxied correctly
31
+ - Sidebar visibility persisted across page reloads
32
+ - Brand logo injected server-side to eliminate flash on load
33
+ - Extension uninstall 404 resolved by normalizing slug field
34
+ - Extension homepage link color variable corrected
35
+ - `request_user_feedback` skill uses structured JSON example to avoid literal `\n` in output
36
+ - `create_session` in extensions now broadcasts session update correctly
37
+
7
38
  ## [1.5.4] - 2026-08-01
8
39
 
9
40
  ### Added
@@ -23,6 +23,7 @@ module Clacky
23
23
  Clacky::Logger.info(
24
24
  "Idle compression skipped",
25
25
  enable_compression: @config.enable_compression,
26
+ enable_idle_compression: @config.enable_idle_compression,
26
27
  previous_total_tokens: @previous_total_tokens,
27
28
  history_size: @history.size,
28
29
  idle_threshold: IDLE_COMPRESSION_THRESHOLD,
@@ -129,8 +130,10 @@ module Clacky
129
130
  # (force: true) is still allowed as a safety net for long-running subagents.
130
131
  return nil if @is_subagent && !force
131
132
 
132
- # Check if compression is enabled
133
+ # Check if compression is enabled (master switch)
133
134
  return nil unless @config.enable_compression
135
+ # Idle compression has its own sub-switch under the master
136
+ return nil if force && !@config.enable_idle_compression
134
137
 
135
138
  # Use the larger of: API-reported tokens from last response, or current
136
139
  # estimated history size. The estimate guards the case where a single
@@ -224,6 +224,7 @@ module Clacky
224
224
  # (falling back to the current default if the model no longer exists).
225
225
  permission_mode: @config.permission_mode.to_s,
226
226
  enable_compression: @config.enable_compression,
227
+ enable_idle_compression: @config.enable_idle_compression,
227
228
  enable_prompt_caching: @config.enable_prompt_caching,
228
229
  max_tokens: @config.max_tokens,
229
230
  verbose: @config.verbose,
data/lib/clacky/agent.rb CHANGED
@@ -53,7 +53,7 @@ module Clacky
53
53
  attr_accessor :channel_info
54
54
  attr_accessor :project_id
55
55
 
56
- REASONING_EFFORTS = %w[low medium high xhigh].freeze
56
+ REASONING_EFFORTS = %w[low medium high xhigh max].freeze
57
57
 
58
58
  def permission_mode
59
59
  @config&.permission_mode&.to_s || ""
@@ -159,7 +159,7 @@ module Clacky
159
159
  DEFAULT_MESSAGE_COUNT_THRESHOLD = 200
160
160
 
161
161
  attr_accessor :permission_mode, :max_tokens, :verbose,
162
- :enable_compression, :enable_prompt_caching,
162
+ :enable_compression, :enable_idle_compression, :enable_prompt_caching,
163
163
  :compression_threshold, :message_count_threshold,
164
164
  :models, :current_model_index, :current_model_id,
165
165
  :memory_update_enabled, :skill_evolution,
@@ -173,6 +173,7 @@ module Clacky
173
173
  @max_tokens = options[:max_tokens] || 16384
174
174
  @verbose = options[:verbose] || false
175
175
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
176
+ @enable_idle_compression = options[:enable_idle_compression].nil? ? true : options[:enable_idle_compression]
176
177
  # Enable prompt caching by default for cost savings
177
178
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
178
179
  # Token threshold that triggers proactive history compression. Local models
@@ -411,7 +412,7 @@ module Clacky
411
412
  # Settings keys that are persisted to config.yml.
412
413
  # These map directly to AgentConfig accessors.
413
414
  CONFIG_SETTINGS_KEYS = %w[
414
- enable_compression enable_prompt_caching
415
+ enable_compression enable_idle_compression enable_prompt_caching
415
416
  compression_threshold message_count_threshold
416
417
  memory_update_enabled
417
418
  skill_evolution max_running_agents max_idle_agents
@@ -428,6 +429,7 @@ module Clacky
428
429
  end
429
430
  settings = {
430
431
  "enable_compression" => @enable_compression,
432
+ "enable_idle_compression" => @enable_idle_compression,
431
433
  "enable_prompt_caching" => @enable_prompt_caching,
432
434
  "compression_threshold" => @compression_threshold,
433
435
  "message_count_threshold" => @message_count_threshold,
@@ -53,8 +53,11 @@ module Clacky
53
53
 
54
54
  # List all available agent profiles across user + extension layers.
55
55
  # Precedence on id collision: user override → extension unit.
56
+ # @param recency [Hash<String, Integer>] agent id => epoch of last use,
57
+ # used to order third-party extension agents by most-recently-used
58
+ # instead of their static `order` field.
56
59
  # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }
57
- def self.all
60
+ def self.all(recency: {})
58
61
  out = {}
59
62
 
60
63
  add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
@@ -76,6 +79,7 @@ module Clacky
76
79
  ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
77
80
  ext_result&.agents&.each do |unit|
78
81
  spec = unit.spec || {}
82
+ next if spec["hidden"]
79
83
  title = spec["title"].to_s
80
84
  title = unit.id if title.empty?
81
85
  avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
@@ -102,8 +106,26 @@ module Clacky
102
106
  )
103
107
  end
104
108
 
105
- source_rank = { "user" => 0, "extension" => 1 }
106
- out.values.sort_by { |a| [source_rank[a[:source]] || 9, a[:order] || 999, a[:id]] }
109
+ # Builtin agents always come first, then user overrides, then third-party
110
+ # extension agents so the "start new session" grid keeps the official
111
+ # agents grouped ahead of anything the user installed from the market.
112
+ # Within the third-party group, agents are ordered by most-recently-used
113
+ # first (via `recency`), falling back to their static `order` field for
114
+ # agents that have never been used yet.
115
+ layer_rank = lambda do |a|
116
+ next 0 if a[:layer] == "builtin"
117
+ next 1 if a[:source] == "user"
118
+
119
+ 2
120
+ end
121
+ out.values.sort_by do |a|
122
+ rank = layer_rank.call(a)
123
+ if rank == 2
124
+ [rank, -(recency[a[:id]] || 0), a[:order] || 999, a[:id]]
125
+ else
126
+ [rank, a[:order] || 999, a[:id]]
127
+ end
128
+ end
107
129
  end
108
130
 
109
131
  private_class_method def self.read_profile_yml(path)
@@ -19,8 +19,8 @@ contributes:
19
19
  - id: ext-developer
20
20
  title: Extension Developer
21
21
  title_zh: 扩展开发
22
- description: AI expert that helps you build, debug, and publish extensions
23
- description_zh: 帮你开发、调试、发布扩展的 AI 专家
22
+ description: AI expert that helps you build custom AI agent workflows
23
+ description_zh: 帮你开发自定义 AI Agent 工作流的专家
24
24
  order: 5
25
25
  prompt: agents/ext-developer/system_prompt.md
26
26
  avatar: agents/ext-developer/avatar.png
@@ -146,7 +146,7 @@
146
146
  "extlist.newExt.label": "Create a new extension",
147
147
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
148
148
  "extlist.newExt.btn": "Create New Extension",
149
- "extlist.newExt.seed": "I want to build a new OpenClacky extension. Help me get started: ask me what the extension should do, then scaffold it and build it out step by step.",
149
+ "extlist.newExt.seed": "I want to build a new extension. Help me get started: ask me what the extension should do, then scaffold it and build it out step by step.",
150
150
  "unit.panel": "panel",
151
151
  "unit.panels": "panels",
152
152
  "unit.agent": "agent",
@@ -331,7 +331,7 @@
331
331
  "extlist.newExt.label": "新建扩展",
332
332
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
333
333
  "extlist.newExt.btn": "新建扩展",
334
- "extlist.newExt.seed": "我想开发一个新的 OpenClacky 扩展。请引导我开始:先问清楚这个扩展要做什么,然后帮我搭好骨架并一步步开发出来。",
334
+ "extlist.newExt.seed": "我想开发一个新的扩展。请引导我开始:先问清楚这个扩展要做什么,然后帮我搭好骨架并一步步开发出来。",
335
335
  "unit.panel": "个面板",
336
336
  "unit.panels": "个面板",
337
337
  "unit.agent": "个 Agent",
@@ -1567,28 +1567,29 @@
1567
1567
  return box;
1568
1568
  }
1569
1569
 
1570
- function rebuild() {
1570
+ function rebuild(headerActions) {
1571
1571
  if (!container) return;
1572
1572
  container.innerHTML = "";
1573
1573
 
1574
- const newBox = el("div", { class: "studio-skill-promo" });
1575
- newBox.appendChild(el("p", { class: "studio-skill-promo-text", text: t("extlist.newExt.label") }));
1576
- newBox.appendChild(el("p", { class: "studio-skill-hint", text: t("extlist.newExt.hint") }));
1577
- const newBar = el("div", { class: "studio-actions" });
1578
- newBar.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("extlist.newExt.btn"), onclick: () => createExtension(t("extlist.newExt.seed")) }));
1579
- newBox.appendChild(newBar);
1580
- container.appendChild(newBox);
1574
+ if (headerActions) {
1575
+ headerActions.innerHTML = "";
1576
+ headerActions.appendChild(el("button", {
1577
+ class: "studio-btn studio-btn-primary",
1578
+ text: t("extlist.newExt.btn"),
1579
+ onclick: () => createExtension(t("extlist.newExt.seed")),
1580
+ }));
1581
+ }
1581
1582
 
1582
1583
  container.appendChild(section("extlist.section.cloud", "extlist.section.cloudHint", cloud, cloudCard, "extlist.cloud.empty"));
1583
1584
  container.appendChild(section("extlist.section.local", "extlist.section.localHint", store.extensions, localCard, "extlist.local.empty"));
1584
1585
  }
1585
1586
 
1586
1587
  return {
1587
- async attach(root) {
1588
+ async attach(root, headerActions) {
1588
1589
  container = el("div", { class: "studio-panel" });
1589
1590
  root.appendChild(container);
1590
1591
  container.innerHTML = _skeletonHtml();
1591
- try { await reload(); rebuild(); }
1592
+ try { await reload(); rebuild(headerActions); }
1592
1593
  catch (e) { container.innerHTML = ""; container.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) })); }
1593
1594
  },
1594
1595
  };
@@ -1723,24 +1724,27 @@
1723
1724
  return box;
1724
1725
  }
1725
1726
 
1726
- function rebuild() {
1727
+ function rebuild(headerActions) {
1727
1728
  if (!container) return;
1728
1729
  container.innerHTML = "";
1729
1730
 
1730
- if (locked) {
1731
- const promo = el("div", { class: "studio-skill-promo" });
1732
- promo.appendChild(el("p", { class: "studio-skill-promo-text", text: t("skills.promo.text") }));
1733
- promo.appendChild(el("p", { class: "studio-empty", text: t("skills.locked") }));
1734
- container.appendChild(promo);
1731
+ if (headerActions) {
1732
+ headerActions.innerHTML = "";
1733
+ const newBtn = el("button", {
1734
+ class: "studio-btn studio-btn-primary",
1735
+ text: t("skills.newSkill.btn"),
1736
+ onclick: () => createSkill(null),
1737
+ });
1738
+ newBtn.disabled = locked;
1739
+ newBtn.title = locked ? t("skills.locked") : "";
1740
+ headerActions.appendChild(newBtn);
1735
1741
  }
1736
1742
 
1737
- const newBox = el("div", { class: "studio-skill-promo" });
1738
- newBox.appendChild(el("p", { class: "studio-skill-promo-text", text: t("skills.newSkill.btn") }));
1739
- newBox.appendChild(el("p", { class: "studio-skill-hint", text: t("skills.newSkill.label") }));
1740
- const newBar = el("div", { class: "studio-actions" });
1741
- newBar.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("skills.newSkill.btn"), onclick: () => createSkill(null) }));
1742
- newBox.appendChild(newBar);
1743
- container.appendChild(newBox);
1743
+ if (locked) {
1744
+ const lockHint = el("p", { class: "studio-empty", text: t("skills.locked") });
1745
+ lockHint.style.marginBottom = "12px";
1746
+ container.appendChild(lockHint);
1747
+ }
1744
1748
 
1745
1749
  const cloudEmptyKey = locked ? "skills.cloud.locked" : "skills.cloud.empty";
1746
1750
  container.appendChild(section("skills.section.cloud", "skills.section.cloudHint", cloud, cloudCard, cloudEmptyKey));
@@ -1748,11 +1752,11 @@
1748
1752
  }
1749
1753
 
1750
1754
  return {
1751
- async attach(root) {
1755
+ async attach(root, headerActions) {
1752
1756
  container = el("div", { class: "studio-panel" });
1753
1757
  root.appendChild(container);
1754
1758
  container.innerHTML = _skeletonHtml();
1755
- try { await reload(); rebuild(); }
1759
+ try { await reload(); rebuild(headerActions); }
1756
1760
  catch (e) { container.innerHTML = ""; container.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) })); }
1757
1761
  },
1758
1762
  };
@@ -1768,6 +1772,8 @@
1768
1772
 
1769
1773
  const header = el("div", { class: "studio-page-head" });
1770
1774
  header.appendChild(el("h2", { class: "studio-page-title", text: t("ws.title") }));
1775
+ const headerActions = el("div", { class: "studio-page-head-actions" });
1776
+ header.appendChild(headerActions);
1771
1777
  page.appendChild(header);
1772
1778
 
1773
1779
  const tabsBar = el("div", { class: "studio-tabs" });
@@ -1785,8 +1791,9 @@
1785
1791
  active = id;
1786
1792
  Array.from(tabsBar.children).forEach((b) => b.classList.toggle("studio-tab-active", b.dataset.tab === id));
1787
1793
  body.innerHTML = "";
1794
+ headerActions.innerHTML = "";
1788
1795
  const tab = tabs.find((t2) => t2.id === id);
1789
- if (tab) tab.build(body);
1796
+ if (tab) tab.build(body, headerActions);
1790
1797
  }
1791
1798
 
1792
1799
  tabs.forEach((tab) => {
@@ -1799,12 +1806,12 @@
1799
1806
  select("extensions");
1800
1807
  }
1801
1808
 
1802
- function buildExtensionsTab(root) {
1803
- createExtensionsPanel().attach(root);
1809
+ function buildExtensionsTab(root, headerActions) {
1810
+ createExtensionsPanel().attach(root, headerActions);
1804
1811
  }
1805
1812
 
1806
- function buildSkillsTab(root) {
1807
- createSkillsPanel().attach(root);
1813
+ function buildSkillsTab(root, headerActions) {
1814
+ createSkillsPanel().attach(root, headerActions);
1808
1815
  }
1809
1816
 
1810
1817
  function navRow(labelKey, onClick) {
@@ -1904,8 +1911,10 @@
1904
1911
  .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; font-size: 12px; border-bottom: 1px solid var(--color-border-primary); }
1905
1912
  .studio-published-name { display: flex; align-items: center; gap: 6px; color: var(--color-text-primary); }
1906
1913
  .studio-page { width: 100%; }
1907
- .studio-page-head { margin-bottom: 20px; }
1914
+ .studio-page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
1915
+ .studio-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
1908
1916
  .studio-page-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
1917
+ .studio-btn-sm { padding: 5px 12px; font-size: 12px; }
1909
1918
  .studio-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 24px; }
1910
1919
  .studio-tab { padding: 8px 16px; border: none; background: transparent; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
1911
1920
  .studio-tab:hover { color: var(--color-text-primary); }
@@ -24,7 +24,7 @@ Seven `contributes:` types (use one, several, or all):
24
24
  - `panels` — WebUI panels (a `view.js`, no build step, no React, no iframe)
25
25
  - `api` — one backend file `api/handler.rb`, mounted at `/api/ext/<id>/`
26
26
  - `skills` — a `SKILL.md` under `skills/<id>/` (prompt-only capability)
27
- - `agents` — a `system_prompt.md`; can reference `panels: [id]` and `skills: [id]`
27
+ - `agents` — a `system_prompt.md`; can reference `panels: [id]` and `skills: [id]`; `hidden: true` hides from the new-session picker
28
28
  - `channels` — an IM adapter
29
29
  - `patches` — monkey-patch a real class (advanced, supply-chain risk)
30
30
  - `hooks` — lifecycle hooks like `before_tool_use` (advanced)
@@ -92,10 +92,12 @@ exactly three capabilities:
92
92
 
93
93
  ```js
94
94
  Clacky.ext.ui.mount(slot, spec, opts) // inject UI into a named slot
95
- Clacky.ext.subscribe(event, handler) // observe host store events (read-only)
95
+ Clacky.ext.subscribe(event, handler) // observe store events + live session events (read-only)
96
96
  Clacky.ext.api.register(name, fn) // expose a named data source; api.resolve(name)
97
97
  ```
98
98
 
99
+ **`subscribe(event, handler)`** - two event families: (1) host **store** events (`skills:changed`, `tasks:changed`, `profile:changed`, …) and (2) **live session events** mirrored from the WebSocket stream — conversation (`session:assistant-message`, `session:tool-call`, `session:tool-result`), status/errors (`session:error`, `session:warning`, `session:update`), lifecycle (`session:renamed`, `session:deleted`, …). Payload is `{ sessionId, ...wsFields }`. Full list in the "Core Events" section of the extend-webui docs. Handlers are read-only.
100
+
99
101
  **`ui.mount(slot, spec, opts)`** — `spec` is either `(container, ctx, runtime) => …` or
100
102
  `{ create?, render }`. The render function:
101
103
  - gets a host-owned `container` DOM element — append into it, **or** return a Node / HTML
@@ -1,6 +1,9 @@
1
1
  id: git
2
2
  name: Git
3
+ display_name: Git
4
+ display_name_zh: Git
3
5
  description: Changes panel — a friendly view of what the AI changed in the working tree
6
+ description_zh: 变更面板 — 直观查看 AI 在工作区的每一处修改
4
7
  version: "0.1.0"
5
8
  author: OpenClacky
6
9
  homepage: https://www.openclacky.com
@@ -1,6 +1,9 @@
1
1
  id: meeting
2
2
  name: Meeting
3
+ display_name: Meeting
4
+ display_name_zh: 会议
3
5
  description: Real-time meeting transcription and AI assistant
6
+ description_zh: 实时会议转录与 AI 助手
4
7
  version: "0.1.0"
5
8
  author: OpenClacky
6
9
  homepage: https://www.openclacky.com
@@ -1,6 +1,9 @@
1
1
  id: time_machine
2
2
  name: Time Machine
3
+ display_name: Time Machine
4
+ display_name_zh: 时间机器
3
5
  description: Vertical timeline of session tasks with per-task diff and one-click restore
6
+ description_zh: 会话任务时间轴,支持按任务查看差异并一键还原
4
7
  version: "0.1.0"
5
8
  author: OpenClacky
6
9
  homepage: https://www.openclacky.com
@@ -9,3 +12,4 @@ contributes:
9
12
  panels:
10
13
  - id: time_machine
11
14
  view: panels/time_machine/view.js
15
+ attach: ["*"]
@@ -17,20 +17,25 @@ When user wants to create a new Rails project:
17
17
  ## Process Steps
18
18
 
19
19
  ### 0. Ask Project Type and Requirement
20
- Before doing anything, use `request_user_feedback` to ask the user two things:
21
-
22
- ```
23
- project_type: "demo" or "production"
24
- requirement: one-sentence description of what they want to build
20
+ Before doing anything, call `request_user_feedback` with a structured prompt. Use the following as a guide:
21
+
22
+ zh:
23
+ ```json
24
+ {
25
+ "question": "请选择项目类型,并用一句话描述你想做什么:",
26
+ "context": "开始新项目前需要知道项目类型和需求,请先完成填写。",
27
+ "options": ["⚡ Demo — 无数据库,AI 自由发挥,快速原型", "🏗️ Production — 真实应用,可部署,完整 Rails 配置"]
28
+ }
25
29
  ```
26
30
 
27
- Card content:
28
- - Title: "🚀 New Project"
29
- - Two options for project type:
30
- - **⚡ Demo** no database, AI builds freely, quick prototype
31
- - **🏗️ Production** real app, ready to deploy, full Rails setup
32
- - One text input: "Describe your project in one sentence"
33
- - Confirm button: "Let's go!"
31
+ en:
32
+ ```json
33
+ {
34
+ "question": "Choose a project type and describe what you want to build in one sentence:",
35
+ "context": "Tell us what kind of project to create so we can get started.",
36
+ "options": ["⚡ Demo no database, AI builds freely, quick prototype", "🏗️ Production real app, ready to deploy, full Rails setup"]
37
+ }
38
+ ```
34
39
 
35
40
  **Based on user's choice:**
36
41
  - If **Demo**: do NOT follow the Rails setup steps below. Instead, freely build a simple HTML/CSS/JS (or React) prototype directly in the working directory based on their requirement. Use your creativity.
@@ -277,6 +277,8 @@ module Clacky
277
277
 
278
278
  submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
279
279
 
280
+ @http_server.send(:broadcast_session_update, session_id)
281
+
280
282
  session_id
281
283
  end
282
284
 
@@ -422,6 +422,7 @@ module Clacky
422
422
  "description" => spec["description"].to_s,
423
423
  "description_zh" => spec["description_zh"].to_s,
424
424
  "order" => spec["order"],
425
+ "hidden" => !!spec["hidden"],
425
426
  "author" => container[:author],
426
427
  "homepage" => container[:homepage],
427
428
  "license" => container[:license],
@@ -18,6 +18,14 @@ module Clacky
18
18
  MANIFEST = "ext.yml"
19
19
  MAX_ZIP_SIZE = 50 * 1024 * 1024
20
20
 
21
+ # Network timeouts for the download step. `read_timeout` is an *idle*
22
+ # timeout (resets on every chunk received), so a stalled connection with
23
+ # no new bytes flowing for this long is treated as dead — not a "download
24
+ # still going, just slow" situation, which large files on a slow link
25
+ # would otherwise trigger.
26
+ OPEN_TIMEOUT = 15 # seconds to establish the connection
27
+ READ_TIMEOUT = 60 # seconds of silence before giving up mid-download
28
+
21
29
  # Platform metadata that leaks in from the developer's OS; never ship it.
22
30
  SYSTEM_METADATA = [".DS_Store", "__MACOSX", "Thumbs.db", "desktop.ini"].freeze
23
31
 
@@ -56,12 +64,19 @@ module Clacky
56
64
  # local zip path or an http(s) URL. Validates the archive (single top
57
65
  # container with an ext.yml, no path traversal), extracts it, then runs
58
66
  # verify on the resolved installed layer.
59
- def install(source, installed_dir: Clacky::ExtensionLoader::INSTALLED_DIR, force: false)
67
+ # on_progress is an optional callable that receives a stage hash, e.g.:
68
+ # { stage: "downloading", progress: 45 }
69
+ # { stage: "extracting" }
70
+ # { stage: "verifying" }
71
+ # Caller can use this for progress reporting without coupling to internals.
72
+ def install(source, installed_dir: Clacky::ExtensionLoader::INSTALLED_DIR, force: false, on_progress: nil)
60
73
  Dir.mktmpdir("clacky-ext-install") do |tmp|
61
- zip_path = local_zip_for(source, tmp)
74
+ on_progress&.call({ stage: "downloading", progress: 0 })
75
+ zip_path = local_zip_for(source, tmp, on_progress: on_progress)
76
+
77
+ on_progress&.call({ stage: "extracting" })
62
78
  extract_root = File.join(tmp, "unpacked")
63
79
  FileUtils.mkdir_p(extract_root)
64
-
65
80
  extract_zip(zip_path, extract_root)
66
81
 
67
82
  ext_id, container_src = locate_container(extract_root)
@@ -74,6 +89,7 @@ module Clacky
74
89
  FileUtils.mkdir_p(target)
75
90
  FileUtils.cp_r(Dir.glob("#{container_src}/*"), target)
76
91
 
92
+ on_progress&.call({ stage: "verifying" })
77
93
  Clacky::ExtensionLoader.invalidate_cache!
78
94
  units = verify_installed(ext_id, installed_dir)
79
95
 
@@ -149,11 +165,11 @@ module Clacky
149
165
  rel.split(File::SEPARATOR).any? { |seg| SYSTEM_METADATA.include?(seg) }
150
166
  end
151
167
 
152
- private def local_zip_for(source, tmp)
168
+ private def local_zip_for(source, tmp, on_progress: nil)
153
169
  src = source.to_s
154
170
  if src.match?(%r{\Ahttps?://})
155
171
  dest = File.join(tmp, "download.zip")
156
- download(src, dest)
172
+ download(src, dest, on_progress: on_progress)
157
173
  dest
158
174
  else
159
175
  path = File.expand_path(src)
@@ -162,19 +178,48 @@ module Clacky
162
178
  end
163
179
  end
164
180
 
165
- private def download(url, dest)
166
- total = 0
167
- URI.open(url, "rb") do |io| # rubocop:disable Security/Open
168
- File.open(dest, "wb") do |out|
169
- while (chunk = io.read(64 * 1024))
170
- total += chunk.bytesize
171
- raise Error, "download exceeds #{MAX_ZIP_SIZE} bytes" if total > MAX_ZIP_SIZE
172
- out.write(chunk)
173
- end
174
- end
181
+ # Download url dest file.
182
+ # IMPORTANT: OpenURI's block form (`URI.open(url) { |io| ... }`) buffers
183
+ # the *entire* response into a StringIO/Tempfile via Net::HTTP's
184
+ # read_body before ever yielding `io` — so reading `io` in chunks after
185
+ # the fact reports progress only after the download has already
186
+ # finished. To get progress callbacks that fire while bytes are still
187
+ # arriving on the socket, we hook OpenURI's own `progress_proc` /
188
+ # `content_length_proc`, which run inside read_body as each chunk lands.
189
+ # Respects whatever proxy env vars the user has configured (some CDN
190
+ # domains are only reachable at usable speed through a proxy).
191
+ private def download(url, dest, on_progress: nil)
192
+ content_length = nil
193
+
194
+ content_length_proc = lambda do |len|
195
+ content_length = len if len&.positive?
196
+ end
197
+
198
+ progress_proc = lambda do |size|
199
+ raise Error, "download exceeds #{MAX_ZIP_SIZE} bytes" if size > MAX_ZIP_SIZE
200
+ next unless on_progress
201
+
202
+ pct = if content_length&.positive?
203
+ [(size * 100 / content_length).floor, 99].min
204
+ else
205
+ (90 - 90 * Math.exp(-size.to_f / (1024 * 1024))).floor
206
+ end
207
+ on_progress.call({ stage: "downloading", progress: pct })
208
+ end
209
+
210
+ URI.open(
211
+ url, "rb",
212
+ open_timeout: OPEN_TIMEOUT,
213
+ read_timeout: READ_TIMEOUT,
214
+ content_length_proc: content_length_proc,
215
+ progress_proc: progress_proc
216
+ ) do |io| # rubocop:disable Security/Open
217
+ File.open(dest, "wb") { |out| out.write(io.read) }
175
218
  end
176
219
  rescue OpenURI::HTTPError, SocketError => e
177
220
  raise Error, "failed to download #{url}: #{e.message}"
221
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
222
+ raise Error, "download timed out: #{e.message}"
178
223
  end
179
224
 
180
225
  private def extract_zip(zip_path, dest_root)
@@ -22,7 +22,7 @@ module Clacky
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
23
23
  API_KEYS = %w[id handler].freeze
24
24
  SKILL_KEYS = %w[id dir protected].freeze
25
- AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar hidden].freeze
26
26
  CHANNEL_KEYS = %w[id platform adapter].freeze
27
27
  PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
28
28
  HOOK_KEYS = %w[event file].freeze
@@ -114,7 +114,7 @@ module Clacky
114
114
  private_class_method def self.normalized_effort(effort)
115
115
  return nil if effort.nil? || effort.to_s.empty?
116
116
  s = effort.to_s
117
- %w[low medium high].include?(s) ? s : nil
117
+ %w[low medium high xhigh max].include?(s) ? s : nil
118
118
  end
119
119
 
120
120
  # Merge consecutive tool_result user messages into a single user message.
@@ -91,7 +91,7 @@ module Clacky
91
91
 
92
92
  private_class_method def self.additional_fields_for_effort(effort)
93
93
  return nil if effort.nil? || effort.to_s.empty?
94
- return nil unless %w[low medium high].include?(effort.to_s)
94
+ return nil unless %w[low medium high xhigh max].include?(effort.to_s)
95
95
  {
96
96
  thinking: { type: "adaptive" },
97
97
  output_config: { effort: effort.to_s }
@@ -279,6 +279,14 @@ module Clacky
279
279
  elsif !effort_str.empty?
280
280
  body[:thinking] = { type: "adaptive" }
281
281
  end
282
+ elsif model.to_s.match?(/deepseek/i)
283
+ # DeepSeek V4 reasoning_effort only accepts low/high/max.
284
+ # xhigh is not recognized — map it to max.
285
+ effort = case effort_str
286
+ when "xhigh" then "max"
287
+ else effort_str
288
+ end
289
+ body[:reasoning_effort] = effort unless effort.empty?
282
290
  elsif reasoning_effort && !effort_str.empty?
283
291
  body[:reasoning_effort] = effort_str
284
292
  end