openclacky 1.5.3 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +57 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +17 -16
  10. data/lib/clacky/agent/session_serializer.rb +7 -0
  11. data/lib/clacky/agent.rb +14 -2
  12. data/lib/clacky/agent_config.rb +10 -5
  13. data/lib/clacky/agent_profile.rb +25 -3
  14. data/lib/clacky/brand_config.rb +32 -12
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -5
  17. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  18. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +43 -34
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +9 -3
  20. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  23. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  24. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  25. data/lib/clacky/extension/api_extension.rb +5 -2
  26. data/lib/clacky/extension/loader.rb +1 -0
  27. data/lib/clacky/extension/packager.rb +60 -15
  28. data/lib/clacky/extension/verifier.rb +1 -1
  29. data/lib/clacky/message_format/anthropic.rb +1 -1
  30. data/lib/clacky/message_format/bedrock.rb +1 -1
  31. data/lib/clacky/message_format/open_ai.rb +34 -3
  32. data/lib/clacky/providers.rb +77 -8
  33. data/lib/clacky/server/http_server.rb +495 -39
  34. data/lib/clacky/server/project_manager.rb +150 -0
  35. data/lib/clacky/server/server_master.rb +7 -0
  36. data/lib/clacky/server/session_registry.rb +14 -1
  37. data/lib/clacky/server/web_ui_controller.rb +12 -2
  38. data/lib/clacky/session_manager.rb +6 -4
  39. data/lib/clacky/tools/file_reader.rb +2 -1
  40. data/lib/clacky/tools/terminal.rb +30 -3
  41. data/lib/clacky/utils/model_pricing.rb +69 -18
  42. data/lib/clacky/utils/parser_manager.rb +115 -7
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +1216 -318
  45. data/lib/clacky/web/app.js +27 -4
  46. data/lib/clacky/web/components/notify.js +139 -22
  47. data/lib/clacky/web/core/aside.js +21 -0
  48. data/lib/clacky/web/features/brand/store.js +3 -1
  49. data/lib/clacky/web/features/brand/view.js +29 -5
  50. data/lib/clacky/web/features/extensions/store.js +214 -9
  51. data/lib/clacky/web/features/extensions/view.js +156 -11
  52. data/lib/clacky/web/features/new-session/store.js +25 -3
  53. data/lib/clacky/web/features/new-session/view.js +339 -142
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/features/trash/store.js +23 -9
  56. data/lib/clacky/web/features/trash/view.js +51 -0
  57. data/lib/clacky/web/i18n.js +124 -15
  58. data/lib/clacky/web/index.html +105 -79
  59. data/lib/clacky/web/projects.js +1143 -0
  60. data/lib/clacky/web/sessions.js +320 -538
  61. data/lib/clacky/web/settings.js +138 -5
  62. data/lib/clacky/web/utils.js +1 -0
  63. data/lib/clacky/web/ws-dispatcher.js +98 -4
  64. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  65. data/scripts/install_system_deps.sh +11 -3
  66. metadata +5 -2
  67. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -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)
@@ -383,16 +383,16 @@ module Clacky
383
383
 
384
384
  # Returns true when a public distribution refresh is due.
385
385
  #
386
- # Refresh is needed only when the install has a package_name configured
387
- # but is not yet activated — activated licenses already get fresh
388
- # distribution data via #heartbeat! (once per 24h).
386
+ # Refresh is needed for unactivated branded installs and for private
387
+ # platform sources that have not supplied their deployment brand yet.
388
+ # Activated licenses already get fresh distribution data via #heartbeat!.
389
389
  #
390
390
  # Rate limit: once every HEARTBEAT_INTERVAL (24h), measured from the last
391
391
  # SUCCESSFUL refresh. A failed refresh does not advance the clock so we'll
392
392
  # keep trying on subsequent startups / status polls.
393
393
  def distribution_refresh_due?
394
- return false unless branded?
395
394
  return false if activated?
395
+ return false unless branded? || private_platform_source?
396
396
  return true if @distribution_last_refreshed_at.nil?
397
397
 
398
398
  elapsed = Time.now.utc - @distribution_last_refreshed_at
@@ -400,7 +400,8 @@ module Clacky
400
400
  end
401
401
 
402
402
  # Refresh public brand assets (logo, theme, homepage_url, support_*) for
403
- # installs that have `package_name` configured but no activated license yet.
403
+ # unactivated installs. Package installs use an exact package lookup;
404
+ # private platform sources without a local brand use the source default.
404
405
  #
405
406
  # Motivation: `install.sh --brand-name=X --command=X` only writes
406
407
  # product_name + package_name to brand.yml. The rest of the distribution
@@ -409,32 +410,47 @@ module Clacky
409
410
  # anonymous public lookup endpoint.
410
411
  #
411
412
  # Behaviour:
