openclacky 1.3.7 → 1.3.8

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +9 -2
  6. data/lib/clacky/agent_profile.rb +7 -4
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  9. data/lib/clacky/default_extensions/coding/ext.yml +4 -3
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -0
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +24 -5
  13. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  14. data/lib/clacky/default_extensions/general/ext.yml +4 -3
  15. data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
  16. data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
  17. data/lib/clacky/extension/loader.rb +6 -0
  18. data/lib/clacky/extension/verifier.rb +1 -1
  19. data/lib/clacky/server/http_server.rb +129 -5
  20. data/lib/clacky/shell_hook_loader.rb +266 -22
  21. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  22. data/lib/clacky/utils/workspace_rules.rb +2 -2
  23. data/lib/clacky/version.rb +1 -1
  24. data/lib/clacky/web/app.css +230 -46
  25. data/lib/clacky/web/core/ext.js +130 -24
  26. data/lib/clacky/web/features/extensions/view.js +4 -3
  27. data/lib/clacky/web/features/new-session/store.js +13 -0
  28. data/lib/clacky/web/features/new-session/view.js +314 -14
  29. data/lib/clacky/web/features/trash/view.js +4 -5
  30. data/lib/clacky/web/i18n.js +34 -0
  31. data/lib/clacky/web/index.html +43 -11
  32. data/lib/clacky/web/sessions.js +3 -3
  33. data/lib/clacky/web/skills.js +86 -48
  34. data/lib/clacky/web/theme.js +3 -0
  35. data/lib/clacky/web/ws-dispatcher.js +11 -2
  36. metadata +7 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48e94ccf83645b5e18be8d5d33582dc128b06824ab226dd314540658b9cc7e4a
4
- data.tar.gz: 1112ed443505eca99e745838377ebbf4b6f1e96a7c7f83c7bf1cff844b5ddea9
3
+ metadata.gz: 589d52edb2af8ec7ebea8d596a4b342791230009666a8757f4391b95df22d667
4
+ data.tar.gz: 07c80f79e8a9df57b4232a1317b1a6f8b4557cf136289f7ea1b7a9e93caba5aa
5
5
  SHA512:
6
- metadata.gz: b0e4b2664bb7990ec91dd7c5598cfb561ce6f931b1f006a962b6cbf5516ee424b3a6f605fd70c3996ad4d0e0bf1a20dfd626d6466020393cc8cf18832008a68a
7
- data.tar.gz: 2b74eea0a552e5d26545779da2f0a2b1f88812063398ffbf15be913e396d6efe7131d8205bff766939ef252c4ca3b8075ce8e86c855de59774226e3e03bdbfde
6
+ metadata.gz: e474dff0d682c03bbd9756a37e9c64a29d13f51c35b73a76440e62eeac4b92d5ce6251e7357493531ce0669a770607a37a17ceb8715eadf5f849a7b04a6f373a
7
+ data.tar.gz: 8863d9a310224162b3fc0dad4513c933ebafcc74be5d2157d890d7a4f2dd667f66c9f658e61d289c078fdf39c5961bb5c0895317553c0f25b5054142f2c127b9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.8] - 2026-07-07
9
+
10
+ ### Added
11
+ - Shell hook `type:rewrite` protocol with `updatedInput` support — hooks can now rewrite the command before execution
12
+ - Support for `AGENTS.md` workspace rules — agent reads project-level rules from AGENTS.md automatically
13
+ - Avatar support in Web UI
14
+ - Open first tab automatically on session start
15
+
16
+ ### Improved
17
+ - New-session page UI humanized with improved layout and discoverability
18
+ - Session bar slot support for extension customization
19
+ - Extension description metadata in ext-studio
20
+
21
+ ### Fixed
22
+ - Mobile new-session page layout uses fixed positioning correctly
23
+ - Weekly stats period now starts from Monday instead of a rolling 7-day window
24
+ - i18n translations updated and UI refreshes immediately on language change
25
+ - Extension market toolbar layout and back button styling
26
+ - Skeleton screen rendering in ext-studio and extension market
27
+ - Trash skeleton screen restored on loading state
28
+ - Media usage logging now passes session_id correctly for image/video generation
29
+ - Billing writes in tests isolated via `CLACKY_BILLING_DIR` env var
30
+ - Meeting panel no longer overrides aside-panel container class
31
+
8
32
  ## [1.3.7] - 2026-07-06
