openclacky 0.9.29 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de10c61ed7d2c530c9bdacae8ddc625cfb969d98316f51f45e1833e87ed9b0cb
4
- data.tar.gz: 42787ba0901cfc544fde90b46e1a2b4ffe07ce6636bcccbf364141dce3fe0515
3
+ metadata.gz: 85678c695dcd96512aecef3a290beefa0f08b605a351da12c2db6072d6852147
4
+ data.tar.gz: 298820c8aabc0a13cb874fa577a5cf12757aa403b804c79d0fa804a5142ecbd0
5
5
  SHA512:
6
- metadata.gz: 7017db404d793f1cb9483a4a28119f252c8e2e0fd1f183bbe8edf6cf1d1fb5748be2225658dbb666e753499a6694be7c1027e4a448898f22298c5c9bc6d1e2e6
7
- data.tar.gz: 406a93802ab41cb68a8c098db3b0777583d888e19f84efa871549832216729ef59899f00512351727a2ceaf0ec209bfc2105061e9e7f050fcfbcca0cf6e50a64
6
+ metadata.gz: 1d291319e545f25c169341a14e54aa740d17107e2cd97ce39d9285ba7820ba69e3f10f14c15e26e579bfe71d471023be15a6e7ff225131fd77aeb9b916cbcc79
7
+ data.tar.gz: f89731bd9fc60ca68def237f4cbda3d998dfa306da9419e24b2c3f2dc6cf91c007a80e8bb658685a85d936dab58f246b55bbed7ead92637f0d2b857e645884af
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.30] - 2026-04-16
11
+
12
+ ### Added
13
+ - **OpenClacky provider support**: new built-in provider preset for OpenClacky API (https://api.openclacky.com) with Claude Opus 4.6, Sonnet 4.6/4.5, and Haiku 4.5 models
14
+ - **Session chunk index system**: compressed conversation chunks now include a searchable index with topics and turn counts — the agent can selectively load only relevant historical context instead of re-reading all compressed messages, dramatically reducing token usage in long sessions
15
+ - **Provider availability indicator**: Web UI now shows a real-time status badge (Available/Unavailable) next to each provider in the settings modal, helping users quickly identify which services are reachable
16
+
17
+ ### Improved
18
+ - **Progress streaming UX**: API call progress messages (e.g., "Agent is thinking...", compression updates) are now streamed incrementally to the Web UI with better visual feedback and reduced latency
19
+ - **Brand name localization**: brand skill metadata now includes configurable Chinese names (`name_zh`) for better display in localized UIs
20
+ - **Idle timer reliability**: fixed a race condition where old idle timers from previous CLI sessions could continue running after restarting, causing premature auto-saves
21
+
22
+ ### Fixed
23
+ - **Prompt caching in subagents**: subagent tool calls (e.g., skills invoked via `invoke_skill`) now correctly inherit and propagate prompt caching behavior from the parent agent, reducing redundant API costs
24
+ - **WeChat Work Ruby 3.1 compatibility**: fixed `Queue.empty?` crash on Ruby < 3.2 in WeCom channel WebSocket client (method was added in Ruby 3.2.0)
25
+ - **WeChat markdown stripping**: incoming messages from WeChat (Weixin) now preserve original text content when stripping markdown decorators, fixing message corruption where text was accidentally removed
26
+
10
27
  ## [0.9.29] - 2026-04-15
11
28
 
12
29
  ### Added
@@ -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(
@@ -167,7 +168,7 @@ module Clacky
167
168
  # Show compression info (use estimated tokens from rebuilt history)
168
169
  compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
169
170
  "level #{compression_context[:compression_level]})"
170
- @ui&.show_idle_status(phase: :end, message: compression_summary)
171
+ @ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
171
172
  end
172
173
 
173
174
  # Get recent messages while preserving tool_calls/tool_results pairs.
@@ -303,16 +304,21 @@ module Clacky
303
304
  # @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
304
305
  # @param chunk_index [Integer] Sequential chunk number
305
306
  # @param compression_level [Integer] Compression level
307
+ # @param topics [String, nil] Short topic description for chunk index card
306
308
  # @return [String, nil] Path to saved chunk file, or nil if save failed
307
- 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)
308
310
  return nil unless @session_id && @created_at
309
311
 
310
312
  # Messages being compressed = original minus system message minus recent messages
311
313
  # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
312
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).
313
319
  recent_set = recent_messages.to_a
