openclacky 1.2.7 → 1.2.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent.rb +3 -0
  4. data/lib/clacky/agent_config.rb +91 -7
  5. data/lib/clacky/billing/billing_store.rb +107 -3
  6. data/lib/clacky/cli.rb +105 -0
  7. data/lib/clacky/client.rb +38 -5
  8. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  9. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  10. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  11. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/media/base.rb +68 -0
  14. data/lib/clacky/media/gemini.rb +36 -0
  15. data/lib/clacky/media/generator.rb +78 -0
  16. data/lib/clacky/media/openai_compat.rb +168 -0
  17. data/lib/clacky/patch_loader.rb +282 -0
  18. data/lib/clacky/providers.rb +82 -0
  19. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  21. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  22. data/lib/clacky/server/channel.rb +5 -0
  23. data/lib/clacky/server/http_server.rb +236 -25
  24. data/lib/clacky/server/scheduler.rb +1 -4
  25. data/lib/clacky/shell_hook_loader.rb +181 -0
  26. data/lib/clacky/telemetry.rb +11 -5
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +326 -24
  29. data/lib/clacky/web/billing.js +117 -22
  30. data/lib/clacky/web/i18n.js +84 -6
  31. data/lib/clacky/web/index.html +14 -2
  32. data/lib/clacky/web/model-tester.js +58 -0
  33. data/lib/clacky/web/onboard.js +17 -30
  34. data/lib/clacky/web/settings.js +322 -97
  35. data/lib/clacky.rb +9 -0
  36. data/scripts/build/lib/network.sh +61 -30
  37. data/scripts/install.sh +61 -30
  38. data/scripts/install_browser.sh +61 -30
  39. data/scripts/install_full.sh +61 -30
  40. data/scripts/install_rails_deps.sh +61 -30
  41. data/scripts/install_system_deps.sh +61 -30
  42. metadata +12 -3
  43. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8057fdabcb077c8ca378fcbbc0efa442abc6b9664464d2d8d1b737e0206d2ed9
4
- data.tar.gz: 657f282a20664ef793d7ff663e963739f5fbdb478b8e174df9c5e459ec09231b
3
+ metadata.gz: b2060d694267d0947681785d2e2ffe730d0b241a9ac2ec68e218eb037478bf27
4
+ data.tar.gz: 0b3e301010e16752da0bd64a9603b06010c2a23c5bd81884c7719d74f8f1bd67
5
5
  SHA512:
6
- metadata.gz: fad3e045271032a1150745f1d8531aeed24ea376efdcd99346aeaeec4eb5f1edec187e9dbe38ee8d19e5a9cf599bfb7e5431ad55e5993f1dd243bb4ebf5faa2d
7
- data.tar.gz: 95b1a7ec783b459f5c70b99d3535f5a0054d4a8c72d855d9c98b9771cde9d59cb33dcf609be844fade932e1487c82c525cf4354438c5ad4b271494a4166dc729
6
+ metadata.gz: 386e2359d904b9a6bc81e56429cd585aaef59b7174f5dbc93e51cdd2bf97df703fd8145910759f50427632fcc90a307ed3ec6b19e48f0230d7e0c39987d3e7a8
7
+ data.tar.gz: 15413b83259ef7a39acac101597149cbf2144473da691d885f14d3b271076399da14c924db19201a1b99d8bf264542b56f3619b6cb6b903dfdac462e46f15a97
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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.2.9] - 2026-06-01
9
+
10
+ ### Added
11
+ - Image generation support via model tool calls
12
+ - Startup telemetry now reports launch source for better usage analytics
13
+
14
+ ### Improved
15
+ - Feishu channel setup simplified with Agent App flow — fewer manual steps and no redirect URL config needed
16
+
17
+ ### Fixed
18
+ - Network region detection hardened with CDN fallback to handle edge cases and improve reliability
19
+
20
+ ## [1.2.8] - 2026-06-01
21
+
22
+ ### Added
23
+ - Extensibility framework: patching, shell hooks, and channel user adapter plugins — customize Clacky behavior without modifying core code
24
+
25
+ ### Improved
26
+ - Billing session list now shows session names, merged deleted sessions, and standardized token breakdown with cache hit/miss color coding
27
+
28
+ ### Fixed
29
+ - Streaming LLM responses automatically retry when connection drops instead of silently truncating
30
+
31
+ ### More
32
+ - Extend openclacky skill with additional extension points
33
+
8
34
  ## [1.2.7] - 2026-06-01