9
33
 
10
34
  ### Added
@@ -28,7 +28,16 @@ module Clacky
28
28
  @hooks[event].each do |hook|
29
29
  begin
30
30
  hook_result = hook.call(*args)
31
- result.merge!(hook_result) if hook_result.is_a?(Hash)
31
+ next unless hook_result.is_a?(Hash)
32
+ # First deny wins and stops the chain: a weaker later verdict must
33
+ # never clobber a stronger earlier one, and the first deny's reason
34
+ # is the one that reaches the agent. Rewrite hooks mutate `call` in
35
+ # place (chained rewrite), so for non-deny results there's nothing to
36
+ # merge — we just keep going.
37
+ if hook_result[:action] == :deny
38
+ result = hook_result
39
+ break
40
+ end
32
41
  rescue StandardError => e
33
42
  # Log error but don't fail
34
43
  Clacky::Logger.error("Hook error", event: event, error: e)
@@ -8,7 +8,7 @@ module Clacky
8
8
  # Builds system prompt by composing layers:
9
9
  # 1. Agent-specific system_prompt.md (role & responsibilities)
10
10
  # 2. base_prompt.md (universal rules: todo manager, tool usage, etc.)
11
- # 3. Project rules (.clackyrules / .cursorrules / CLAUDE.md)
11
+ # 3. Project rules (.clackyrules / AGENTS.md / .cursorrules / CLAUDE.md)
12
12
  # 4. SOUL.md (agent personality — user override or built-in default)
13
13
  # 5. USER.md (user profile — user override or built-in default)
14
14
  # 6. Skills context (available skills list)
data/lib/clacky/agent.rb CHANGED
@@ -145,8 +145,15 @@ module Clacky
145
145
  # Register built-in tools
146
146
  register_builtin_tools
147
147
 
148
- # Load declarative shell hooks from ~/.clacky/hooks.yml
149
- ShellHookLoader.load_into(@hooks)
148
+ # Load declarative shell hooks from ~/.clacky/hooks.yml. Entries with
149
+ # `type: rewrite` use the rich JSON protocol (updatedInput rewrite);
150
+ # entries without `type` use the simple exit-code protocol.
151
+ ShellHookLoader.load_into(
152
+ @hooks,
153
+ session_id_fn: -> { @session_id },
154
+ cwd_fn: -> { @working_dir },
155
+ permission_mode_fn: -> { @config.permission_mode.to_s }
156
+ )
150
157
 
151
158
  # Copy ext.yml-contributed hook callbacks (contributes.hooks) onto this
152
159
  # agent's hook manager. The callbacks were registered process-wide at
@@ -53,11 +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
- # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author: }
56
+ # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }
57
57
  def self.all
58
58
  out = {}
59
59
 
60
- add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author|
60
+ add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
61
61
  next if id.nil? || id.empty?
62
62
  out[id] = {
63
63
  id: id,
@@ -69,6 +69,7 @@ module Clacky
69
69
  order: order,
70
70
  layer: layer,
71
71
  author: author,
72
+ avatar: avatar,
72
73
  }
73
74
  end
74
75
 
@@ -77,11 +78,12 @@ module Clacky
77
78
  spec = unit.spec || {}
78
79
  title = spec["title"].to_s
79
80
  title = unit.id if title.empty?
81
+ avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
80
82
  add.call(
81
83
  unit.id, title, spec["title_zh"].to_s,
82
84
  spec["description"].to_s, spec["description_zh"].to_s,
83
85
  "extension", spec["order"], unit.layer.to_s,
84
- spec["author"].to_s
86
+ spec["author"].to_s, avatar
85
87
  )
86
88
  end
87
89
 
@@ -91,11 +93,12 @@ module Clacky
91
93
  next if id.start_with?("_")
92
94
  next unless File.file?(File.join(path, "profile.yml"))
93
95
  meta = read_profile_yml(File.join(path, "profile.yml"))
96
+ user_avatar = File.file?(File.join(path, "avatar.png")) ? "/agent_avatar/#{id}" : nil
94
97
  add.call(
95
98
  id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
96
99
  meta["description"].to_s, meta["description_zh"].to_s,
97
100
  "user", meta["order"], "user",
98
- meta["author"].to_s.empty? ? "You" : meta["author"].to_s
101
+ meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar
99
102
  )
100
103
  end
101
104
 
@@ -13,7 +13,7 @@ module Clacky
13
13
  BILLING_DIR = File.join(Dir.home, ".clacky", "billing")
14
14
 
15
15
  def initialize(billing_dir: nil)
16
- @billing_dir = billing_dir || BILLING_DIR
16
+ @billing_dir = billing_dir || ENV["CLACKY_BILLING_DIR"] || BILLING_DIR
17
17
  ensure_billing_dir
18
18
  end
19
19
 
@@ -339,7 +339,8 @@ module Clacky
339
339
  when :day
340
340
  Time.new(now.year, now.month, now.day)
341
341
  when :week
342
- now - (7 * 24 * 60 * 60)
342
+ days_since_monday = now.wday == 0 ? 6 : now.wday - 1
343
+ Time.new(now.year, now.month, now.day) - (days_since_monday * 24 * 60 * 60)
343
344
  when :month
344
345
  Time.new(now.year, now.month, 1)
345
346
  when :year
@@ -10,9 +10,10 @@ contributes:
10
10
  agents:
11
11
  - id: coding
12
12
  title: Coding
13
- title_zh: 编程
14
- description: AI coding assistant and technical co-founder
15
- description_zh: AI 编程助手,你的技术合伙人
13
+ title_zh: 编程开发
14
+ description: Claude Code-grade coding capability, your technical co-founder
15
+ description_zh: 对齐 Claude Code 的编程能力,你的技术合伙人
16
16
  order: 2
17
17
  prompt: agents/coding/system_prompt.md
18
+ avatar: agents/coding/avatar.png
18
19
  panels: [git/git, time_machine/time_machine]
@@ -24,6 +24,7 @@ contributes:
24
24
  description_zh: 帮你开发、调试、发布 OpenClacky 扩展的 AI 专家
25
25
  order: 5
26
26
  prompt: agents/ext-developer/system_prompt.md
27
+ avatar: agents/ext-developer/avatar.png
27
28
  panels: [studio]
28
29
  skills: [ext-scaffold, ext-debug, ext-publish]
29
30
  skills:
@@ -787,6 +787,23 @@
787
787
  };
788
788
  }
789
789
 
790
+ function _skeletonHtml() {
791
+ return Array.from({ length: 3 }).map(() => `
792
+ <div class="studio-skill-card" style="pointer-events:none">
793
+ <div class="studio-skill-head">
794
+ <span class="skel" style="display:inline-block;height:1rem;width:40%"></span>
795
+ <span class="skel" style="display:inline-block;height:1.2rem;width:3.5rem;border-radius:4px;"></span>
796
+ </div>
797
+ <div class="studio-skill-meta" style="margin-top:6px">
798
+ <span class="skel" style="display:inline-block;height:0.85rem;width:20%"></span>
799
+ </div>
800
+ <div class="studio-actions">
801
+ <span class="skel" style="display:inline-block;height:1.75rem;width:5rem;border-radius:6px;"></span>
802
+ <span class="skel" style="display:inline-block;height:1.75rem;width:4rem;border-radius:6px;"></span>
803
+ </div>
804
+ </div>`).join("");
805
+ }
806
+
790
807
  // ── Extensions tab (full-page): cloud + local extension cards ──────────────
791
808
  // Reuses backend endpoints GET /published (cloud), the shared `store` (local,
792
809
  // GET /extensions), POST /publish|/pack|/unpublish, and POST /develop to open