412
- # * No-op (returns { success: false, message: "..." }) when not branded,
413
- # already activated, or package_name is blank.
413
+ # * No-op when already activated, or when neither a package_name nor a
414
+ # private platform source is available.
414
415
  # * On success: apply_distribution + save + stamp
415
416
  # @distribution_last_refreshed_at.
417
+ # * When a block is provided, discard the response unless it still
418
+ # confirms that the platform source is current.
416
419
  # * On failure: log and return without touching the timestamp (so we
417
420
  # retry on next trigger).
418
421
  #
419
422
  # Returns { success: Boolean, message: String }.
420
423
  def refresh_distribution!
421
- unless branded?
424
+ source_brand_lookup = private_platform_source? &&
425
+ (@package_name.nil? || @package_name.strip.empty?)
426
+ unless branded? || source_brand_lookup
422
427
  return { success: false, message: "Not branded" }
423
428
  end
424
429
  if activated?
425
430
  return { success: false, message: "License activated — use heartbeat! instead" }
426
431
  end
427
- if @package_name.nil? || @package_name.strip.empty?
432
+ if !source_brand_lookup && (@package_name.nil? || @package_name.strip.empty?)
428
433
  return { success: false, message: "package_name not configured" }
429
434
  end
430
435
 
431
- encoded_pkg = URI.encode_www_form_component(@package_name.strip)
432
- path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}"
436
+ if source_brand_lookup
437
+ path = "/api/v1/distributions/lookup"
438
+ else
439
+ encoded_pkg = URI.encode_www_form_component(@package_name.strip)
440
+ path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}"
441
+ end
433
442
 
434
- Clacky::Logger.info("[Brand] refresh_distribution! fetching — package_name=#{@package_name}")
443
+ Clacky::Logger.info(
444
+ "[Brand] refresh_distribution! fetching — package_name=#{@package_name || "source-default"}"
445
+ )
435
446
  response = platform_client.get(path)
436
447
 
437
448
  if response[:success] && response[:data].is_a?(Hash) && response[:data]["distribution"].is_a?(Hash)
449
+ if block_given? && !yield
450
+ Clacky::Logger.info("[Brand] refresh_distribution! discarded — platform source changed")
451
+ return { success: false, message: "Platform source changed during refresh" }
452
+ end
453
+
438
454
  apply_distribution(response[:data]["distribution"])
439
455
  @distribution_last_refreshed_at = Time.now.utc
440
456
  save
@@ -1762,6 +1778,10 @@ module Clacky
1762
1778
  @homepage_url = dist["homepage_url"] if dist.key?("homepage_url")
1763
1779
  end
1764
1780
 
1781
+ private def private_platform_source?
1782
+ !ENV["CLACKY_LICENSE_SERVER"].to_s.strip.empty?
1783
+ end
1784
+
1765
1785
  # Download a remote URL to a local file path.
1766
1786
  #
1767
1787
  # Deprecated: this method now delegates to
@@ -143,3 +143,18 @@ main product, and apply every rule below whenever you propose or write code:
143
143
  Ruby and carry supply-chain risk.
144
144
  - After editing `view.js`, `handler.rb`, or a `SKILL.md`, tell the user to reload the
145
145
  WebUI page — hot reload is per-request, no restart needed.