9
35
 
10
36
  ### Added
data/lib/clacky/agent.rb CHANGED
@@ -137,6 +137,9 @@ module Clacky
137
137
  # Register built-in tools
138
138
  register_builtin_tools
139
139
 
140
+ # Load declarative shell hooks from ~/.clacky/hooks.yml
141
+ ShellHookLoader.load_into(@hooks)
142
+
140
143
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
141
144
  Utils::ParserManager.setup!
142
145
 
@@ -318,7 +318,9 @@ module Clacky
318
318
  end
319
319
  end
320
320
 
321
- new(**constructor_args)
321
+ instance = new(**constructor_args)
322
+ instance.derive_media_models!
323
+ instance
322
324
  end
323
325
 
324
326
  # Auto-injection of provider-preset lite models into @models has been
@@ -585,12 +587,94 @@ module Clacky
585
587
  }.compact
586
588
  end
587
589
 
588
- # Find model by type (default or lite)
589
- # Returns the model hash or nil if not found
590
+ # Find model by type (default or lite or media kind)
591
+ # Returns the model hash or nil if not found.
592
+ # For media kinds (image/video/audio): explicit user-configured (custom)
593
+ # entries win; otherwise an auto-derived virtual entry is returned
594
+ # based on the default model's provider — mirroring how lite is
595
+ # virtually derived via #lite_model_config_for_current.
590
596
  def find_model_by_type(type)
597
+ kind = type.to_s
598
+ if Clacky::Providers::MEDIA_KINDS.include?(kind)
599
+ custom = @models.find { |m| m["type"] == kind }
600
+ return custom if custom
601
+ return derive_media_model(kind)
602
+ end
591
603
  @models.find { |m| m["type"] == type }
592
604
  end
593
605
 
606
+ private def derive_media_model(kind)
607
+ default = find_model_by_type("default")
608
+ return nil unless default
609
+
610
+ provider_id = Clacky::Providers.resolve_provider(
611
+ base_url: default["base_url"],
612
+ api_key: default["api_key"]
613
+ )
614
+ return nil unless provider_id
615
+
616
+ model_name = Clacky::Providers.default_media_model(provider_id, kind)
617
+ return nil if model_name.nil? || model_name.to_s.empty?
618
+
619
+ {
620
+ "model" => model_name,
621
+ "base_url" => default["base_url"],
622
+ "api_key" => default["api_key"],
623
+ "type" => kind,
624
+ "auto_injected" => true
625
+ }
626
+ end
627
+
628
+ # Kept as a no-op for backward compatibility. Media auto entries are
629
+ # now derived virtually on read; nothing is materialized into @models.
630
+ def derive_media_models!
631
+ @models.reject! { |m| m["auto_injected"] && Clacky::Providers::MEDIA_KINDS.include?(m["type"].to_s) }
632
+ end
633
+
634
+ # Returns the configured/derived media model entry for `kind`, plus a
635
+ # hint about its source. UI uses this to render the tri-state control.
636
+ # @param kind [String] one of "image" / "video" / "audio"
637
+ # @return [Hash{String=>Object}] keys:
638
+ # "configured" [Boolean] — anything available?
639
+ # "source" [String] — "off" | "auto" | "custom"
640
+ # "model" [String, nil]
641
+ # "base_url" [String, nil]
642
+ # "provider" [String, nil] — provider id
643
+ # "available" [Array<String>] — auto-source candidates from preset
644
+ def media_state(kind)
645
+ kind = kind.to_s
646
+ custom = @models.find { |m| m["type"] == kind }
647
+ auto = custom ? nil : derive_media_model(kind)
648
+ entry = custom || auto
649
+
650
+ provider_id = if entry
651
+ Clacky::Providers.resolve_provider(
652
+ base_url: entry["base_url"],
653
+ api_key: entry["api_key"]
654
+ )
655
+ end
656
+
657
+ available_provider_id = if custom
658
+ provider_id
659
+ else
660
+ default = find_model_by_type("default")
661
+ default && Clacky::Providers.resolve_provider(
662
+ base_url: default["base_url"],
663
+ api_key: default["api_key"]
664
+ )
665
+ end
666
+ available = available_provider_id ? Clacky::Providers.media_models(available_provider_id, kind) : []
667
+
668
+ {
669
+ "configured" => !entry.nil?,
670
+ "source" => custom ? "custom" : (auto ? "auto" : "off"),
671
+ "model" => entry && entry["model"],
672
+ "base_url" => entry && entry["base_url"],
673
+ "provider" => provider_id,
674
+ "available" => available
675
+ }
676
+ end
677
+
594
678
  # Find model by composite key (model name + base_url).
595
679
  # Used when restoring a session to match its original model without relying
596
680
  # on the runtime-only id (which changes on every process restart).
@@ -896,14 +980,14 @@ module Clacky
896
980
  Clacky::Providers.supports?(provider_id, capability, model_name: m["model"])
897
981
  end
898
982
 
899
- # Set a model's type (default or lite)
900
- # Ensures only one model has each type
983
+ # Set a model's type (default, lite, image, video, or audio).
984
+ # At most one model carries each type at a time.
901
985
  # @param index [Integer] the model index
902
- # @param type [String, nil] "default", "lite", or nil to remove type
986
+ # @param type [String, nil] type tag, or nil to clear
903
987
  # Returns true if successful
904
988
  def set_model_type(index, type)
905
989
  return false if index < 0 || index >= @models.length
906
- return false unless ["default", "lite", nil].include?(type)
990
+ return false unless ["default", "lite", "image", "video", "audio", nil].include?(type)
907
991
 
908
992
  if type
909
993
  # Remove type from any other model that has it
@@ -4,7 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "securerandom"
6
6
  require_relative "billing_record"
7
-
7
+ require_relative "../session_manager"
8
8
  module Clacky
9
9
  module Billing
10
10
  # Persistent storage for billing records using JSONL files
@@ -115,8 +115,112 @@ module Clacky
115
115
  }
116
116
  end
117
117
 
118
- # Get daily cost breakdown for the last N days
119
- # @param days [Integer] Number of days to include
118
+ # Get session-level summary statistics
119
+ # @param period [Symbol] :day, :week, :month, :year, or :all
120
+ # @param model [String, nil] Filter by model name
121
+ # @param limit [Integer] Maximum number of sessions to return
122
+ # @return [Array<Hash>] Session summaries sorted by cost descending
123
+ def session_summary(period: :month, model: nil, limit: 50)
124
+ from_time = period_start(period)
125
+ records = query(from: from_time, model: model)
126
+
127
+ # Load session names from session manager
128
+ session_names = load_session_names
129
+
130
+ # Group by session_id
131
+ by_session = records.group_by { |r| r.session_id || "unknown" }
132
+
133
+ active_sessions = []
134
+ deleted_records = []
135
+
136
+ by_session.each do |session_id, rs|
137
+ total_cost = rs.sum { |r| r.cost_usd || 0 }
138
+ total_prompt = rs.sum { |r| r.prompt_tokens || 0 }
139
+ total_completion = rs.sum { |r| r.completion_tokens || 0 }
140
+ total_cache_read = rs.sum { |r| r.cache_read_tokens || 0 }
141
+ total_cache_write = rs.sum { |r| r.cache_write_tokens || 0 }
142
+ first_record = rs.min_by { |r| r.timestamp }
143
+ last_record = rs.max_by { |r| r.timestamp }
144
+
145
+ entry = {
146
+ session_id: session_id,
147
+ session_name: session_names[session_id],
148
+ total_cost: total_cost.round(6),
149
+ total_tokens: total_prompt + total_completion,
150
+ prompt_tokens: total_prompt,
151
+ completion_tokens: total_completion,
152
+ cache_read_tokens: total_cache_read,
153
+ cache_write_tokens: total_cache_write,
154
+ requests: rs.size,
155
+ first_request: first_record&.timestamp&.iso8601,
156
+ last_request: last_record&.timestamp&.iso8601,
157
+ models: rs.map(&:model).uniq
158
+ }
159
+
160
+ if session_names[session_id]
161
+ active_sessions << entry
162
+ else
163
+ deleted_records << entry
164
+ end
165
+ end
166
+
167
+ # Merge all deleted sessions into a single row
168
+ if deleted_records.any?
169
+ merged = {
170
+ session_id: "_deleted_",
171
+ session_name: nil,
172
+ is_deleted: true,
173
+ total_cost: deleted_records.sum { |r| r[:total_cost] }.round(6),
174
+ total_tokens: deleted_records.sum { |r| r[:total_tokens] },
175
+ prompt_tokens: deleted_records.sum { |r| r[:prompt_tokens] },
176
+ completion_tokens: deleted_records.sum { |r| r[:completion_tokens] },
177
+ cache_read_tokens: deleted_records.sum { |r| r[:cache_read_tokens] },
178
+ cache_write_tokens: deleted_records.sum { |r| r[:cache_write_tokens] },
179
+ requests: deleted_records.sum { |r| r[:requests] },
180
+ first_request: deleted_records.map { |r| r[:first_request] }.compact.min,
181
+ last_request: deleted_records.map { |r| r[:last_request] }.compact.max,
182
+ models: deleted_records.flat_map { |r| r[:models] }.uniq
183
+ }
184
+ active_sessions << merged
185
+ end
186
+
187
+ # Sort by total cost descending
188
+ active_sessions.sort_by! { |s| -s[:total_cost] }
189
+
190
+ # Apply limit
191
+ limit ? active_sessions.first(limit) : active_sessions
192
+ end
193
+
194
+ # Load session names from session manager (including trashed sessions)
195
+ # Returns a hash mapping session_id to session name
196
+ def load_session_names
197
+ names = {}
198
+ begin
199
+ # Load from active sessions
200
+ manager = Clacky::SessionManager.new
201
+ manager.all_sessions.each do |session|
202
+ id = session[:session_id]
203
+ name = session[:name]
204
+ names[id] = name if id && name && !name.to_s.empty?
205
+ end
206
+
207
+ # Also load from trashed sessions
208
+ trash_dir = File.join(Dir.home, ".clacky", "trash", "sessions-trash")
209
+ if Dir.exist?(trash_dir)
210
+ Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
211
+ session = JSON.parse(File.read(filepath), symbolize_names: true) rescue next
212
+ id = session[:session_id]
213
+ name = session[:name]
214
+ names[id] = name if id && name && !name.to_s.empty?
215
+ end
216
+ end
217
+ rescue => e
218
+ # Silently fail if session manager is not available
219
+ end
220
+ names
221
+ end
222
+
223
+ # Get daily cost breakdown for the last N days # @param days [Integer] Number of days to include
120
224
  # @param model [String, nil] Filter by model name
121
225
  # @return [Array<Hash>] Daily summaries with date and cost
122
226
  def daily_breakdown(days: 30, model: nil)
data/lib/clacky/cli.rb CHANGED
@@ -942,6 +942,111 @@ module Clacky
942
942
  end
943
943
 
944
944
  # ── billing command ────────────────────────────────────────────────────────
945
+ desc "patch_new ID TARGET", "Scaffold a runtime patch for a method (TARGET like Clacky::Tools::WebSearch#execute)"
946
+ long_desc <<-LONGDESC
947
+ Generate a method-override patch under ~/.clacky/patches/ID/. The current
948
+ method fingerprint is computed automatically and stored in meta.yml; you
949
+ only edit the method body in patch.rb. If a future gem version changes the
950
+ targeted method, the fingerprint will no longer match and the patch is
951
+ auto-disabled on next start (rather than applied and risking breakage).
952
+
953
+ Examples:
954
+ $ clacky patch_new fix-search Clacky::Tools::WebSearch#execute -d "bump timeout"
955
+ LONGDESC
956
+ option :desc, type: :string, aliases: "-d", default: "", desc: "Short description"
957
+ def patch_new(id, target)
958
+ require_relative "patch_loader"
959
+ path = Clacky::PatchLoader.scaffold(id, target, description: options[:desc])
960
+ puts "Created patch: #{path}"
961
+ puts "Edit patch.rb, then run: clacky patch_verify"
962
+ rescue ArgumentError, StandardError => e
963
+ warn "Error: #{e.message}"
964
+ exit 1
965
+ end
966
+
967
+ desc "patch_verify", "Load ~/.clacky/patches/ and report applied / disabled / skipped"
968
+ def patch_verify
969
+ require "clacky"
970
+ result = Clacky::PatchLoader.last_result
971
+
972
+ if result.applied.empty? && result.disabled.empty? && result.skipped.empty?
973
+ puts "No patches found in ~/.clacky/patches/"
974
+ return
975
+ end
976
+
977
+ result.applied.each { |id| puts "[OK] #{id}" }
978
+ result.disabled.each { |(id, reason)| puts "[DISABLED] #{id} — #{reason}" }
979
+ result.skipped.each { |(id, reason)| puts "[SKIP] #{id} — #{reason}" }
980
+ exit 1 if result.skipped.any?
981
+ end
982
+
983
+ desc "patch_list", "List patches under ~/.clacky/patches/ and their status"
984
+ def patch_list
985
+ invoke :patch_verify, []
986
+ end
987
+
988
+ desc "hook_new", "Scaffold a starter ~/.clacky/hooks.yml with an example guard script"
989
+ def hook_new
990
+ require_relative "shell_hook_loader"
991
+ path = Clacky::ShellHookLoader.scaffold
992
+ puts "Created hooks config: #{path}"
993
+ puts "Edit it, then run: clacky hook_verify"
994
+ rescue ArgumentError => e
995
+ warn "Error: #{e.message}"
996
+ exit 1
997
+ end
998
+
999
+ desc "hook_verify", "Load ~/.clacky/hooks.yml and report which hooks register"
1000
+ def hook_verify
1001
+ require_relative "agent/hook_manager"
1002
+ require_relative "shell_hook_loader"
1003
+ hm = Clacky::HookManager.new
1004
+ result = Clacky::ShellHookLoader.load_into(hm)
1005
+
1006
+ if result.registered.empty? && result.skipped.empty?
1007
+ puts "No hooks found in ~/.clacky/hooks.yml"
1008
+ return
1009
+ end
1010
+
1011
+ result.registered.each { |(event, name)| puts "[OK] #{event} → #{name}" }
1012
+ result.skipped.each { |(name, reason)| puts "[SKIP] #{name} — #{reason}" }
1013
+ exit 1 if result.skipped.any?
1014
+ end
1015
+
1016
+ desc "channel_new NAME", "Scaffold a custom channel adapter at ~/.clacky/channels/NAME/"
1017
+ long_desc <<-LONGDESC
1018
+ Generate a ready-to-edit channel adapter skeleton. The skeleton already
1019
+ self-registers and implements the full adapter interface with TODO markers —
1020
+ you only fill in the method bodies, then run `clacky channel_verify`.
1021
+
1022
+ Examples:
1023
+ $ clacky channel_new slack
1024
+ LONGDESC
1025
+ def channel_new(name)
1026
+ require_relative "server/channel"
1027
+ path = Clacky::Channel::Adapters::UserAdapterLoader.scaffold(name)
1028
+ puts "Created channel adapter: #{path}"
1029
+ puts "Edit the TODO sections, then run: clacky channel_verify"
1030
+ rescue ArgumentError => e
1031
+ warn "Error: #{e.message}"
1032
+ exit 1
1033
+ end
1034
+
1035
+ desc "channel_verify", "Load user channel adapters and report which are valid"
1036
+ def channel_verify
1037
+ require_relative "server/channel"
1038
+ result = Clacky::Channel::Adapters::UserAdapterLoader.last_result
1039
+
1040
+ if result.loaded.empty? && result.skipped.empty?
1041
+ puts "No custom channel adapters found in ~/.clacky/channels/"
1042
+ return
1043
+ end
1044
+
1045
+ result.loaded.each { |n| puts "[OK] #{n}" }
1046
+ result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
1047
+ exit 1 if result.skipped.any?
1048
+ end
1049
+
945
1050
  desc "billing", "Show billing summary and usage statistics"
946
1051
  long_desc <<-LONGDESC
947
1052
  Display billing summary with token usage and cost breakdown.
data/lib/clacky/client.rb CHANGED
@@ -258,7 +258,16 @@ module Clacky
258
258
  response.env.body = sse_buf if response.body.to_s.empty?
259
259
  raise_error(response)
260
260
  end