314
320
  messages_to_archive = original_messages.reject do |m|
315
- m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
321
+ m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
316
322
  end
317
323
 
318
324
  return nil if messages_to_archive.empty?
@@ -324,7 +330,7 @@ module Clacky
324
330
  chunk_filename = "#{base_name}-chunk-#{chunk_index}.md"
325
331
  chunk_path = File.join(sessions_dir, chunk_filename)
326
332
 
327
- 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)
328
334
 
329
335
  File.write(chunk_path, md_content)
330
336
  FileUtils.chmod(0o600, chunk_path)
@@ -339,8 +345,9 @@ module Clacky
339
345
  # @param messages [Array<Hash>] Messages to render
340
346
  # @param chunk_index [Integer] Chunk number for metadata
341
347
  # @param compression_level [Integer] Compression level
348
+ # @param topics [String, nil] Short topic description extracted from LLM summary
342
349
  # @return [String] Markdown content
343
- def build_chunk_md(messages, chunk_index:, compression_level:)
350
+ def build_chunk_md(messages, chunk_index:, compression_level:, topics: nil)
344
351
  lines = []
345
352
 
346
353
  # Front matter
@@ -350,6 +357,7 @@ module Clacky
350
357
  lines << "compression_level: #{compression_level}"
351
358
  lines << "archived_at: #{Time.now.iso8601}"
352
359
  lines << "message_count: #{messages.size}"
360
+ lines << "topics: #{topics}" if topics
353
361
  lines << "---"
354
362
  lines << ""
355
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}#{skill.name_zh ? " (#{skill.name_zh})" : ""}")
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}#{skill.name_zh ? " (#{skill.name_zh})" : ""} [#{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)
@@ -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
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)
data/lib/clacky/client.rb CHANGED
@@ -114,12 +114,25 @@ module Clacky
114
114
 
115
115
  # ── Prompt-caching support ────────────────────────────────────────────────
116
116
 
117
- # Returns true for Claude 3.5+ models that support prompt caching.
117
+ # Returns true for Claude models that support prompt caching (gen 3.5+ or gen 4+).
118
+ #
119
+ # Handles both direct model names (e.g. "claude-haiku-4-5") and
120
+ # Clacky AI Bedrock proxy names with "abs-" prefix (e.g. "abs-claude-haiku-4-5").
121
+ #
122
+ # Why only Claude models:
123
+ # - MiniMax uses automatic server-side caching (no cache_control needed from client)
124
+ # - Kimi uses a proprietary prompt_cache_key param, not cache_control
125
+ # - MiMo has no documented caching API
126
+ # - Only Claude (direct, OpenRouter, or ClackyAI Bedrock proxy) consumes our
127
+ # cache_control / cachePoint markers
118
128
  def supports_prompt_caching?(model)
119
- model_str = model.to_s.downcase
129
+ # Strip ClackyAI Bedrock proxy prefix before matching
130
+ model_str = model.to_s.downcase.sub(/^abs-/, "")
120
131
  return false unless model_str.include?("claude")
121
132
 
122
- model_str.match?(/claude(?:-3[-.]?[5-9]|-[4-9]|-sonnet-[34])/)
133
+ # Match Claude gen 3.5+ (3.5/3.6/3.7…) or gen 4+ in any name format:
134
+ # claude-3.5-sonnet-... claude-3-7-sonnet claude-haiku-4-5 claude-sonnet-4-6
135
+ model_str.match?(/claude(?:-3[-.]?[5-9]|.*-[4-9][-.]|.*-[4-9]$|-[4-9][-.]|-[4-9]$|-sonnet-[34])/)
123
136
  end
124
137
 
125
138
 
@@ -97,10 +97,6 @@ module Clacky
97
97
  emit("info", message: message)
98
98
  end
99
99
 