146
+ - After you finish editing extension files, call this once at the very end to trigger
147
+ a reload button in the UI (if it doesn't appear, the user can manually refresh the browser):
148
+ ```
149
+ curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/show_ext_refresh" \
150
+ -H "Content-Type: application/json" \
151
+ -d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
152
+ ```
153
+ (`--noproxy '*'` prevents shell proxy env vars from silently intercepting this loopback call.)
154
+ - If the extension contributes a `session.aside` panel, also call this right after the above
155
+ to open the panel automatically:
156
+ ```
157
+ curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/open_aside" \
158
+ -H "Content-Type: application/json" \
159
+ -d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
160
+ ```
@@ -201,11 +201,7 @@ class ExtStudioExt < Clacky::ApiExtension
201
201
  error!("extension not found: #{ext_id}", status: 404) unless container
202
202
 
203
203
  readme_path = File.join(container[:dir], "README.md")
204
- if readme.strip.empty?
205
- File.delete(readme_path) if File.exist?(readme_path)
206
- else
207
- File.write(readme_path, readme, encoding: "utf-8")
208
- end
204
+ File.write(readme_path, readme, encoding: "utf-8")
209
205
  end
210
206
 
211
207
  json(ok: true, ext_id: ext_id, saved_to: published ? "platform" : "local")
@@ -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",
@@ -1539,7 +1539,7 @@
1539
1539
  let data = {};
1540
1540
  try { data = await res.json(); } catch (_e) {}
1541
1541
  if (!res.ok) throw new Error(data?.error || `Error ${res.status}`);
1542
- await reload(); rebuild();
1542
+ location.reload();
1543
1543
  } catch (e) {
1544
1544
  alert(t("err.generic", { msg: e.message }));
1545
1545
  btn.disabled = false;
@@ -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
@@ -204,6 +206,10 @@ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filena
204
206
  - Drive sessions from the backend: `create_session(prompt:, profile:, …)`,
205
207
  `submit_task(session_id, prompt)`, `dispatch_to_session(session_id, prompt)` (runs a
206
208
  side task on a fork and returns its reply without touching the conversation).
209
+ - `create_session(hidden: true)` creates a session the openclacky session list hides
210
+ and the 200-session cleanup skips - use it for dedicated extension sessions you
211
+ manage yourself. Still reachable by `session_id` via `submit_task` /
212
+ `dispatch_to_session` / `registry.with_session`; forking resets `hidden` to false.
207
213
  - Public (no-auth) endpoints: call `public_endpoint("/path")` in the class **and** set
208
214
  `public: true` at ext.yml top level — both are required.
209
215
 
@@ -294,7 +300,7 @@ contributes:
294
300
  classes; swap the body for the real UI, POST to your own route:
295
301
 
296
302
  ```js
297
- Clacky.ext.ui.mount("session.aside", function (ctx) {
303
+ Clacky.ext.ui.mount("session.aside", function (container, ctx) {
298
304
  var el = document.createElement("div");
299
305
  el.style.padding = "16px";
300
306
  var input = document.createElement("input");
@@ -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: ["*"]
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """xlsx_parser.py — extract an XLSX/XLS workbook to Markdown tables.
3
+
4
+ Usage: python3 xlsx_parser.py <file_path>
5
+ Output: stdout — Markdown tables (UTF-8), one section per non-empty sheet
6
+ stderr — error messages
7
+ exit 0 success / 1 failure / 2 dependency missing
8
+
9
+ Uses openpyxl in read_only mode: a streaming reader that keeps memory
10
+ constant regardless of sheet size, so multi-megabyte worksheets parse in
11
+ seconds instead of exhausting RAM/CPU like a full DOM parse would.
12
+ """
13
+ # VERSION: 2
14
+ import sys
15
+
16
+
17
+ def build_table(rows):
18
+ width = max(len(r) for r in rows)
19
+ lines = []
20
+ for i, r in enumerate(rows):
21
+ padded = r + [""] * (width - len(r))
22
+ lines.append("| " + " | ".join(padded) + " |")
23
+ if i == 0:
24
+ lines.append("|" + " --- |" * width)
25
+ return "\n".join(lines)
26
+
27
+
28
+ def main():
29
+ if len(sys.argv) < 2:
30
+ sys.stderr.write("Usage: xlsx_parser.py <file_path>\n")
31
+ sys.exit(1)
32
+ path = sys.argv[1]
33
+
34
+ try:
35
+ from openpyxl import load_workbook
36
+ except ImportError as e:
37
+ sys.stderr.write(f"openpyxl not installed: {e}\n")
38
+ sys.stderr.write("Install with: pip3 install --user openpyxl\n")
39
+ sys.exit(2)
40
+
41
+ try:
42
+ wb = load_workbook(path, read_only=True, data_only=True)
43
+ except Exception as e:
44
+ sys.stderr.write(f"Failed to open workbook: {e}\n")
45
+ sys.exit(1)
46
+
47
+ sections = []
48
+ for ws in wb.worksheets:
49
+ rows = []
50
+ for row in ws.iter_rows(values_only=True):
51
+ cells = ["" if v is None else str(v) for v in row]
52
+ if any(c != "" for c in cells):
53
+ rows.append(cells)
54
+ if rows:
55
+ sections.append(f"### {ws.title}\n\n{build_table(rows)}")
56
+ wb.close()
57
+
58
+ if not sections:
59
+ sys.stderr.write("Spreadsheet appears to be empty\n")
60
+ sys.exit(1)
61
+
62
+ sys.stdout.write("\n\n".join(sections))
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -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.
@@ -263,7 +263,7 @@ module Clacky
263
263
  # submitted immediately (the session starts running); display_message
264
264
  # controls the user-facing bubble shown in place of the raw prompt.
265
265
  def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
266
- source: :manual, display_message: nil)
266
+ source: :manual, display_message: nil, hidden: false)
267
267
  error!("server not ready", status: 503) unless @http_server
268
268
 
269
269
  session_id = @http_server.send(
@@ -271,11 +271,14 @@ module Clacky
271
271
  name: name,
272
272
  working_dir: working_dir,
273
273
  profile: profile,
274
- source: source
274
+ source: source,
275
+ hidden: hidden
275
276
  )
276
277
 
277
278
  submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
278
279
 
280
+ @http_server.send(:broadcast_session_update, session_id)
281
+
279
282
  session_id
280
283
  end
281
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],