261
- MessageFormat::Bedrock.parse_response(aggregator.to_h)
261
+
262
+ result = aggregator.to_h
263
+ # A complete Converse stream always emits stopReason in its messageStop
264
+ # frame. Its absence means the upstream cut the stream mid-response,
265
+ # leaving a half-written message; retry rather than accept the truncation.
266
+ if result["stopReason"].nil?
267
+ raise Clacky::UpstreamTruncatedError,
268
+ "[LLM] Streaming response ended without stopReason (upstream cut the stream). Retrying..."
269
+ end
270
+ MessageFormat::Bedrock.parse_response(result)
262
271
  end
263
272
 
264
273
  def parse_simple_bedrock_response(response)
@@ -307,7 +316,16 @@ module Clacky
307
316
  recovered = Struct.new(:status, :body).new(response.status, recovered_body)
308
317
  raise_error(recovered)
309
318
  end
310
- MessageFormat::Anthropic.parse_response(aggregator.to_h)
319
+
320
+ result = aggregator.to_h
321
+ # A complete Messages stream always emits stop_reason in its message_delta
322
+ # frame. Its absence means the upstream cut the stream mid-response,
323
+ # leaving a half-written message; retry rather than accept the truncation.
324
+ if result["stop_reason"].nil?
325
+ raise Clacky::UpstreamTruncatedError,
326
+ "[LLM] Streaming response ended without stop_reason (upstream cut the stream). Retrying..."
327
+ end
328
+ MessageFormat::Anthropic.parse_response(result)
311
329
  end
312
330
 
313
331
  def parse_simple_anthropic_response(response)
@@ -360,7 +378,18 @@ module Clacky
360
378
  response.env.body = sse_buf if response.body.to_s.empty?
361
379
  raise_error(response)
362
380
  end
363
- MessageFormat::OpenAI.parse_response(aggregator.to_h)
381
+
382
+ result = aggregator.to_h
383
+ # A complete chat-completion stream always terminates with a frame
384
+ # carrying finish_reason. Its absence means the upstream cut the stream
385
+ # mid-response (e.g. proxy idle-timeout, connection reset that Faraday
386
+ # didn't surface as an exception), leaving a half-written message. Treat
387
+ # as retryable so we don't hand a silently truncated answer to the agent.
388
+ if result.dig("choices", 0, "finish_reason").nil?
389
+ raise Clacky::UpstreamTruncatedError,
390
+ "[LLM] Streaming response ended without finish_reason (upstream cut the stream). Retrying..."
391
+ end
392
+ MessageFormat::OpenAI.parse_response(result)
364
393
  end
365
394
 
366
395
  def parse_simple_openai_response(response)
@@ -532,10 +561,14 @@ module Clacky
532
561
  # ── Error handling ────────────────────────────────────────────────────────
533
562
 
534
563
  def handle_test_response(response)
535
- return { success: true } if response.status == 200
564
+ return { success: true, status: response.status } if response.status == 200
536
565
 
537
566
  error_body = JSON.parse(response.body) rescue nil
538
- { success: false, error: extract_error_message(error_body, response.body) }
567
+ {
568
+ success: false,
569
+ status: response.status,
570
+ error: extract_error_message(error_body, response.body)
571
+ }
539
572
  end
540
573
 
541
574
  def raise_error(response)
@@ -99,128 +99,51 @@ Ask:
99
99
 
100
100
  ### Feishu setup
101
101
 
102
- #### Step 1 Try automated setup (script)
102
+ Feishu now offers a one-click **Agent App** (智能体应用) that auto-configures all
103
+ required permissions, events, and publishing for you — no Bot capability toggle,
104
+ no permission JSON, no event subscription, no version/release steps. Just create
105
+ the app and copy the credentials. The connection mode is unchanged (long
106
+ connection / WebSocket), handled entirely by the server.
103
107
 