@@ -926,7 +943,7 @@
926
943
  async attach(root) {
927
944
  container = el("div", { class: "studio-panel" });
928
945
  root.appendChild(container);
929
- container.appendChild(el("p", { class: "studio-hint", text: t("extlist.loading") }));
946
+ container.innerHTML = _skeletonHtml();
930
947
  try { await reload(); rebuild(); }
931
948
  catch (e) { container.innerHTML = ""; container.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) })); }
932
949
  },
@@ -1090,7 +1107,7 @@
1090
1107
  async attach(root) {
1091
1108
  container = el("div", { class: "studio-panel" });
1092
1109
  root.appendChild(container);
1093
- container.appendChild(el("p", { class: "studio-hint", text: t("skills.loading") }));
1110
+ container.innerHTML = _skeletonHtml();
1094
1111
  try { await reload(); rebuild(); }
1095
1112
  catch (e) { container.innerHTML = ""; container.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) })); }
1096
1113
  },
@@ -1146,7 +1163,7 @@
1146
1163
  createSkillsPanel().attach(root);
1147
1164
  }
1148
1165
 
1149
- function navRow(label, onClick) {
1166
+ function navRow(labelKey, onClick) {
1150
1167
  const item = el("div", { class: "task-item task-item-summary" });
1151
1168
  item.innerHTML =
1152
1169
  '<div class="task-row">' +
@@ -1159,7 +1176,9 @@
1159
1176
  '</svg>' +
1160
1177
  '<div class="task-info"><span class="task-name"></span></div>' +
1161
1178
  '</div>';
1162
- item.querySelector(".task-name").textContent = label;
1179
+ const nameEl = item.querySelector(".task-name");
1180
+ nameEl.textContent = t(labelKey);
1181
+ document.addEventListener("langchange", () => { nameEl.textContent = t(labelKey); });
1163
1182
  item.addEventListener("click", onClick);
1164
1183
  return item;
1165
1184
  }
@@ -1170,7 +1189,7 @@
1170
1189
  });
1171
1190
 
1172
1191
  Clacky.ext.ui.mount("sidebar.nav.bottom", function () {
1173
- return navRow(t("nav.entry"), function () { Clacky.ext.ui.openWorkspace(WS_ID); });
1192
+ return navRow("nav.entry", function () { Clacky.ext.ui.openWorkspace(WS_ID); });
1174
1193
  }, { workspace: WS_ID });
1175
1194
 
1176
1195
  // Extension-developer session tools: debug + publish, as aside tabs. These
@@ -10,9 +10,10 @@ contributes:
10
10
  agents:
11
11
  - id: general
12
12
  title: General
13
- title_zh: 通用
14
- description: A versatile digital employee living on your computer
15
- description_zh: 驻留在你电脑里的多面手数字员工
13
+ title_zh: 日常工作
14
+ description: Handles your everyday work
15
+ description_zh: 帮你打理日常工作,查资料、写东西、整理归纳都行
16
16
  order: 1
17
17
  prompt: agents/general/system_prompt.md
18
+ avatar: agents/general/avatar.png
18
19
  panels: [meeting/meeting]
@@ -187,12 +187,12 @@
187
187
  );
188
188
  }
189
189
 
