openclacky 0.9.28 → 0.9.30
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 +39 -0
- data/docs/deploy-architecture.md +619 -0
- data/lib/clacky/agent/llm_caller.rb +14 -2
- data/lib/clacky/agent/message_compressor.rb +24 -6
- data/lib/clacky/agent/message_compressor_helper.rb +17 -10
- data/lib/clacky/agent/session_serializer.rb +69 -0
- data/lib/clacky/agent/skill_manager.rb +2 -2
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/brand_config.rb +29 -3
- data/lib/clacky/clacky_auth_client.rb +152 -0
- data/lib/clacky/clacky_cloud_config.rb +123 -0
- data/lib/clacky/cli.rb +13 -0
- data/lib/clacky/client.rb +21 -7
- data/lib/clacky/cloud_project_client.rb +169 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_parsers/doc_parser.rb +9 -9
- data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
- data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
- data/lib/clacky/default_skills/new/SKILL.md +117 -5
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
- data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
- data/lib/clacky/deploy_api_client.rb +484 -0
- data/lib/clacky/json_ui_controller.rb +16 -10
- data/lib/clacky/message_format/bedrock.rb +3 -2
- data/lib/clacky/message_history.rb +8 -0
- data/lib/clacky/plain_ui_controller.rb +1 -6
- data/lib/clacky/providers.rb +23 -4
- data/lib/clacky/server/browser_manager.rb +3 -1
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
- data/lib/clacky/server/http_server.rb +12 -2
- data/lib/clacky/server/server_master.rb +43 -7
- data/lib/clacky/server/web_ui_controller.rb +17 -9
- data/lib/clacky/skill.rb +6 -2
- data/lib/clacky/tools/run_project.rb +4 -1
- data/lib/clacky/tools/shell.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +1 -5
- data/lib/clacky/ui_interface.rb +5 -7
- data/lib/clacky/utils/arguments_parser.rb +22 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +45 -5
- data/lib/clacky/web/app.js +126 -19
- data/lib/clacky/web/i18n.js +57 -0
- data/lib/clacky/web/sessions.js +108 -39
- data/lib/clacky/web/skills.js +8 -2
- data/lib/clacky.rb +3 -0
- metadata +8 -1
|
@@ -81,10 +81,16 @@ module Clacky
|
|
|
81
81
|
# infrastructure blips — do NOT trigger fallback. Just retry on the current
|
|
82
82
|
# model (primary or already-active fallback) up to max_retries.
|
|
83
83
|
if retries <= max_retries
|
|
84
|
-
@ui&.
|
|
84
|
+
@ui&.show_progress(
|
|
85
|
+
"Network failed: #{e.message}",
|
|
86
|
+
progress_type: "retrying",
|
|
87
|
+
phase: "active",
|
|
88
|
+
metadata: { attempt: retries, total: max_retries }
|
|
89
|
+
)
|
|
85
90
|
sleep retry_delay
|
|
86
91
|
retry
|
|
87
92
|
else
|
|
93
|
+
@ui&.show_progress(progress_type: "retrying", phase: "done")
|
|
88
94
|
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
89
95
|
raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
90
96
|
end
|
|
@@ -112,10 +118,16 @@ module Clacky
|
|
|
112
118
|
retry
|
|
113
119
|
end
|
|
114
120
|
end
|
|
115
|
-
@ui&.
|
|
121
|
+
@ui&.show_progress(
|
|
122
|
+
e.message,
|
|
123
|
+
progress_type: "retrying",
|
|
124
|
+
phase: "active",
|
|
125
|
+
metadata: { attempt: retries, total: current_max }
|
|
126
|
+
)
|
|
116
127
|
sleep retry_delay
|
|
117
128
|
retry
|
|
118
129
|
else
|
|
130
|
+
@ui&.show_progress(progress_type: "retrying", phase: "done")
|
|
119
131
|
@ui&.show_error("LLM service unavailable after #{current_max} retries. Please try again later.")
|
|
120
132
|
raise AgentError, "LLM service unavailable after #{current_max} retries"
|
|
121
133
|
end
|
|
@@ -38,16 +38,23 @@ module Clacky
|
|
|
38
38
|
YOUR ONLY TASK: Create a comprehensive summary of the conversation above.
|
|
39
39
|
|
|
40
40
|
REQUIRED RESPONSE FORMAT:
|
|
41
|
-
|
|
41
|
+
First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
|
|
42
|
+
Then output the full summary wrapped in <summary> tags.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
Example format:
|
|
45
|
+
<topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
|
|
46
|
+
<summary>
|
|
47
|
+
...full summary text...
|
|
48
|
+
</summary>
|
|
49
|
+
|
|
50
|
+
Focus on:
|
|
44
51
|
- User's explicit requests and intents
|
|
45
52
|
- Key technical concepts and code changes
|
|
46
53
|
- Files examined and modified
|
|
47
54
|
- Errors encountered and fixes applied
|
|
48
55
|
- Current work status and pending tasks
|
|
49
56
|
|
|
50
|
-
Begin your
|
|
57
|
+
Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then <summary>.
|
|
51
58
|
PROMPT
|
|
52
59
|
|
|
53
60
|
def initialize(client, model: nil)
|
|
@@ -109,22 +116,33 @@ module Clacky
|
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
|
|
119
|
+
# Parse topics tag from compressed content.
|
|
120
|
+
# Returns the topics string if found, nil otherwise.
|
|
121
|
+
# e.g. "<topics>Rails setup, database config</topics>" → "Rails setup, database config"
|
|
122
|
+
def parse_topics(content)
|
|
123
|
+
m = content.match(/<topics>(.*?)<\/topics>/m)
|
|
124
|
+
m ? m[1].strip : nil
|
|
125
|
+
end
|
|
126
|
+
|
|
112
127
|
def parse_compressed_result(result, chunk_path: nil)
|
|
113
128
|
# Return the compressed result as a single assistant message
|
|
114
|
-
# Keep the <
|
|
129
|
+
# Keep the <summary> tags as they provide semantic context
|
|
115
130
|
content = result.to_s.strip
|
|
116
131
|
|
|
117
132
|
if content.empty?
|
|
118
133
|
[]
|
|
119
134
|
else
|
|
135
|
+
# Strip out the <topics> block — it's metadata for the chunk file, not for AI context
|
|
136
|
+
content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
|
|
137
|
+
|
|
120
138
|
# Inject chunk anchor so AI knows where to find original conversation
|
|
121
139
|
if chunk_path
|
|
122
140
|
anchor = "\n\n---\n📁 **Original conversation archived at:** `#{chunk_path}`\n" \
|
|
123
141
|
"_Use `file_reader` tool to recall details from this chunk._"
|
|
124
|
-
|
|
142
|
+
content_without_topics = content_without_topics + anchor
|
|
125
143
|
end
|
|
126
144
|
|
|
127
|
-
[{ role: "assistant", content:
|
|
145
|
+
[{ role: "assistant", content: content_without_topics, compressed_summary: true, chunk_path: chunk_path }]
|
|
128
146
|
end
|
|
129
147
|
end
|
|
130
148
|
end
|
|
@@ -17,9 +17,9 @@ module Clacky
|
|
|
17
17
|
def trigger_idle_compression
|
|
18
18
|
# Check if we should compress (force mode)
|
|
19
19
|
compression_context = compress_messages_if_needed(force: true)
|
|
20
|
-
@ui&.
|
|
20
|
+
@ui&.show_progress("Idle detected. Compressing conversation to optimize costs...", progress_type: "idle_compress", phase: "active")
|
|
21
21
|
if compression_context.nil?
|
|
22
|
-
@ui&.
|
|
22
|
+
@ui&.show_progress("Idle skipped.", progress_type: "idle_compress", phase: "done")
|
|
23
23
|
Clacky::Logger.info(
|
|
24
24
|
"Idle compression skipped",
|
|
25
25
|
enable_compression: @config.enable_compression,
|
|
@@ -137,7 +137,8 @@ module Clacky
|
|
|
137
137
|
original_messages,
|
|
138
138
|
compression_context[:recent_messages],
|
|
139
139
|
chunk_index: chunk_index,
|
|
140
|
-
compression_level: compression_context[:compression_level]
|
|
140
|
+
compression_level: compression_context[:compression_level],
|
|
141
|
+
topics: @message_compressor.parse_topics(compressed_content)
|
|
141
142
|
)
|
|
142
143
|
|
|
143
144
|
@history.replace_all(@message_compressor.rebuild_with_compression(
|
|
@@ -165,10 +166,9 @@ module Clacky
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
# Show compression info (use estimated tokens from rebuilt history)
|
|
168
|
-
@
|
|
169
|
-
"History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
|
|
169
|
+
compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
|
|
170
170
|
"level #{compression_context[:compression_level]})"
|
|
171
|
-
)
|
|
171
|
+
@ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
# Get recent messages while preserving tool_calls/tool_results pairs.
|
|
@@ -304,16 +304,21 @@ module Clacky
|
|
|
304
304
|
# @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
|
|
305
305
|
# @param chunk_index [Integer] Sequential chunk number
|
|
306
306
|
# @param compression_level [Integer] Compression level
|
|
307
|
+
# @param topics [String, nil] Short topic description for chunk index card
|
|
307
308
|
# @return [String, nil] Path to saved chunk file, or nil if save failed
|
|
308
|
-
def save_compressed_chunk(original_messages, recent_messages, chunk_index:, compression_level:)
|
|
309
|
+
def save_compressed_chunk(original_messages, recent_messages, chunk_index:, compression_level:, topics: nil)
|
|
309
310
|
return nil unless @session_id && @created_at
|
|
310
311
|
|
|
311
312
|
# Messages being compressed = original minus system message minus recent messages
|
|
312
313
|
# Also exclude system-injected scaffolding (session context, memory prompts, etc.)
|
|
313
314
|
# — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
|
|
315
|
+
# Also exclude previous compressed_summary messages: they are index cards pointing
|
|
316
|
+
# to older chunk files and must NOT be embedded inside a new chunk, otherwise
|
|
317
|
+
# parse_chunk_md_to_rounds would follow the nested reference and create circular
|
|
318
|
+
# chunk chains (chunk-2 → chunk-1 → ... → chunk-2).
|
|
314
319
|
recent_set = recent_messages.to_a
|
|
315
320
|
messages_to_archive = original_messages.reject do |m|
|
|
316
|
-
m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
|
|
321
|
+
m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
|
|
317
322
|
end
|
|
318
323
|
|
|
319
324
|
return nil if messages_to_archive.empty?
|
|
@@ -325,7 +330,7 @@ module Clacky
|
|
|
325
330
|
chunk_filename = "#{base_name}-chunk-#{chunk_index}.md"
|
|
326
331
|
chunk_path = File.join(sessions_dir, chunk_filename)
|
|
327
332
|
|
|
328
|
-
md_content = build_chunk_md(messages_to_archive, chunk_index: chunk_index, compression_level: compression_level)
|
|
333
|
+
md_content = build_chunk_md(messages_to_archive, chunk_index: chunk_index, compression_level: compression_level, topics: topics)
|
|
329
334
|
|
|
330
335
|
File.write(chunk_path, md_content)
|
|
331
336
|
FileUtils.chmod(0o600, chunk_path)
|
|
@@ -340,8 +345,9 @@ module Clacky
|
|
|
340
345
|
# @param messages [Array<Hash>] Messages to render
|
|
341
346
|
# @param chunk_index [Integer] Chunk number for metadata
|
|
342
347
|
# @param compression_level [Integer] Compression level
|
|
348
|
+
# @param topics [String, nil] Short topic description extracted from LLM summary
|
|
343
349
|
# @return [String] Markdown content
|
|
344
|
-
def build_chunk_md(messages, chunk_index:, compression_level:)
|
|
350
|
+
def build_chunk_md(messages, chunk_index:, compression_level:, topics: nil)
|
|
345
351
|
lines = []
|
|
346
352
|
|
|
347
353
|
# Front matter
|
|
@@ -351,6 +357,7 @@ module Clacky
|
|
|
351
357
|
lines << "compression_level: #{compression_level}"
|
|
352
358
|
lines << "archived_at: #{Time.now.iso8601}"
|
|
353
359
|
lines << "message_count: #{messages.size}"
|
|
360
|
+
lines << "topics: #{topics}" if topics
|
|
354
361
|
lines << "---"
|
|
355
362
|
lines << ""
|
|
356
363
|
lines << "# Session Chunk #{chunk_index}"
|
|
@@ -529,6 +529,75 @@ module Clacky
|
|
|
529
529
|
{ name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
|
|
530
530
|
end
|
|
531
531
|
end
|
|
532
|
+
|
|
533
|
+
# Inject a chunk index card into the conversation when archived chunks exist.
|
|
534
|
+
# Lists all chunk files (path + topics + turn count) so the AI knows where to
|
|
535
|
+
# look if it needs details from past conversations. The AI can load any chunk
|
|
536
|
+
# on demand using the existing file_reader tool — no new tools required.
|
|
537
|
+
#
|
|
538
|
+
# Only re-injects when a new chunk has been added since the last injection,
|
|
539
|
+
# keeping the message list clean across multiple compressions.
|
|
540
|
+
#
|
|
541
|
+
# Cache-safe: injected as a system_injected user message in the conversation
|
|
542
|
+
# turns, never touching the system prompt.
|
|
543
|
+
def inject_chunk_index_if_needed
|
|
544
|
+
# Collect all compressed_summary messages that carry a chunk_path
|
|
545
|
+
chunk_msgs = @history.to_a.select { |m| m[:compressed_summary] && m[:chunk_path] }
|
|
546
|
+
return if chunk_msgs.empty?
|
|
547
|
+
|
|
548
|
+
# Skip if we already injected an index for this exact chunk count
|
|
549
|
+
return if @history.last_injected_chunk_count == chunk_msgs.size
|
|
550
|
+
|
|
551
|
+
# Remove any previously injected chunk index (stale — chunk count changed)
|
|
552
|
+
@history.delete_where { |m| m[:chunk_index] }
|
|
553
|
+
|
|
554
|
+
# Build index card lines
|
|
555
|
+
lines = ["## Previous Session Archives (#{chunk_msgs.size} chunk#{"s" if chunk_msgs.size > 1} available)\n"]
|
|
556
|
+
chunk_msgs.each_with_index do |msg, i|
|
|
557
|
+
path = msg[:chunk_path].to_s
|
|
558
|
+
topics = read_chunk_topics(path)
|
|
559
|
+
turns = read_chunk_message_count(path)
|
|
560
|
+
lines << "[CHUNK-#{i + 1}] #{path}"
|
|
561
|
+
lines << " Topics: #{topics}" if topics
|
|
562
|
+
lines << " Turns: #{turns}" if turns
|
|
563
|
+
lines << ""
|
|
564
|
+
end
|
|
565
|
+
lines << "Use file_reader to load a chunk file when you need original conversation details."
|
|
566
|
+
|
|
567
|
+
@history.append({
|
|
568
|
+
role: "user",
|
|
569
|
+
content: lines.join("\n"),
|
|
570
|
+
system_injected: true,
|
|
571
|
+
chunk_index: true,
|
|
572
|
+
chunk_count: chunk_msgs.size
|
|
573
|
+
})
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Read the `topics` field from a chunk MD file's YAML front matter.
|
|
577
|
+
# Returns nil if the file is missing or has no topics field.
|
|
578
|
+
private def read_chunk_topics(chunk_path)
|
|
579
|
+
return nil unless chunk_path && File.exist?(chunk_path)
|
|
580
|
+
File.foreach(chunk_path) do |line|
|
|
581
|
+
return line.sub(/^topics:\s*/, "").strip if line.start_with?("topics:")
|
|
582
|
+
break if line.strip == "---" && $. > 1 # end of front matter
|
|
583
|
+
end
|
|
584
|
+
nil
|
|
585
|
+
rescue
|
|
586
|
+
nil
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Read the `message_count` field from a chunk MD file's YAML front matter.
|
|
590
|
+
# Returns nil if the file is missing or has no message_count field.
|
|
591
|
+
private def read_chunk_message_count(chunk_path)
|
|
592
|
+
return nil unless chunk_path && File.exist?(chunk_path)
|
|
593
|
+
File.foreach(chunk_path) do |line|
|
|
594
|
+
return line.sub(/^message_count:\s*/, "").strip.to_i if line.start_with?("message_count:")
|
|
595
|
+
break if line.strip == "---" && $. > 1
|
|
596
|
+
end
|
|
597
|
+
nil
|
|
598
|
+
rescue
|
|
599
|
+
nil
|
|
600
|
+
end
|
|
532
601
|
end
|
|
533
602
|
end
|
|
534
603
|
end
|
|
@@ -255,7 +255,7 @@ module Clacky
|
|
|
255
255
|
transient: transient
|
|
256
256
|
})
|
|
257
257
|
|
|
258
|
-
@ui&.show_info("Injected skill content for /#{skill.identifier}")
|
|
258
|
+
@ui&.show_info("Injected skill content for /#{skill.identifier}#{skill.name_zh.to_s.empty? ? "" : " (#{skill.name_zh})"}")
|
|
259
259
|
end
|
|
260
260
|
|
|
261
261
|
|
|
@@ -405,7 +405,7 @@ module Clacky
|
|
|
405
405
|
|
|
406
406
|
# Log which model the subagent is actually using (may differ from requested
|
|
407
407
|
# when "lite" falls back to default due to no lite model configured)
|
|
408
|
-
@ui&.show_info("Subagent start: #{skill.identifier} [#{subagent.current_model_info[:model]}]")
|
|
408
|
+
@ui&.show_info("Subagent start: #{skill.identifier}#{skill.name_zh.to_s.empty? ? "" : " (#{skill.name_zh})"} [#{subagent.current_model_info[:model]}]")
|
|
409
409
|
|
|
410
410
|
# Run subagent with the actual task as the sole user turn.
|
|
411
411
|
# If the user typed the skill command with no arguments (e.g. "/jade-appraisal"),
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -212,6 +212,9 @@ module Clacky
|
|
|
212
212
|
# Inject session context (date + model) if not yet present or date has changed
|
|
213
213
|
inject_session_context_if_needed
|
|
214
214
|
|
|
215
|
+
# Inject chunk index card if archived chunks exist and index is stale
|
|
216
|
+
inject_chunk_index_if_needed
|
|
217
|
+
|
|
215
218
|
# Split files into vision images and disk files; downgrade oversized images to disk
|
|
216
219
|
image_files, disk_files = partition_files(Array(files))
|
|
217
220
|
vision_images, downgraded = resolve_vision_images(image_files)
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -560,7 +560,7 @@ module Clacky
|
|
|
560
560
|
# Record installed version in brand_skills.json (including description for
|
|
561
561
|
# offline display when the remote API is unreachable).
|
|
562
562
|
# encrypted: true because the ZIP contains MANIFEST.enc.json + AES-256-GCM encrypted files.
|
|
563
|
-
record_installed_skill(slug, version, skill_info["description"], encrypted: true, description_zh: skill_info["description_zh"])
|
|
563
|
+
record_installed_skill(slug, version, skill_info["description"], encrypted: true, description_zh: skill_info["description_zh"], name_zh: skill_info["name_zh"])
|
|
564
564
|
|
|
565
565
|
{ success: true, name: slug, version: version }
|
|
566
566
|
rescue StandardError, ScriptError => e
|
|
@@ -623,7 +623,7 @@ module Clacky
|
|
|
623
623
|
File.binwrite(enc_path, mock_content.encode("UTF-8"))
|
|
624
624
|
|
|
625
625
|
# encrypted: false — mock skills store plain bytes in .enc, no MANIFEST needed.
|
|
626
|
-
record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh)
|
|
626
|
+
record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh, name_zh: skill_info["name_zh"])
|
|
627
627
|
{ success: true, name: slug, version: version }
|
|
628
628
|
rescue StandardError => e
|
|
629
629
|
{ success: false, error: e.message }
|
|
@@ -664,11 +664,36 @@ module Clacky
|
|
|
664
664
|
# installed and that have a newer version available.
|
|
665
665
|
# New skills are never auto-installed — the user must click Install/Update
|
|
666
666
|
# explicitly from the Brand Skills panel.
|
|
667
|
+
installed = installed_brand_skills
|
|
667
668
|
skills_needing_update = result[:skills].select { |s| s["needs_update"] }
|
|
668
669
|
results = skills_needing_update.map do |skill_info|
|
|
669
670
|
install_brand_skill!(skill_info)
|
|
670
671
|
end
|
|
671
672
|
|
|
673
|
+
# Even when the version hasn't changed, display metadata (name_zh,
|
|
674
|
+
# description_zh, description) may have been updated on the platform.
|
|
675
|
+
# Patch brand_skills.json in-place without re-downloading the ZIP.
|
|
676
|
+
result[:skills].each do |skill_info|
|
|
677
|
+
name = skill_info["name"]
|
|
678
|
+
next unless installed.key?(name)
|
|
679
|
+
next if skill_info["needs_update"] # already being reinstalled above
|
|
680
|
+
|
|
681
|
+
local = installed[name]
|
|
682
|
+
next if local["name_zh"] == skill_info["name_zh"].to_s &&
|
|
683
|
+
local["description_zh"] == skill_info["description_zh"].to_s &&
|
|
684
|
+
local["description"] == skill_info["description"].to_s
|
|
685
|
+
|
|
686
|
+
# Metadata changed — update brand_skills.json without reinstalling.
|
|
687
|
+
record_installed_skill(
|
|
688
|
+
name,
|
|
689
|
+
local["version"],
|
|
690
|
+
skill_info["description"].to_s,
|
|
691
|
+
encrypted: local["encrypted"] != false,
|
|
692
|
+
description_zh: skill_info["description_zh"].to_s,
|
|
693
|
+
name_zh: skill_info["name_zh"].to_s
|
|
694
|
+
)
|
|
695
|
+
end
|
|
696
|
+
|
|
672
697
|
on_complete&.call(results)
|
|
673
698
|
rescue StandardError
|
|
674
699
|
# Background sync failures are intentionally swallowed — the agent
|
|
@@ -1069,7 +1094,7 @@ module Clacky
|
|
|
1069
1094
|
# 1. name already valid → use name as-is
|
|
1070
1095
|
# 2. name invalid — sanitize → downcase, spaces→hyphens, strip illegal chars
|
|
1071
1096
|
# 3. still invalid after sanitize → raise, caller gets { success: false }
|
|
1072
|
-
private def record_installed_skill(name, version, description = nil, encrypted: true, description_zh: nil)
|
|
1097
|
+
private def record_installed_skill(name, version, description = nil, encrypted: true, description_zh: nil, name_zh: nil)
|
|
1073
1098
|
safe_name = sanitize_skill_name(name)
|
|
1074
1099
|
|
|
1075
1100
|
FileUtils.mkdir_p(brand_skills_dir)
|
|
@@ -1078,6 +1103,7 @@ module Clacky
|
|
|
1078
1103
|
installed[safe_name] = {
|
|
1079
1104
|
"version" => version,
|
|
1080
1105
|
"name" => safe_name,
|
|
1106
|
+
"name_zh" => name_zh.to_s,
|
|
1081
1107
|
"description" => description.to_s,
|
|
1082
1108
|
"description_zh" => description_zh.to_s,
|
|
1083
1109
|
"encrypted" => encrypted,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# ClackyAuthClient - Fetches LLM keys from a Clacky workspace via API
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# client = ClackyAuthClient.new("clacky_ak_xxx", base_url: "https://api.example.com")
|
|
11
|
+
# result = client.fetch_workspace_keys
|
|
12
|
+
# # => { success: true, llm_key: "ABSK...", model_name: "jp.anthropic.claude-sonnet-4-6",
|
|
13
|
+
# # base_url: "https://...", anthropic_format: false }
|
|
14
|
+
class ClackyAuthClient
|
|
15
|
+
WORKSPACE_KEYS_PATH = "/openclacky/v1/workspace/keys"
|
|
16
|
+
REQUEST_TIMEOUT = 15 # seconds
|
|
17
|
+
OPEN_TIMEOUT = 5 # seconds
|
|
18
|
+
|
|
19
|
+
# Default model to use when the workspace/keys response does not specify one
|
|
20
|
+
DEFAULT_MODEL = "jp.anthropic.claude-sonnet-4-6"
|
|
21
|
+
|
|
22
|
+
def initialize(workspace_api_key, base_url:)
|
|
23
|
+
@workspace_api_key = workspace_api_key.to_s.strip
|
|
24
|
+
@base_url = base_url.to_s.strip.sub(%r{/+$}, "")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fetch workspace keys from the Clacky backend.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
# On success:
|
|
31
|
+
# { success: true,
|
|
32
|
+
# llm_key: "...", # raw LLM key string returned by the API (ABSK prefix = Bedrock)
|
|
33
|
+
# model_name: "...", # model to configure (provider default or our default)
|
|
34
|
+
# base_url: "...", # LLM proxy base URL (clean host, no path suffix)
|
|
35
|
+
# anthropic_format: false # ABSK keys use Bedrock Converse format, not Anthropic wire format
|
|
36
|
+
# }
|
|
37
|
+
# On failure:
|
|
38
|
+
# { success: false, error: "..." }
|
|
39
|
+
def fetch_workspace_keys
|
|
40
|
+
validate_inputs!
|
|
41
|
+
|
|
42
|
+
response = connection.get(WORKSPACE_KEYS_PATH)
|
|
43
|
+
|
|
44
|
+
unless response.status == 200
|
|
45
|
+
error_msg = extract_error(response)
|
|
46
|
+
return { success: false, error: "HTTP #{response.status}: #{error_msg}" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
body = JSON.parse(response.body)
|
|
50
|
+
|
|
51
|
+
unless body["code"].to_i == 200
|
|
52
|
+
return { success: false, error: "API error: #{body["msg"] || body["message"]}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
llm_key_data = body.dig("data", "llm_key")
|
|
56
|
+
if llm_key_data.nil?
|
|
57
|
+
return { success: false, error: "No LLM key available for this workspace" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Extract key value – the API returns a hash with fields:
|
|
61
|
+
# raw_key – plaintext secret (primary field since 2026-03-30)
|
|
62
|
+
# key – alias used by some gateway endpoints
|
|
63
|
+
# key_id – legacy identifier (kept for forward-compat)
|
|
64
|
+
# Priority: raw_key > key > key_id > value
|
|
65
|
+
# We also accept a plain string form for forward-compat.
|
|
66
|
+
llm_key = case llm_key_data
|
|
67
|
+
when String then llm_key_data
|
|
68
|
+
when Hash
|
|
69
|
+
llm_key_data["raw_key"] || llm_key_data["key"] ||
|
|
70
|
+
llm_key_data["key_id"] || llm_key_data["value"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if llm_key.nil? || llm_key.to_s.strip.empty?
|
|
74
|
+
return { success: false, error: "LLM key value is empty or missing in response" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# base_url comes from the `host` field in the API response (set per environment by backend config).
|
|
78
|
+
# Fallback to @base_url (the backend URL the user entered).
|
|
79
|
+
# No path suffix is appended — the LLM key has ABSK prefix (Bedrock), so client.rb will
|
|
80
|
+
# automatically build the correct endpoint: /model/{model}/converse
|
|
81
|
+
host = llm_key_data.is_a?(Hash) ? llm_key_data["host"].to_s.strip : ""
|
|
82
|
+
llm_base_url = if host.start_with?("http://", "https://")
|
|
83
|
+
host
|
|
84
|
+
else
|
|
85
|
+
@base_url
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
success: true,
|
|
90
|
+
llm_key: llm_key.to_s.strip,
|
|
91
|
+
model_name: DEFAULT_MODEL,
|
|
92
|
+
base_url: llm_base_url,
|
|
93
|
+
anthropic_format: false
|
|
94
|
+
}
|
|
95
|
+
rescue Faraday::ConnectionFailed => e
|
|
96
|
+
{ success: false, error: "Connection failed: #{e.message}" }
|
|
97
|
+
rescue Faraday::TimeoutError
|
|
98
|
+
{ success: false, error: "Request timed out (#{REQUEST_TIMEOUT}s)" }
|
|
99
|
+
rescue Faraday::Error => e
|
|
100
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
101
|
+
rescue JSON::ParserError => e
|
|
102
|
+
{ success: false, error: "Invalid JSON response: #{e.message}" }
|
|
103
|
+
rescue ArgumentError => e
|
|
104
|
+
{ success: false, error: e.message }
|
|
105
|
+
rescue => e
|
|
106
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Validate that inputs look reasonable before making a network request.
|
|
110
|
+
private def validate_inputs!
|
|
111
|
+
if @workspace_api_key.empty?
|
|
112
|
+
raise ArgumentError, "Workspace API key is required"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless @workspace_api_key.start_with?("clacky_ak_")
|
|
116
|
+
raise ArgumentError, "Invalid key format (expected prefix: clacky_ak_)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if @base_url.empty?
|
|
120
|
+
raise ArgumentError, "Base URL is required"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless @base_url.start_with?("http://", "https://")
|
|
124
|
+
raise ArgumentError, "Base URL must start with http:// or https://"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build a Faraday connection pointing at the Clacky backend.
|
|
129
|
+
private def connection
|
|
130
|
+
@connection ||= Faraday.new(url: @base_url) do |conn|
|
|
131
|
+
conn.headers["Content-Type"] = "application/json"
|
|
132
|
+
conn.headers["Authorization"] = "Bearer #{@workspace_api_key}"
|
|
133
|
+
conn.options.timeout = REQUEST_TIMEOUT
|
|
134
|
+
conn.options.open_timeout = OPEN_TIMEOUT
|
|
135
|
+
conn.ssl.verify = false
|
|
136
|
+
conn.adapter Faraday.default_adapter
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extract a human-readable error from a failed response.
|
|
141
|
+
private def extract_error(response)
|
|
142
|
+
body = JSON.parse(response.body) rescue nil
|
|
143
|
+
return response.body.to_s[0..200] unless body.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
body["msg"] ||
|
|
146
|
+
body["message"] ||
|
|
147
|
+
body.dig("error", "message") ||
|
|
148
|
+
body["error"].to_s[0..200] ||
|
|
149
|
+
response.body.to_s[0..200]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# ClackyCloudConfig — stores the Clacky Cloud credentials used for workspace-key
|
|
8
|
+
# import (workspace_api_key + backend base_url) in a dedicated file so the user
|
|
9
|
+
# never has to re-enter them.
|
|
10
|
+
#
|
|
11
|
+
# File location: ~/.clacky/clacky_cloud.yml
|
|
12
|
+
# File format (YAML):
|
|
13
|
+
# workspace_key: clacky_ak_xxxx
|
|
14
|
+
# base_url: https://api.clacky.ai
|
|
15
|
+
# dashboard_url: https://app.clacky.ai # optional, inferred from base_url if absent
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# cfg = ClackyCloudConfig.load
|
|
19
|
+
# cfg.workspace_key # => "clacky_ak_xxxx" or nil
|
|
20
|
+
# cfg.base_url # => "https://api.clacky.ai"
|
|
21
|
+
# cfg.dashboard_url # => "https://app.clacky.ai" (explicit or inferred)
|
|
22
|
+
# cfg.configured? # => true / false
|
|
23
|
+
#
|
|
24
|
+
# cfg.workspace_key = "clacky_ak_newkey"
|
|
25
|
+
# cfg.save
|
|
26
|
+
class ClackyCloudConfig
|
|
27
|
+
CONFIG_DIR = File.join(Dir.home, ".clacky")
|
|
28
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "clacky_cloud.yml")
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.clacky.ai"
|
|
31
|
+
DEFAULT_DASHBOARD_URL = "https://app.clacky.ai"
|
|
32
|
+
|
|
33
|
+
attr_accessor :workspace_key, :base_url, :dashboard_url
|
|
34
|
+
|
|
35
|
+
def initialize(workspace_key: nil, base_url: DEFAULT_BASE_URL, dashboard_url: nil)
|
|
36
|
+
@workspace_key = workspace_key.to_s.strip
|
|
37
|
+
@workspace_key = nil if @workspace_key.empty?
|
|
38
|
+
@base_url = (base_url.to_s.strip.empty? ? DEFAULT_BASE_URL : base_url.to_s.strip)
|
|
39
|
+
.sub(%r{/+$}, "") # strip trailing slash
|
|
40
|
+
|
|
41
|
+
# dashboard_url: use explicit value if provided, otherwise infer from base_url
|
|
42
|
+
explicit = dashboard_url.to_s.strip.sub(%r{/+$}, "")
|
|
43
|
+
@dashboard_url = explicit.empty? ? infer_dashboard_url(@base_url) : explicit
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Load from ~/.clacky/clacky_cloud.yml (returns an empty config if the file is absent)
|
|
47
|
+
def self.load(config_file = CONFIG_FILE)
|
|
48
|
+
if File.exist?(config_file)
|
|
49
|
+
data = YAML.safe_load(File.read(config_file)) || {}
|
|
50
|
+
new(
|
|
51
|
+
workspace_key: data["workspace_key"],
|
|
52
|
+
base_url: data["base_url"] || DEFAULT_BASE_URL,
|
|
53
|
+
dashboard_url: data["dashboard_url"]
|
|
54
|
+
)
|
|
55
|
+
else
|
|
56
|
+
new
|
|
57
|
+
end
|
|
58
|
+
rescue => e
|
|
59
|
+
# Corrupt file — return empty config rather than crash
|
|
60
|
+
warn "[clacky_cloud_config] Failed to load #{config_file}: #{e.message}"
|
|
61
|
+
new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Persist to ~/.clacky/clacky_cloud.yml
|
|
65
|
+
def save(config_file = CONFIG_FILE)
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(config_file))
|
|
67
|
+
File.write(config_file, to_yaml)
|
|
68
|
+
FileUtils.chmod(0o600, config_file)
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Serialize to YAML string
|
|
73
|
+
def to_yaml
|
|
74
|
+
data = { "base_url" => @base_url }
|
|
75
|
+
data["workspace_key"] = @workspace_key if @workspace_key
|
|
76
|
+
# Only persist dashboard_url when it differs from the inferred default,
|
|
77
|
+
# so the file stays minimal for users who don't need to override it.
|
|
78
|
+
inferred = infer_dashboard_url(@base_url)
|
|
79
|
+
data["dashboard_url"] = @dashboard_url if @dashboard_url != inferred
|
|
80
|
+
YAML.dump(data)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# True when a non-empty workspace_key is stored
|
|
84
|
+
def configured?
|
|
85
|
+
!@workspace_key.nil? && !@workspace_key.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Remove the saved file (used for reset / tests)
|
|
89
|
+
def self.clear!(config_file = CONFIG_FILE)
|
|
90
|
+
FileUtils.rm_f(config_file)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Derive the dashboard web-app URL from the API base_url.
|
|
94
|
+
#
|
|
95
|
+
# Mapping rules:
|
|
96
|
+
# https://api.clacky.ai -> https://app.clacky.ai
|
|
97
|
+
# https://<env>.api.clackyai.com -> https://<env>.app.clackyai.com
|
|
98
|
+
# http://localhost:<port> -> http://localhost:3001
|
|
99
|
+
# (anything else) -> https://app.clacky.ai (safe default)
|
|
100
|
+
private def infer_dashboard_url(api_url)
|
|
101
|
+
return DEFAULT_DASHBOARD_URL if api_url.nil? || api_url.strip.empty?
|
|
102
|
+
|
|
103
|
+
# Production: api.clacky.ai -> app.clacky.ai
|
|
104
|
+
return "https://app.clacky.ai" if api_url == "https://api.clacky.ai"
|
|
105
|
+
|
|
106
|
+
# Staging/dev on clackyai.com: <env>.api.clackyai.com -> <env>.app.clackyai.com
|
|
107
|
+
if api_url =~ %r{\Ahttps?://(.+)\.api\.clackyai\.com\z}
|
|
108
|
+
env_prefix = Regexp.last_match(1)
|
|
109
|
+
scheme = api_url.start_with?("https") ? "https" : "http"
|
|
110
|
+
return "#{scheme}://#{env_prefix}.app.clackyai.com"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Local development: localhost:<port> -> localhost:3001
|
|
114
|
+
if api_url =~ %r{\Ahttps?://localhost(:\d+)?\z}
|
|
115
|
+
scheme = api_url.start_with?("https") ? "https" : "http"
|
|
116
|
+
return "#{scheme}://localhost:3001"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fallback: return production dashboard
|
|
120
|
+
DEFAULT_DASHBOARD_URL
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -709,8 +709,21 @@ module Clacky
|
|
|
709
709
|
sleep 0.1
|
|
710
710
|
# Clear output area
|
|
711
711
|
ui_controller.layout.clear_output
|
|
712
|
+
# Cancel old idle timer before replacing agent to avoid stale-agent compression
|
|
713
|
+
idle_timer.cancel
|
|
712
714
|
# Clear session by creating a new agent
|
|
713
715
|
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: ui_controller, profile: agent.agent_profile.name, session_id: Clacky::SessionManager.generate_id, source: :manual)
|
|
716
|
+
# Rebuild idle timer bound to the new agent
|
|
717
|
+
idle_timer = Clacky::IdleCompressionTimer.new(
|
|
718
|
+
agent: agent,
|
|
719
|
+
session_manager: session_manager,
|
|
720
|
+
logger: ->(msg, level:) { ui_controller.log(msg, level: level) }
|
|
721
|
+
) do |success|
|
|
722
|
+
if success
|
|
723
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
724
|
+
end
|
|
725
|
+
ui_controller.set_idle_status
|
|
726
|
+
end
|
|
714
727
|
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
715
728
|
# Update session bar with reset values
|
|
716
729
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|