104
- Run the setup script (full path is available in the supporting files list above):
105
- ```bash
106
- ruby "SKILL_DIR/feishu_setup.rb"
107
- ```
108
- **Important**: call `terminal` with `timeout: 180` — the script may wait up to 90s for a WebSocket connection in Phase 4.
109
-
110
- **If exit code is 0:**
111
- - The script completed successfully.
112
- - Config is already written to `~/.clacky/channels.yml`.
113
- - Tell the user: "✅ Feishu channel configured automatically! The channel is ready."
114
- - **Skip Step 2 (manual fallback) and continue to Step 3.**
115
-
116
- **If exit code is non-0:**
117
- - Check stdout for the error message.
118
- - **If the error contains "Browser not configured" or "browser tool":**
119
- - Tell the user: "The browser tool is not configured yet. Let me help you set it up first..."
120
- - Invoke the `browser-setup` skill: `invoke_skill("browser-setup", "setup")`.
121
- - After browser-setup completes, tell the user: "Browser is ready! Let me retry the Feishu setup..."
122
- - **Retry the script** (same command, same timeout). If it succeeds this time, stop. If it fails again, check the new error and proceed accordingly.
123
- - **If the error contains "No cookies found" or "Please log in":**
124
- - Open Feishu login page using browser tool:
125
- ```
126
- browser(action="navigate", url="https://open.feishu.cn/app")
127
- ```
128
- - Tell the user: "I've opened Feishu in your browser. Please log in, then reply 'done'."
129
- - Wait for "done".
130
- - **Retry the script** (same command, same timeout). Repeat this login-wait-retry loop up to **3 times total**.
131
- - If any attempt succeeds (exit code 0), stop — setup is complete.
132
- - If an attempt fails with a **different** error (not a login error), break out of the loop and continue to Step 2.
133
- - If all 3 attempts fail with login errors, tell the user: "Automated setup was unable to detect a Feishu login after 3 attempts. Switching to guided setup..." and continue to Step 2.
134
- - **Otherwise (non-login, non-browser error):**
135
- - Tell the user: "Automated setup encountered an issue: `<error message>`. Switching to guided setup..."
136
- - Continue to Step 2 (manual flow) below.
137
-
138
- ---
139
-
140
- #### Step 2 — Manual guided setup (fallback)
141
-
142
- Only reach here if the automated script failed.
143
-
144
- ##### Phase 1 — Open Feishu Open Platform
145
-
146
- 1. Navigate: `open https://open.feishu.cn/app`. Pass `isolated: true`.
147
- 2. If a login page or QR code is shown, tell the user to log in and wait for "done".
148
- 3. Confirm the app list is visible.
149
-
150
- ##### Phase 2 — Create a new app
151
-
152
- 4. **Always create a new app** — do NOT reuse existing apps. Guide the user: "Click 'Create Enterprise Self-Built App', fill in name (e.g. Open Clacky) and description (e.g. AI assistant powered by openclacky), then submit. Reply done." Wait for "done".
153
-
154
- ##### Phase 3 — Enable Bot capability
155
-
156
- 5. Feishu opens Add App Capabilities by default after creating an app. Guide the user: "Find the Bot capability card and click the Add button next to it, then reply done." Wait for "done".
157
-
158
- ##### Phase 4 — Get credentials
159
-
160
- 6. Navigate to Credentials & Basic Info in the left menu.
161
- 7. Guide the user: "Copy App ID and App Secret, then paste here. Reply with: App ID: xxx, App Secret: xxx" Wait for the reply. Parse `app_id` and `app_secret`.
108
+ #### Step 1 Open the Agent App creation page
162
109
 
163
- ##### Phase 5Add message permissions
110
+ 1. Navigate: `open https://open.feishu.cn/page/launcher?from=backend_oneclick`. Pass `isolated: true`. If the browser is not configured (the `open` call fails), just give the user the URL and ask them to open it manually in any browser the rest of the flow is fully manual and does not need browser automation.
111
+ 2. If a login page or QR code is shown, tell the user to scan/log in and wait for "done".
164
112
 
165
- 8. Navigate to Permission Management and open the bulk import dialog.
166
- 9. Guide the user: "In the bulk import dialog, clear the existing example first (select all, delete), then paste the following JSON. Reply done." Wait for "done". Do NOT try to clear or edit via browser — user does it.
113
+ #### Step 2 Create the Agent App
167
114
 
168
- ```json
169
- {
170
- "scopes": {
171
- "tenant": [
172
- "im:message",
173
- "im:message.p2p_msg:readonly",
174
- "im:message:send_as_bot"
175
- ],
176
- "user": []
177
- }
178
- }
179
- ```
115
+ 3. After login, the page lands on **创建飞书智能体应用 (Create Feishu Agent App)**.
116
+ Guide the user: "Enter an app name (e.g. Open Clacky), then click **立即创建 (Create Now)**. Reply done."
117
+ (The avatar is auto-assigned at random and can be changed anytime — it does not affect setup.)
118
+ Wait for "done".
180
119
 
181
- ##### Phase 6Configure event subscription (Long Connection)
120
+ #### Step 3Copy credentials
182
121
 
183
- **CRITICAL**: Feishu requires the long connection to be established *before* you can save the event config. The platform shows "No application connection detected" until `clacky server` is running and connected.
122
+ 4. The page jumps to **创建成功 (Created Successfully)**, showing `App ID` and `App Secret`.
123
+ The Secret is masked by default. Guide the user: "Click the eye icon next to **App Secret** to reveal it,
124
+ then copy both values and paste here. Reply with: App ID: xxx, App Secret: xxx"
125
+ Wait for the reply. Parse `app_id` (starts with `cli_`) and `app_secret`. Trim whitespace and
126
+ make sure the two values are not swapped.
184
127
 
185
- 10. **Apply config and establish connection** — Run:
186
- ```bash
187
- curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
188
- -H "Content-Type: application/json" \
189
- -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
190
- ```
191
- **CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml` or any file under `~/.clacky/channels/` directly. The server API handles persistence and hot-reload.**
192
- 11. **Wait for connection** — Poll until log shows `[feishu-ws] WebSocket connected ✅`:
193
- ```bash
194
- for i in $(seq 1 20); do
195
- grep -q "\[feishu-ws\] WebSocket connected" ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log 2>/dev/null && echo "CONNECTED" && break
196
- sleep 1
197
- done
198
- ```
199
- 12. **Configure events** — Guide the user: "In Events & Callbacks, select 'Long Connection' mode. Click Save. Then click Add Event, search `im.message.receive_v1`, select it, click Add. Reply done." Wait for "done".
128
+ #### Step 4 Save credentials
200
129
 
201
- ##### Phase 7 — Publish the app
202
-
203
- 13. Navigate to Version Management & Release. Guide the user: "Create a new version (e.g. 1.0.0, note: Initial release for Open Clacky) and publish it. Reply done." Wait for "done".
204
-
205
- ##### Phase 8 — Validate
206
-
207
- ```bash
208
- curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
209
- -H "Content-Type: application/json" \
210
- -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>"}'
211
- ```
212
-
213
- Check for `"code":0`. On success: continue to Step 3 (below).
214
-
215
- ##### Phase 9 — done
130
+ 5. Run:
131
+ ```bash
132
+ curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
133
+ -H "Content-Type: application/json" \
134
+ -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
135
+ ```
136
+ **CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
137
+ or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
138
+ and establishing the long connection.**
216
139
 
217
- Step 2 ends here. **Continue to Step 3.**
140
+ On success: tell the user "✅ Feishu channel configured!" and **continue to Step 5 (Feishu CLI)**.
218
141
 
219
142
  ---
220
143
 
221
- #### Step 3 — Optional: install Feishu CLI
144
+ #### Step 5 — Optional: install Feishu CLI
222
145
 
223
- Reach here from either Step 1 success or end of Step 2. Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
146
+ Reach here after the channel is configured (Step 4 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
224
147
 
225
148
  Call `request_user_feedback`:
226
149
 
@@ -269,7 +192,7 @@ When `lark-cli auth login` returns successfully, tell the user:
269
192
 
270
193
  ### WeCom setup
271
194
 
272
- 1. Navigate: `open https://work.weixin.qq.com/wework_admin/frame#/aiHelper/create`. Pass `isolated: true`.
195
+ 1. Navigate: `open https://work.weixin.qq.com/wework_admin/frame#/aiHelper/create`. Pass `isolated: true`. If the browser is not configured (the `open` call fails), just give the user the URL and ask them to open it manually in any browser — the rest of the flow is fully manual and does not need browser automation.
273
196
  2. If a login page or QR code is shown, tell the user to log in and wait for "done".
274
197
  3. Guide the user: "Scroll to the bottom of the right panel and click 'API mode creation'. Reply done." Wait for "done".
275
198
  4. Guide the user: "Click 'Add' next to 'Visible Range'. Select the top-level company node. Click Confirm. Reply done." Wait for "done".