100
- def show_idle_status(phase:, message:)
101
- emit("idle_status", phase: phase.to_s, message: message)
102
- end
103
-
104
100
  def show_warning(message)
105
101
  emit("warning", message: message)
106
102
  end
@@ -119,15 +115,25 @@ module Clacky
119
115
 
120
116
  # === Progress ===
121
117
 
122
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
123
- @progress_start_time = Time.now
124
- emit("progress", message: message, status: "start")
118
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
119
+ @progress_start_time = Time.now if phase == "active"
120
+
121
+ data = {
122
+ message: message,
123
+ progress_type: progress_type,
124
+ phase: phase,
125
+ status: phase == "active" ? "start" : "stop" # backward compat
126
+ }
127
+ data[:metadata] = metadata unless metadata.empty?
128
+ data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
129
+
130
+ emit("progress", **data)
131
+
132
+ @progress_start_time = nil if phase == "done"
125
133
  end
126
134
 
127
135
  def clear_progress
128
- elapsed = @progress_start_time ? (Time.now - @progress_start_time).round(1) : 0
129
- @progress_start_time = nil
130
- emit("progress", status: "stop", elapsed: elapsed)
136
+ show_progress(progress_type: "thinking", phase: "done")
131
137
  end
132
138
 
133
139
  # === State updates ===
@@ -12,6 +12,7 @@ module Clacky
12
12
  task_id created_at system_injected session_context memory_update
13
13
  subagent_instructions subagent_result token_usage
14
14
  compressed_summary chunk_path truncated transient
15
+ chunk_index chunk_count
15
16
  ].freeze
16
17
 
17
18
  def initialize(messages = [])
@@ -123,6 +124,13 @@ module Clacky
123
124
  msg&.dig(:session_date)
124
125
  end
125
126
 
127
+ # Return the chunk_count from the most recently injected chunk index message.
128
+ # Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.
129
+ def last_injected_chunk_count
130
+ msg = @messages.reverse.find { |m| m[:chunk_index] }
131
+ msg&.dig(:chunk_count) || 0
132
+ end
133
+
126
134
  # Return only real (non-system-injected) user messages.
127
135
  def real_user_messages
128
136
  @messages.select { |m| m[:role] == "user" && !m[:system_injected] }
@@ -87,11 +87,6 @@ module Clacky
87
87
  puts_line("[info] #{message}")
88
88
  end
89
89
 
90
- def show_idle_status(phase:, message:)
91
- # In plain mode, just print the final state
92
- puts_line("[info] #{message}") if phase.to_s == "end"
93
- end
94
-
95
90
  def show_warning(message)
96
91
  puts_line("[warn] #{message}")
97
92
  end
@@ -111,7 +106,7 @@ module Clacky
111
106
 
112
107
  # === Progress (no-ops — no spinner in plain mode) ===
113
108
 
114
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil); end
109
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
115
110
  def clear_progress; end
116
111
 
117
112
  # === State updates (no-ops) ===
@@ -11,13 +11,31 @@ module Clacky
11
11
  # - api: API type (anthropic-messages, openai-responses, openai-completions)
12
12
  # - default_model: Recommended default model
13
13
  PRESETS = {
14
+ "openclacky" => {
15
+ "name" => "OpenClacky",
16
+ "base_url" => "https://api.openclacky.com",
17
+ "api" => "bedrock",
18
+ "default_model" => "abs-claude-sonnet-4-5",
19
+ "lite_model" => "abs-claude-haiku-4-5",
20
+ "models" => [
21
+ "abs-claude-opus-4-6",
22
+ "abs-claude-sonnet-4-6",
23
+ "abs-claude-sonnet-4-5",
24
+ "abs-claude-haiku-4-5"
25
+ ],
26
+ # Fallback chain: if a model is unavailable, try the next one in order.
27
+ # Keys are primary model names; values are the fallback model to use instead.
28
+ "fallback_models" => {
29
+ "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
30
+ },
31
+ "website_url" => "https://www.openclacky.com/ai-keys"
32
+ }.freeze,
14
33
 
15
34
  "openrouter" => {
16
35
  "name" => "OpenRouter",
17
36
  "base_url" => "https://openrouter.ai/api/v1",
18
37
  "api" => "openai-responses",
19
38
  "default_model" => "anthropic/claude-sonnet-4-6",
20
- "lite_model" => "anthropic/claude-haiku-4-5",
21
39
  "models" => [], # Dynamic - fetched from API
22
40
  "website_url" => "https://openrouter.ai/keys"
23
41
  }.freeze,
@@ -49,15 +67,16 @@ module Clacky
49
67
  "website_url" => "https://console.anthropic.com/settings/keys"
50
68
  }.freeze,
