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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/docs/deploy-architecture.md +619 -0
  4. data/lib/clacky/agent/llm_caller.rb +14 -2
  5. data/lib/clacky/agent/message_compressor.rb +24 -6
  6. data/lib/clacky/agent/message_compressor_helper.rb +17 -10
  7. data/lib/clacky/agent/session_serializer.rb +69 -0
  8. data/lib/clacky/agent/skill_manager.rb +2 -2
  9. data/lib/clacky/agent.rb +3 -0
  10. data/lib/clacky/brand_config.rb +29 -3
  11. data/lib/clacky/clacky_auth_client.rb +152 -0
  12. data/lib/clacky/clacky_cloud_config.rb +123 -0
  13. data/lib/clacky/cli.rb +13 -0
  14. data/lib/clacky/client.rb +21 -7
  15. data/lib/clacky/cloud_project_client.rb +169 -0
  16. data/lib/clacky/default_agents/base_prompt.md +1 -0
  17. data/lib/clacky/default_parsers/doc_parser.rb +9 -9
  18. data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
  19. data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
  20. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
  21. data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
  22. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
  23. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
  24. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
  25. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
  26. data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
  27. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
  28. data/lib/clacky/default_skills/new/SKILL.md +117 -5
  29. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
  30. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
  31. data/lib/clacky/deploy_api_client.rb +484 -0
  32. data/lib/clacky/json_ui_controller.rb +16 -10
  33. data/lib/clacky/message_format/bedrock.rb +3 -2
  34. data/lib/clacky/message_history.rb +8 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -6
  36. data/lib/clacky/providers.rb +23 -4
  37. data/lib/clacky/server/browser_manager.rb +3 -1
  38. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
  39. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
  40. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
  41. data/lib/clacky/server/http_server.rb +12 -2
  42. data/lib/clacky/server/server_master.rb +43 -7
  43. data/lib/clacky/server/web_ui_controller.rb +17 -9
  44. data/lib/clacky/skill.rb +6 -2
  45. data/lib/clacky/tools/run_project.rb +4 -1
  46. data/lib/clacky/tools/shell.rb +7 -1
  47. data/lib/clacky/ui2/ui_controller.rb +1 -5
  48. data/lib/clacky/ui_interface.rb +5 -7
  49. data/lib/clacky/utils/arguments_parser.rb +22 -5
  50. data/lib/clacky/version.rb +1 -1
  51. data/lib/clacky/web/app.css +45 -5
  52. data/lib/clacky/web/app.js +126 -19
  53. data/lib/clacky/web/i18n.js +57 -0
  54. data/lib/clacky/web/sessions.js +108 -39
  55. data/lib/clacky/web/skills.js +8 -2
  56. data/lib/clacky.rb +3 -0
  57. 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&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
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&.show_warning("#{e.message} (#{retries}/#{current_max})")
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
- Your response MUST start with <analysis> or <summary> tags. No other format is acceptable.
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
- Follow the detailed compression prompt structure provided earlier. Focus on:
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 summary NOW. Remember: PURE TEXT response only, starting with <analysis> or <summary> tags.
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 <analysis> or <summary> tags as they provide semantic context
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
- content = content + anchor
142
+ content_without_topics = content_without_topics + anchor
125
143
  end
126
144
 
127
- [{ role: "assistant", content: content, compressed_summary: true, chunk_path: chunk_path }]
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&.show_idle_status(phase: :start, message: "Idle detected. Compressing conversation to optimize costs...")
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&.show_idle_status(phase: :end, message: "Idle skipped.")
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
- @ui&.show_info(
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)
@@ -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)