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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/agent_config.rb +91 -7
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/cli.rb +105 -0
- data/lib/clacky/client.rb +38 -5
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/providers.rb +82 -0
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +1 -1
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +236 -25
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +326 -24
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +84 -6
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +9 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2060d694267d0947681785d2e2ffe730d0b241a9ac2ec68e218eb037478bf27
|
|
4
|
+
data.tar.gz: 0b3e301010e16752da0bd64a9603b06010c2a23c5bd81884c7719d74f8f1bd67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -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
|
|
900
|
-
#
|
|
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]
|
|
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
|
|
119
|
-
# @param
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
120
|
+
#### Step 3 — Copy credentials
|
|
182
121
|
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
140
|
+
On success: tell the user "✅ Feishu channel configured!" and **continue to Step 5 (Feishu CLI)**.
|
|
218
141
|
|
|
219
142
|
---
|
|
220
143
|
|
|
221
|
-
#### Step
|
|
144
|
+
#### Step 5 — Optional: install Feishu CLI
|
|
222
145
|
|
|
223
|
-
Reach here
|
|
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".
|