190
- Clacky.ext.ui.mount("session.aside", (ctx) => {
191
- if (!ctx || !ctx.sessionId) return null;
190
+ Clacky.ext.ui.mount("session.aside", (container, ctx) => {
191
+ if (!ctx || !ctx.sessionId) return;
192
192
  const body = el("div", { class: "changes-panel" });
193
193
  const root = el("div", { class: "changes-root", "data-panel": "changes" }, body);
194
+ container.appendChild(root);
194
195
  refresh(ctx.sessionId, root, body, ctx);
195
- return root;
196
196
  }, {
197
197
  order: 10,
198
198
  tab: { id: "changes", label: () => t("changes.tab") },
@@ -893,7 +893,6 @@
893
893
  // Every tab activation gets a fresh empty container; wire it into state
894
894
  // and paint the current state onto it.
895
895
  attach(container) {
896
- container.className = "meeting-container";
897
896
  state.container = container;
898
897
  renderUI(container);
899
898
  },
@@ -923,7 +922,7 @@
923
922
  // Inject minimal styles
924
923
  const style = document.createElement("style");
925
924
  style.textContent = `
926
- .meeting-container { padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
925
+ .meeting-panel { padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
927
926
  .meeting-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; transition: background .15s, border-color .15s; }
928
927
  .meeting-btn-start { background: var(--color-button-primary); color: var(--color-button-primary-text); }
929
928
  .meeting-btn-start:hover { background: var(--color-button-primary-hover); }
@@ -391,6 +391,10 @@ module Clacky
391
391
  return nil
392
392
  end
393
393
 
394
+ avatar_rel = spec["avatar"].to_s
395
+ avatar_abs = avatar_rel.empty? ? "" : File.join(container[:dir], avatar_rel)
396
+ avatar_abs = "" unless !avatar_abs.empty? && File.file?(avatar_abs)
397
+
394
398
  Unit.new(kind: :agent, id: spec["id"].to_s, ext_id: ext_id,
395
399
  layer: container[:layer], origin: container[:origin],
396
400
  dir: container[:dir],
@@ -399,6 +403,8 @@ module Clacky
399
403
  "title_zh" => spec["title_zh"].to_s,
400
404
  "prompt" => spec["prompt"],
401
405
  "prompt_abs" => prompt_abs,
406
+ "avatar" => avatar_rel,
407
+ "avatar_abs" => avatar_abs,
402
408
  "description" => spec["description"].to_s,
403
409
  "description_zh" => spec["description_zh"].to_s,
404
410
  "order" => spec["order"],
@@ -22,7 +22,7 @@ module Clacky
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach].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].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar].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
@@ -369,6 +369,8 @@ module Clacky
369
369
  res.body = html
370
370
  elsif req.path.start_with?("/agent_ui/")
371
371
  self.send(:serve_agent_ui, req, res)
372
+ elsif req.path.start_with?("/agent_avatar/")
373
+ self.send(:serve_agent_avatar, req, res)
372
374
  elsif req.path.start_with?("/ext_ui/")
373
375
  self.send(:serve_ext_ui, req, res)
374
376
  else
@@ -623,6 +625,9 @@ module Clacky
623
625
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
624
626
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
625
627
  api_session_skills(session_id, res)
628
+ elsif method == "GET" && path.match?(%r{^/api/agents/[^/]+/skills$})
629
+ agent_id = path[%r{^/api/agents/([^/]+)/skills$}, 1]
630
+ api_agent_skills(agent_id, res)
626
631
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
627
632
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
628
633
  action = path[%r{/git/([a-z]+)$}, 1]
@@ -1061,6 +1066,7 @@ module Clacky
1061
1066
  private def webui_ext_script_tags
1062
1067
  [
1063
1068
  panel_agents_script,
1069
+ ext_contributions_script,
1064
1070
  agent_webui_script_tags,
1065
1071
  container_ext_script_tags,
1066
1072
  ].reject(&:empty?).join("\n")
@@ -1097,6 +1103,36 @@ module Clacky
1097
1103
  "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1098
1104
  end
1099
1105
 
1106
+ # Inline script mapping agent id => the panels & skills its extension
1107
+ # contributes (straight from ext.yml). Built-in extensions are excluded so
1108
+ # the new-session page only advertises what a third-party agent adds.
1109
+ private def ext_contributions_script
1110
+ map = ext_contributions_map
1111
+ return "" if map.empty?
1112
+
1113
+ "<script>Clacky.ext.registerAgentContributions(#{map.to_json})</script>"
1114
+ end
1115
+
1116
+ private def ext_contributions_map
1117
+ result = Clacky::ExtensionLoader.load_all
1118
+ by_ext = Hash.new { |h, k| h[k] = { "panels" => [], "skills" => [] } }
1119
+ label = lambda do |unit|
1120
+ { "id" => unit.id, "title" => unit.spec["title"] || unit.id, "title_zh" => unit.spec["title_zh"] }
1121
+ end
1122
+
1123
+ result.panels.each { |u| by_ext[u.ext_id]["panels"] << label.call(u) unless u.layer == :builtin }
1124
+ result.skills.each { |u| by_ext[u.ext_id]["skills"] << label.call(u) unless u.layer == :builtin }
1125
+
1126
+ out = {}
1127
+ result.agents.each do |u|
1128
+ next if u.layer == :builtin
1129
+ contrib = by_ext[u.ext_id]
1130
+ next if contrib["panels"].empty? && contrib["skills"].empty?
1131
+ out[u.id] = contrib
1132
+ end
1133
+ out
1134
+ end
1135
+
1100
1136
  # panel id => list of agent names allowed to see it. Two sources merge:
1101
1137
  # 1. `agent.panels: [id]` — agent-side explicit reference (opt-in mount)
1102
1138
  # 2. `panel.attach: [...]` — panel-author's default suggestion
@@ -1283,6 +1319,42 @@ module Clacky
1283
1319
  serve_static_under(File.join(dir, "webui"), rel, res)
1284
1320
  end
1285
1321
 
1322
+ # GET /agent_avatar/<id> — serve an agent's avatar image (extension
1323
+ # `avatar` file, or a user agent's avatar.png). Read-only, images only.
1324
+ private def serve_agent_avatar(req, res)
1325
+ id = req.path.delete_prefix("/agent_avatar/").split("/").first.to_s
1326
+ return (res.status = 404; res.body = "not found") if id.empty?
1327
+
1328
+ abs = agent_avatar_path(id)
1329
+ unless abs && File.file?(abs)
1330
+ res.status = 404
1331
+ res.body = "not found"
1332
+ return
1333
+ end
1334
+
1335
+ ctype = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
1336
+ ".webp" => "image/webp", ".svg" => "image/svg+xml", ".gif" => "image/gif" }[File.extname(abs).downcase]
1337
+ unless ctype
1338
+ res.status = 415
1339
+ res.body = "unsupported media type"
1340
+ return
1341
+ end
1342
+
1343
+ res.status = 200
1344
+ res["Content-Type"] = ctype
1345
+ res["Cache-Control"] = "public, max-age=86400"
1346
+ res.body = File.binread(abs)
1347
+ end
1348
+
1349
+ private def agent_avatar_path(id)
1350
+ unit = Clacky::ExtensionLoader.last_result&.agents&.find { |u| u.id == id }
1351
+ avatar_abs = unit&.spec&.[]("avatar_abs").to_s
1352
+ return avatar_abs unless avatar_abs.empty?
1353
+
1354
+ user_png = File.expand_path("~/.clacky/agents/#{id}/avatar.png")
1355
+ File.file?(user_png) ? user_png : nil
1356
+ end
1357
+
1286
1358
  # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1287
1359
  # strict path containment so a crafted rel cannot escape `root`.
1288
1360
  private def serve_static_under(root, rel, res)
@@ -1419,6 +1491,9 @@ module Clacky
1419
1491
  output_dir = body["output_dir"].to_s
1420
1492
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1421
1493
 
1494
+ session_id = body["session_id"].to_s
1495
+ session_id = nil if session_id.empty?
1496
+
1422
1497
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1423
1498
  prompt: prompt,
1424
1499
  aspect_ratio: aspect_ratio,
@@ -1427,7 +1502,7 @@ module Clacky
1427
1502
  images: body["images"]
1428
1503
  )
1429
1504
  if result["success"]
1430
- log_media_usage(result, prompt: prompt)
1505
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1431
1506
  end
1432
1507
  status = result["success"] ? 200 : 422
1433
1508
  json_response(res, status, result)
@@ -1451,6 +1526,9 @@ module Clacky
1451
1526
  output_dir = body["output_dir"].to_s
1452
1527
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1453
1528
 
1529
+ session_id = body["session_id"].to_s
1530
+ session_id = nil if session_id.empty?
1531
+
1454
1532
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1455
1533
  prompt: prompt,
1456
1534
  aspect_ratio: aspect_ratio,
@@ -1459,7 +1537,7 @@ module Clacky
1459
1537
  image: image
1460
1538
  )
1461
1539
  if result["success"]
1462
- log_media_usage(result, prompt: prompt)
1540
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1463
1541
  end
1464
1542
  status = result["success"] ? 200 : 422
1465
1543
  json_response(res, status, result)
@@ -1480,13 +1558,16 @@ module Clacky
1480
1558
  output_dir = body["output_dir"].to_s
1481
1559
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1482
1560
 
1561
+ session_id = body["session_id"].to_s
1562
+ session_id = nil if session_id.empty?
1563
+
1483
1564
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1484
1565
  input: input,
1485
1566
  voice: voice,
1486
1567
  output_dir: output_dir
1487
1568
  )
1488
1569
  if result["success"]
1489
- log_media_usage(result, prompt: input)
1570
+ log_media_usage(result, prompt: input, session_id: session_id)
1490
1571
  end
1491
1572
  status = result["success"] ? 200 : 422
1492
1573
  json_response(res, status, result)
@@ -1547,7 +1628,7 @@ module Clacky
1547
1628
  json_response(res, 500, { error: e.message })
1548
1629
  end
1549
1630
 
1550
- private def log_media_usage(result, prompt:)
1631
+ private def log_media_usage(result, prompt:, session_id: nil)
1551
1632
  usage = result["usage"]
1552
1633
  cost = result["cost_usd"]
1553
1634
  return if usage.nil? && cost.nil?
@@ -1568,6 +1649,20 @@ module Clacky
1568
1649
  else "image"
1569
1650
  end
1570
1651
  Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
1652
+
1653
+ require_relative "../billing/billing_store"
1654
+ require_relative "../billing/billing_record"
1655
+ record = Clacky::Billing::BillingRecord.new(
1656
+ session_id: session_id,
1657
+ model: result["model"].to_s,
1658
+ prompt_tokens: usage.is_a?(Hash) ? usage["prompt_tokens"].to_i : 0,
1659
+ completion_tokens: usage.is_a?(Hash) ? usage["completion_tokens"].to_i : 0,
1660
+ cache_read_tokens: usage.is_a?(Hash) ? usage["cache_read_tokens"].to_i : 0,
1661
+ cache_write_tokens: usage.is_a?(Hash) ? usage["cache_write_tokens"].to_i : 0,
1662
+ cost_usd: cost.to_f,
1663
+ cost_source: :api
1664
+ )
1665
+ Clacky::Billing::BillingStore.new.append(record)
1571
1666
  end
1572
1667
 
1573
1668
  # GET /api/media/types
@@ -3917,7 +4012,36 @@ module Clacky
3917
4012
  json_response(res, 200, { skills: skill_data })
3918
4013
  end
3919
4014
 
3920
- # GET /api/sessions/:id/git/<action>read-only git info for the session's
4015
+ # GET /api/agents/:id/skillslike api_session_skills but keyed on an
4016
+ # agent id rather than a live session. Used by the New Session page so the
4017
+ # first-message composer can offer slash-command autocomplete before a
4018
+ # session exists.
4019
+ def api_agent_skills(agent_id, res)
4020
+ profile = begin
4021
+ Clacky::AgentProfile.load(agent_id)
4022
+ rescue StandardError
4023
+ nil
4024
+ end
4025
+
4026
+ @skill_loader.load_all
4027
+ skills = @skill_loader.user_invocable_skills
4028
+ skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
4029
+
4030
+ loaded_from = @skill_loader.loaded_from
4031
+ skill_data = skills.map do |skill|
4032
+ {
4033
+ name: skill.identifier,
4034
+ name_zh: skill.name_zh,
4035
+ description: skill.description || skill.context_description,
4036
+ description_zh: skill.description_zh,
4037
+ encrypted: skill.encrypted?,
4038
+ source_type: loaded_from[skill.identifier],
4039
+ always_show: skill.always_show
4040
+ }
4041
+ end
4042
+
4043
+ json_response(res, 200, { skills: skill_data })
4044
+ end
3921
4045
  # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3922
4046
  # log accepts ?limit=.
3923
4047
  def api_session_git(session_id, action, req, res)