51
69
 
52
- "clackyai" => {
53
- "name" => "ClackyAI",
70
+ "clackyai-sea" => {
71
+ "name" => "ClackyAI( Sea )",
54
72
  "base_url" => "https://api.clacky.ai",
55
73
  "api" => "bedrock",
56
- "default_model" => "abs-claude-sonnet-4-6",
74
+ "default_model" => "abs-claude-sonnet-4-5",
57
75
  "lite_model" => "abs-claude-haiku-4-5",
58
76
  "models" => [
59
77
  "abs-claude-opus-4-6",
60
78
  "abs-claude-sonnet-4-6",
79
+ "abs-claude-sonnet-4-5",
61
80
  "abs-claude-haiku-4-5"
62
81
  ],
63
82
  # Fallback chain: if a model is unavailable, try the next one in order.
@@ -361,7 +361,9 @@ module Clacky
361
361
 
362
362
  send_frame(cmd: cmd, req_id: req_id, body: body)
363
363
 
364
- result = queue.pop(timeout: 30)
364
+ timeout_thread = Thread.new { sleep 30; queue.push(nil) }
365
+ result = queue.pop
366
+ timeout_thread.kill
365
367
  raise "Timeout waiting for ack (req_id=#{req_id}, cmd=#{cmd})" if result.nil?
366
368
 
367
369
  errcode = result["errcode"] || result.dig("body", "errcode")
@@ -417,11 +417,11 @@ module Clacky
417
417
  r = text.dup
418
418
  r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
419
419
  r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
420
- r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '')
421
- r.gsub!(/\*\*([^*]+)\*\*/, '')
422
- r.gsub!(/\*([^*]+)\*/, '')
423
- r.gsub!(/__([^_]+)__/, '')
424
- r.gsub!(/_([^_]+)_/, '')
420
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\\1')
421
+ r.gsub!(/\*\*([^*]+)\*\*/, '\\1')
422
+ r.gsub!(/\*([^*]+)\*/, '\\1')
423
+ r.gsub!(/__([^_]+)__/, '\\1')
424
+ r.gsub!(/_([^_]+)_/, '\\1')
425
425
  r.gsub!(/^#+\s+/, "")
426
426
  r.gsub!(/^[-*_]{3,}\s*$/, "")
427
427
  r.strip
@@ -208,13 +208,25 @@ module Clacky
208
208
 
209
209
  # === Progress ===
210
210
 
211
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
212
- @progress_start_time = Time.now
211
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
212
+ @progress_start_time = Time.now if phase == "active"
213
213
  @live_progress_message = message
214
214
  # Reset stdout buffer for each new command so re-subscribe only replays current run
215
- @live_stdout_buffer = []
216
- emit("progress", message: message, status: "start")
215
+ @live_stdout_buffer = [] if phase == "active"
216
+
217
+ data = {
218
+ message: message,
219
+ progress_type: progress_type,
220
+ phase: phase,
221
+ status: phase == "active" ? "start" : "stop" # backward compat
222
+ }
223
+ data[:metadata] = metadata unless metadata.empty?
224
+ data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
225
+
226
+ emit("progress", **data)
217
227
  forward_to_subscribers { |sub| sub.show_progress(message) }
228
+
229
+ @progress_start_time = nil if phase == "done"
218
230
  end
219
231
 
220
232
  # Stream shell stdout/stderr lines to the browser while a command is running.
@@ -230,14 +242,10 @@ module Clacky
230
242
  end
231
243
 
232
244
  def clear_progress
233
- elapsed = @progress_start_time ? (Time.now - @progress_start_time).round(1) : 0
234
- @progress_start_time = nil
235
- @live_progress_message = nil
236
245
  @live_tool_call = nil # command finished — nothing left to replay
237
246
  # Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
238
247
  # This allows a brief replay window even after the command finishes.
239
- emit("progress", status: "stop", elapsed: elapsed)
240
- forward_to_subscribers { |sub| sub.clear_progress }
248
+ show_progress(progress_type: "thinking", phase: "done")
241
249
  end
242
250
 
243
251
  # Replay in-progress command state to a newly (re-)subscribing browser tab.
@@ -466,8 +466,7 @@ module Clacky
466
466
  # Show progress indicator with dynamic elapsed time
467
467
  # @param message [String] Progress message (optional, will use random thinking verb if nil)
468
468
  # @param prefix_newline [Boolean] Whether to add a blank line before progress (default: true)
469
- # @param output_buffer [Hash, nil] Shared output buffer for real-time command output (optional)
470
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
469
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
471
470
  # Stop any existing progress thread
472
471
  stop_progress_thread
473
472
 
@@ -476,7 +475,6 @@ module Clacky
476
475
 
477
476
  @progress_message = message || Clacky::THINKING_VERBS.sample
478
477
  @progress_start_time = Time.now
479
- @progress_output_buffer = output_buffer
480
478
  # Flag used by the progress thread to know when to stop gracefully.
481
479
  # Using a flag + join is safe because Thread#kill can interrupt a thread
482
480
  # while it holds @render_mutex, causing a permanent deadlock.
@@ -562,7 +560,6 @@ module Clacky
562
560
 
563
561
  # Signal thread to stop without joining — it will exit on next loop tick
564
562
  @progress_start_time = nil
565
- @progress_output_buffer = nil
566
563
  @stdout_lines = nil
567
564
  @progress_thread_stop = true
568
565
  # Detach: let the thread die on its own; we do NOT join here
@@ -594,7 +591,6 @@ module Clacky
594
591
  # @render_mutex) and leave the mutex permanently locked.
595
592
  def stop_progress_thread
596
593
  @progress_start_time = nil
597
- @progress_output_buffer = nil
598
594
  @progress_thread_stop = true
599
595
  if @progress_thread&.alive?
600
596
  # Join with a short timeout; fall back to kill only as a last resort
@@ -30,7 +30,11 @@ module Clacky
30
30
  def log(message, level: :info); end
31
31
 
32
32
  # === Progress ===
33
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil); end
33
+ # Unified progress indicator with type-based display customization.
34
+ # progress_type: "thinking" | "retrying" | "idle_compress" | custom
35
+ # phase: "active" | "done"
36
+ # metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
37
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
34
38
  def clear_progress; end
35
39
 
36
40
  # === State updates ===
@@ -39,12 +43,6 @@ module Clacky
39
43
  def set_working_status; end
40
44
  def set_idle_status; end
41
45
 
42
- # === Idle compression status ===
43
- # Emits a two-phase idle compression status update.
44
- # phase: :start → show "Idle detected. Compressing..." (with spinner)
45
- # phase: :end → update same element with final result (skipped / compressed)
46
- def show_idle_status(phase:, message:); end
47
-
48
46
  # === Blocking interaction ===
49
47
  def request_confirmation(message, default: true); end
50
48
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.29"
4
+ VERSION = "0.9.30"
5
5
  end
@@ -2012,6 +2012,14 @@ body {
2012
2012
  padding: 4px 0;
2013
2013
  }
2014
2014
 
2015
+ .skill-ac-empty {
2016
+ padding: 14px 16px;
2017
+ font-size: 13px;
2018
+ color: var(--color-text-tertiary, #888);
2019
+ text-align: left;
2020
+ font-style: italic;
2021
+ }
2022
+
2015
2023
  .skill-ac-item {
2016
2024
  display: flex;
2017
2025
  align-items: baseline;
@@ -292,6 +292,7 @@ function showConfirmModal(confId, message) {
292
292
  let _initialRestoreDone = false;
293
293
 
294
294
  WS.onEvent(ev => {
295
+ console.log("[DEBUG] WS event received:", ev.type, ev);
295
296
  switch (ev.type) {
296
297
 
297
298
  // ── Internal WS lifecycle ──────────────────────────────────────────
@@ -446,9 +447,17 @@ WS.onEvent(ev => {
446
447
  break;
447
448
 
448
449
  case "progress":
450
+ console.log("[DEBUG] progress event:", ev);
449
451
  if (ev.session_id !== Sessions.activeId) break;
450
- if (ev.status === "start") Sessions.showProgress(ev.message || I18n.t("chat.thinking"));
451
- else Sessions.clearProgress();
452
+ if (ev.phase === "active" || ev.status === "start") {
453
+ const progress_type = ev.progress_type || "thinking";
454
+ const metadata = ev.metadata || {};
455
+ console.log("[DEBUG] calling showProgress:", { message: ev.message, progress_type, metadata });
456
+ Sessions.showProgress(ev.message, progress_type, metadata);
457
+ } else {
458
+ console.log("[DEBUG] calling clearProgress:", ev.message);
459
+ Sessions.clearProgress(ev.message);
460
+ }
452
461
  break;
453
462
 
454
463
  case "complete":
@@ -471,17 +480,16 @@ WS.onEvent(ev => {
471
480
  break;
472
481
 
473
482
  // ── Info / errors ──────────────────────────────────────────────────
474
- case "idle_status":
475
- // Two-phase: "start" shows a spinner line, "end" updates it in-place
476
- Sessions.updateIdleStatus(ev.phase, ev.message);
477
- break;
478
-
479
483
  case "info":
480
484
  Sessions.appendInfo(ev.message);
481
485
  break;
482
486
 
483
487
  case "warning":
484
- Sessions.appendInfo("⚠ " + ev.message);
488
+ // Optimize retry messages for better UX
489
+ const friendlyWarning = _transformRetryWarning(ev.message);
490
+ if (friendlyWarning) {
491
+ Sessions.appendInfo(friendlyWarning);
492
+ }
485
493
  break;
486
494
 
487
495
  case "success":
@@ -1204,11 +1212,21 @@ const SkillAC = (() => {
1204
1212
 
1205
1213
  _items = scored.map(({ skill }) => skill);
1206
1214
 
1207
- if (_items.length === 0) { _hide(); return; }
1208
-
1209
1215
  const list = $("skill-autocomplete-list");
1210
1216
  list.innerHTML = "";
1211
1217
 
1218
+ if (_items.length === 0) {
1219
+ // Show empty state instead of hiding the dropdown
1220
+ const emptyEl = document.createElement("div");
1221
+ emptyEl.className = "skill-ac-empty";
1222
+ emptyEl.textContent = I18n.t("skills.ac.empty");
1223
+ list.appendChild(emptyEl);
1224
+ $("skill-autocomplete").style.display = "";
1225
+ _visible = true;
1226
+ _createOverlay();
1227
+ return;
1228
+ }
1229
+
1212
1230
  _items.forEach((skill, idx) => {
1213
1231
  const item = document.createElement("div");
1214
1232
  item.className = "skill-ac-item" + (idx === _activeIndex ? " active" : "");
@@ -35,6 +35,7 @@ const I18n = (() => {
35
35
  "chat.input.placeholder": "Message… (Enter to send, Shift+Enter for newline)",
36
36
  "chat.btn.send": "Send",
37
37
  "chat.thinking": "Thinking…",
38
+ "chat.retrying": "Retrying",
38
39
  "chat.history_load_failed": "Could not load history",
39
40
  "chat.history_start": "No more history",
40
41
  "chat.image_expired": "Expired",
@@ -188,6 +189,7 @@ const I18n = (() => {
188
189
  "skills.toggle.disableDesc": "AI can auto-invoke. Disable to prevent auto-triggering (manual use still available)",
189
190
  "skills.toggle.enableDesc": "AI cannot auto-invoke. Enable to allow auto-triggering",
190
191
  "skills.toggleError": "Error: ",
192
+ "skills.ac.empty": "No skills available",
191
193
  "skills.upload.uploading": "Uploading…",
192
194
  "skills.upload.uploaded": "Uploaded",
193
195
  "skills.upload.upload": "Upload",
@@ -362,6 +364,7 @@ const I18n = (() => {
362
364
  "chat.input.placeholder": "输入消息…(Enter 发送,Shift+Enter 换行)",
363
365
  "chat.btn.send": "发送",
364
366
  "chat.thinking": "思考中…",
367
+ "chat.retrying": "正在重试",
365
368
  "chat.history_load_failed": "历史记录加载失败",
366
369
  "chat.history_start": "没有更多历史了",
367
370
  "chat.image_expired": "已过期",
@@ -515,6 +518,7 @@ const I18n = (() => {
515
518
  "skills.toggle.disableDesc": "AI 可自动调用。关闭后 AI 不会主动触发(手动使用仍可用)",
516
519
  "skills.toggle.enableDesc": "AI 不会自动调用。开启后允许 AI 主动触发",
517
520
  "skills.toggleError": "错误:",
521
+ "skills.ac.empty": "暂无可用技能",
518
522
  "skills.upload.uploading": "上传中…",
519
523
  "skills.upload.uploaded": "已上传",
520
524
  "skills.upload.upload": "上传",
@@ -740,3 +744,56 @@ const I18n = (() => {
740
744
  // ── Public API ─────────────────────────────────────────────────────────────
741
745
  return { lang, setLang, t, applyAll };
742
746
  })();
747
+
748
+ // ── Thinking Verbs for Progress Animation ──────────────────────────────────
749
+ const THINKING_VERBS = {
750
+ en: [
751
+ "Cogitating",
752
+ "Pondering",
753
+ "Ruminating",
754
+ "Deliberating",
755
+ "Contemplating",
756
+ "Flibbertigibbeting",
757
+ "Percolating",
758
+ "Noodling",
759
+ "Brewing",
760
+ "Marinating",
761
+ "Stewing",
762
+ "Mulling",
763
+ "Processing",
764
+ "Computing",
765
+ "Calculating",
766
+ "Analyzing",
767
+ "Synthesizing",
768
+ "Ideating",
769
+ "Brainstorming",
770
+ "Reasoning"
771
+ ],
772
+ zh: [
773
+ "思考中",
774
+ "琢磨中",
775
+ "推敲中",
776
+ "酝酿中",
777
+ "沉思中",
778
+ "冥想中",
779
+ "盘算中",
780
+ "权衡中",
781
+ "构思中",
782
+ "揣摩中",
783
+ "运算中",
784
+ "分析中",
785
+ "综合中",
786
+ "理解中",
787
+ "头脑风暴中",
788
+ "推理中",
789
+ "整理思绪中",
790
+ "组织语言中"
791
+ ]
792
+ };
793
+
794
+ // Get a random thinking verb based on current language
795
+ function getRandomThinkingVerb() {
796
+ const lang = I18n.lang();
797
+ const verbs = THINKING_VERBS[lang] || THINKING_VERBS.en;
798
+ return verbs[Math.floor(Math.random() * verbs.length)];
799
+ }
@@ -1314,52 +1314,81 @@ const Sessions = (() => {
1314
1314
  _scrollToBottomIfNeeded(messages);
1315
1315
  },
1316
1316
 
1317
- // Two-phase idle compression status — renders as a single line that updates in-place.
1318
- // phase "start": create element with spinner; phase "end": update same element with result.
1319
- updateIdleStatus(phase, text) {
1320
- const messages = $("messages");
1321
- if (phase === "start") {
1322
- Sessions.collapseToolGroup();
1323
- const el = document.createElement("div");
1324
- el.className = "msg msg-info msg-idle-status";
1325
- el.textContent = "⟳ " + text;
1326
- messages.appendChild(el);
1327
- Sessions._idleStatusEl = el;
1328
- } else {
1329
- // phase "end" — update existing element or create a new one if start was missed
1330
- if (Sessions._idleStatusEl) {
1331
- Sessions._idleStatusEl.textContent = "· " + text;
1332
- Sessions._idleStatusEl.classList.add("msg-idle-done");
1333
- Sessions._idleStatusEl = null;
1334
- } else {
1335
- Sessions.appendInfo("· " + text);
1336
- }
1337
- }
1338
- _scrollToBottomIfNeeded(messages);
1339
- },
1340
-
1341
- _idleStatusEl: null,
1317
+ _progressEl: null,
1318
+ _progressInterval: null,
1319
+ _progressStartTime: null,
1320
+ _progressType: null,
1342
1321
 
1343
- showProgress(text) {
1322
+ showProgress(text, progress_type = "thinking", metadata = {}) {
1323
+ console.log("[DEBUG] showProgress called:", { text, progress_type, metadata });
1344
1324
  Sessions.clearProgress();
1325
+
1326
+ Sessions._progressType = progress_type;
1327
+ Sessions._progressStartTime = Date.now();
1328
+
1345
1329
  const messages = $("messages");
1346
1330
  const el = document.createElement("div");
1347
- el.className = "progress-msg";
1348
- el.textContent = "⟳ " + text;
1331
+ el.className = "progress-msg";
1332
+
1333
+ // Choose display text based on type
1334
+ let displayText;
1335
+ if (progress_type === "thinking") {
1336
+ displayText = text || getRandomThinkingVerb();
1337
+ console.log("[DEBUG] thinking verb:", displayText);
1338
+ } else if (progress_type === "retrying") {
1339
+ const { attempt, total } = metadata;
1340
+ // Show error reason + retry count
1341
+ if (text && attempt && total) {
1342
+ displayText = `${I18n.t("chat.retrying")}: ${text} (${attempt}/${total})`;
1343
+ } else if (attempt && total) {
1344
+ displayText = `${I18n.t("chat.retrying")} (${attempt}/${total})`;
1345
+ } else {
1346
+ displayText = text || I18n.t("chat.retrying");
1347
+ }
1348
+ } else if (progress_type === "idle_compress") {
1349
+ displayText = text || "Compressing...";
1350
+ } else {
1351
+ displayText = text || I18n.t("chat.thinking");
1352
+ }
1353
+
1354
+ el.textContent = "⟳ " + displayText;
1355
+ console.log("[DEBUG] appending progress element:", el.textContent);
1349
1356
  messages.appendChild(el);
1350
1357
  Sessions._progressEl = el;
1351
1358
  _scrollToBottomIfNeeded(messages);
1359
+
1360
+ // Start elapsed time counter (update every second)
1361
+ Sessions._progressInterval = setInterval(() => {
1362
+ const elapsed = Math.floor((Date.now() - Sessions._progressStartTime) / 1000);
1363
+ if (Sessions._progressEl) {
1364
+ Sessions._progressEl.textContent = `⟳ ${displayText}… (${elapsed}s)`;
1365
+ }
1366
+ }, 1000);
1352
1367
  },
1353
1368
 
1354
- clearProgress() {
1369
+ clearProgress(finalMessage = null) {
1370
+ console.log("[DEBUG] clearProgress called:", finalMessage);
1371
+ // Clear interval timer
1372
+ if (Sessions._progressInterval) {
1373
+ clearInterval(Sessions._progressInterval);
1374
+ Sessions._progressInterval = null;
1375
+ }
1376
+
1377
+ // Remove progress element
1355
1378
  if (Sessions._progressEl) {
1356
1379
  Sessions._progressEl.remove();
1357
1380
  Sessions._progressEl = null;
1358
1381
  }
1382
+
1383
+ // Show final message if provided (for idle_compress, etc.)
1384
+ if (finalMessage && Sessions._progressType !== "thinking") {
1385
+ Sessions.appendInfo(`· ${finalMessage}`);
1386
+ }
1387
+
1388
+ Sessions._progressStartTime = null;
1389
+ Sessions._progressType = null;
1359
1390
  },
1360
1391
 
1361
- _progressEl: null,
1362
-
1363
1392
  // ── Create ─────────────────────────────────────────────────────────────
1364
1393
 
1365
1394
  /** Create a new session and navigate to it. */
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.29
4
+ version: 0.9.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy