openclacky 1.2.10 → 1.2.13

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +56 -1
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/session_serializer.rb +1 -1
  8. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  9. data/lib/clacky/agent/skill_evolution.rb +23 -5
  10. data/lib/clacky/agent/skill_manager.rb +86 -1
  11. data/lib/clacky/agent/skill_reflector.rb +18 -23
  12. data/lib/clacky/agent/tool_registry.rb +10 -0
  13. data/lib/clacky/agent.rb +68 -23
  14. data/lib/clacky/agent_config.rb +59 -15
  15. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  16. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  17. data/lib/clacky/cli.rb +55 -0
  18. data/lib/clacky/client.rb +25 -3
  19. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  20. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  21. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  22. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  23. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  24. data/lib/clacky/idle_compression_timer.rb +1 -1
  25. data/lib/clacky/message_format/open_ai.rb +7 -1
  26. data/lib/clacky/message_history.rb +57 -0
  27. data/lib/clacky/openai_stream_aggregator.rb +30 -3
  28. data/lib/clacky/providers.rb +40 -12
  29. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  30. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  31. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  32. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  33. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  34. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  35. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  36. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  37. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  38. data/lib/clacky/server/http_server.rb +190 -10
  39. data/lib/clacky/server/session_registry.rb +34 -14
  40. data/lib/clacky/server/web_ui_controller.rb +24 -1
  41. data/lib/clacky/session_manager.rb +120 -0
  42. data/lib/clacky/tools/trash_manager.rb +1 -1
  43. data/lib/clacky/tools/web_search.rb +59 -8
  44. data/lib/clacky/ui2/layout_manager.rb +15 -5
  45. data/lib/clacky/ui2/progress_handle.rb +7 -1
  46. data/lib/clacky/ui2/ui_controller.rb +27 -0
  47. data/lib/clacky/ui_interface.rb +22 -0
  48. data/lib/clacky/utils/model_pricing.rb +96 -0
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +230 -7
  51. data/lib/clacky/web/app.js +6 -5
  52. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  53. data/lib/clacky/web/brand.js +22 -2
  54. data/lib/clacky/web/favicon.ico +0 -0
  55. data/lib/clacky/web/i18n.js +22 -4
  56. data/lib/clacky/web/index.html +6 -4
  57. data/lib/clacky/web/logo_nav_dark.png +0 -0
  58. data/lib/clacky/web/model-tester.js +8 -1
  59. data/lib/clacky/web/sessions.js +576 -120
  60. data/lib/clacky/web/settings.js +213 -51
  61. data/lib/clacky/web/skills.js +5 -14
  62. data/lib/clacky/web/theme.js +1 -0
  63. data/lib/clacky/web/utils.js +57 -0
  64. data/lib/clacky/web/ws-dispatcher.js +136 -0
  65. data/scripts/build/lib/gem.sh +9 -2
  66. data/scripts/build/src/install_full.sh.cc +2 -0
  67. data/scripts/build/src/uninstall.sh.cc +1 -1
  68. data/scripts/install.ps1 +19 -5
  69. data/scripts/install.sh +9 -2
  70. data/scripts/install_full.sh +11 -2
  71. data/scripts/install_rails_deps.sh +9 -2
  72. data/scripts/uninstall.sh +10 -3
  73. metadata +9 -2
data/lib/clacky/agent.rb CHANGED
@@ -104,6 +104,7 @@ module Clacky
104
104
  @pending_injections = [] # Pending inline skill injections to flush after observe()
105
105
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs to shred when agent.run completes
106
106
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
107
+ @last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
107
108
 
108
109
  # Compression tracking
109
110
  @compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
@@ -251,7 +252,7 @@ module Clacky
251
252
  @name = new_name.to_s.strip
252
253
  end
253
254
 
254
- def run(user_input, files: [])
255
+ def run(user_input, files: [], display_text: nil)
255
256
  # Show the "thinking" indicator as early as possible so the user gets
256
257
  # immediate feedback after sending a message. Without this the UI stays
257
258
  # silent during synchronous setup work (system prompt assembly, file
@@ -263,24 +264,33 @@ module Clacky
263
264
  # Start new task for Time Machine
264
265
  task_id = start_new_task
265
266
 
266
- @start_time = Time.now
267
- @task_truncation_count = 0 # Reset truncation counter for each task
268
- @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
269
- @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
270
- @task_cost_source = :estimated # Reset for new task
271
- # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
272
- # across tasks to correctly calculate delta tokens in each iteration
273
- @task_start_iterations = @iterations # Track starting iterations for this task
274
- @task_start_cost = @total_cost # Track starting cost for this task
275
- # Track cache stats for current task
276
- @task_cache_stats = {
277
- cache_creation_input_tokens: 0,
278
- cache_read_input_tokens: 0,
279
- prompt_tokens: 0,
280
- completion_tokens: 0,
281
- total_requests: 0,
282
- cache_hit_requests: 0
283
- }
267
+ # Continuation of a previously-interrupted task (e.g. user sent a
268
+ # supplementary message without stopping the running task) keeps the
269
+ # existing task-start snapshot so the completion summary accumulates
270
+ # iterations/cost/duration across the relay, instead of resetting and
271
+ # only counting the post-interrupt portion.
272
+ if @last_run_interrupted
273
+ @last_run_interrupted = false
274
+ else
275
+ @start_time = Time.now
276
+ @task_truncation_count = 0 # Reset truncation counter for each task
277
+ @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
278
+ @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
279
+ @task_cost_source = :estimated # Reset for new task
280
+ # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
281
+ # across tasks to correctly calculate delta tokens in each iteration
282
+ @task_start_iterations = @iterations # Track starting iterations for this task
283
+ @task_start_cost = @total_cost # Track starting cost for this task
284
+ # Track cache stats for current task
285
+ @task_cache_stats = {
286
+ cache_creation_input_tokens: 0,
287
+ cache_read_input_tokens: 0,
288
+ prompt_tokens: 0,
289
+ completion_tokens: 0,
290
+ total_requests: 0,
291
+ cache_hit_requests: 0
292
+ }
293
+ end
284
294
 
285
295
  # Deferred error rollback: if the previous session ended with an error,
286
296
  # trim history back to just before that failed user message now — at the
@@ -348,6 +358,7 @@ module Clacky
348
358
  end
349
359
 
350
360
  @history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f,
361
+ display_text: display_text,
351
362
  display_files: display_files.empty? ? nil : display_files })
352
363
  @total_tasks += 1
353
364
 
@@ -477,14 +488,34 @@ module Clacky
477
488
  # truncation pattern, we still won't silently exit while the model
478
489
  # is mid-tool_call.
479
490
  if response[:tool_calls].nil? || response[:tool_calls].empty?
480
- # [DIAG] Pin down exactly which sub-condition triggered the task exit.
491
+ content_str = response[:content].to_s
492
+ stripped = content_str.strip
493
+ ends_with_question = stripped.end_with?("?", "?")
494
+ finish_reason_str = response[:finish_reason].to_s
495
+ completion_tokens = response.dig(:token_usage, :completion_tokens)
496
+
481
497
  Clacky::Logger.info("agent.loop_break_normal",
482
498
  session_id: @session_id,
483
499
  iteration: @iterations,
484
500
  branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
485
- finish_reason: response[:finish_reason].to_s,
486
- tool_calls_count: (response[:tool_calls] || []).size
501
+ finish_reason: finish_reason_str,
502
+ tool_calls_count: (response[:tool_calls] || []).size,
503
+ completion_tokens: completion_tokens,
504
+ max_tokens: @config.max_tokens,
505
+ content_len: content_str.length,
506
+ content_ends_with_question: ends_with_question
487
507
  )
508
+
509
+ if finish_reason_str == "length"
510
+ Clacky::Logger.warn("agent.loop_break_on_length",
511
+ session_id: @session_id,
512
+ iteration: @iterations,
513
+ completion_tokens: completion_tokens,
514
+ max_tokens: @config.max_tokens,
515
+ content_len: content_str.length,
516
+ content_tail: content_str[-200, 200]
517
+ )
518
+ end
488
519
  if response[:content] && !response[:content].empty?
489
520
  emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
490
521
  end
@@ -502,6 +533,11 @@ module Clacky
502
533
  end
503
534
  end
504
535
 
536
+ # If the assistant ended its turn with a question, treat this as
537
+ # an in-flight conversation (agent is awaiting the user's reply)
538
+ # and skip skill evolution — the task isn't truly complete yet.
539
+ awaiting_user_feedback = true if ends_with_question
540
+
505
541
  break
506
542
  end
507
543
 
@@ -595,6 +631,11 @@ module Clacky
595
631
  @hooks.trigger(:on_complete, result)
596
632
  result
597
633
  rescue Clacky::AgentInterrupted
634
+ # Mark this run as interrupted so the next run() (e.g. user's
635
+ # supplementary message during a running task) keeps the existing
636
+ # task-start snapshot — the completion summary should reflect the
637
+ # entire task across the relay, not just the post-interrupt portion.
638
+ @last_run_interrupted = true
598
639
  # Let CLI handle the interrupt message
599
640
  raise
600
641
  rescue StandardError => e
@@ -620,6 +661,7 @@ module Clacky
620
661
  # Safety net: ensure any lingering progress spinner is stopped.
621
662
  # Normal paths close their own spinners; this guards against exceptions
622
663
  # raised between a progress slot's active/done pair.
664
+ Clacky::Logger.warn("[ph_debug] agent_run_ensure")
623
665
  @ui&.show_progress(phase: "done")
624
666
 
625
667
  # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
@@ -808,6 +850,7 @@ module Clacky
808
850
  # reasoning_content so every outgoing payload satisfies the provider's
809
851
  # "reasoning_content must be passed back" contract.
810
852
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
853
+ check_stale!
811
854
  @history.append(msg)
812
855
 
813
856
  # Close the thinking spinner before returning. The caller (run loop)
@@ -1199,7 +1242,7 @@ module Clacky
1199
1242
  # Skip malformed tool calls with nil name or arguments
1200
1243
  next if name.nil? || arguments.nil?
1201
1244
 
1202
- {
1245
+ formatted = {
1203
1246
  id: call[:id],
1204
1247
  type: call[:type] || "function",
1205
1248
  function: {
@@ -1207,6 +1250,8 @@ module Clacky
1207
1250
  arguments: arguments
1208
1251
  }
1209
1252
  }
1253
+ formatted[:extra_content] = call[:extra_content] if call[:extra_content]
1254
+ formatted
1210
1255
  end
1211
1256
 
1212
1257
  valid.any? ? valid : nil
@@ -615,14 +615,17 @@ module Clacky
615
615
  def find_model_by_type(type)
616
616
  kind = type.to_s
617
617
  if Clacky::Providers::MEDIA_KINDS.include?(kind)
618
- custom = @models.find { |m| m["type"] == kind }
619
- return custom if custom
620
- return derive_media_model(kind)
618
+ entry = @models.find { |m| m["type"] == kind }
619
+ return nil if entry && entry["disabled"]
620
+ if entry && entry["base_url"].to_s.strip != "" && entry["api_key"].to_s.strip != ""
621
+ return entry
622
+ end
623
+ return derive_media_model(kind, model_override: entry && entry["model"])
621
624
  end
622
625
  @models.find { |m| m["type"] == type }
623
626
  end
624
627
 
625
- private def derive_media_model(kind)
628
+ private def derive_media_model(kind, model_override: nil)
626
629
  default = find_model_by_type("default")
627
630
  return nil unless default
628
631
 
@@ -632,7 +635,16 @@ module Clacky
632
635
  )
633
636
  return nil unless provider_id
634
637
 
635
- model_name = Clacky::Providers.default_media_model(provider_id, kind)
638
+ if model_override && !model_override.to_s.strip.empty?
639
+ available = Clacky::Providers.media_models(provider_id, kind)
640
+ if available.include?(model_override)
641
+ model_name = model_override
642
+ else
643
+ model_name = Clacky::Providers.default_media_model(provider_id, kind)
644
+ end
645
+ else
646
+ model_name = Clacky::Providers.default_media_model(provider_id, kind)
647
+ end
636
648
  return nil if model_name.nil? || model_name.to_s.empty?
637
649
 
638
650
  {
@@ -662,35 +674,67 @@ module Clacky
662
674
  # "available" [Array<String>] — auto-source candidates from preset
663
675
  def media_state(kind)
664
676
  kind = kind.to_s
665
- custom = @models.find { |m| m["type"] == kind }
666
- auto = custom ? nil : derive_media_model(kind)
667
- entry = custom || auto
677
+ raw_entry = @models.find { |m| m["type"] == kind }
678
+
679
+ if raw_entry && raw_entry["disabled"]
680
+ default = find_model_by_type("default")
681
+ default_provider = default && Clacky::Providers.resolve_provider(
682
+ base_url: default["base_url"], api_key: default["api_key"]
683
+ )
684
+ available = default_provider ? Clacky::Providers.media_models(default_provider, kind) : []
685
+ aliases = default_provider ? Clacky::Providers.media_model_aliases(default_provider, kind) : {}
686
+ return {
687
+ "configured" => false,
688
+ "source" => "off",
689
+ "model" => nil,
690
+ "base_url" => nil,
691
+ "provider" => nil,
692
+ "available" => available,
693
+ "aliases" => aliases,
694
+ "stale" => false
695
+ }
696
+ end
697
+
698
+ is_custom = raw_entry &&
699
+ raw_entry["base_url"].to_s.strip != "" &&
700
+ raw_entry["api_key"].to_s.strip != ""
701
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
702
+
703
+ entry = if is_custom
704
+ raw_entry
705
+ else
706
+ derive_media_model(kind, model_override: override_model)
707
+ end
668
708
 
669
709
  provider_id = if entry
670
710
  Clacky::Providers.resolve_provider(
671
- base_url: entry["base_url"],
672
- api_key: entry["api_key"]
711
+ base_url: entry["base_url"], api_key: entry["api_key"]
673
712
  )
674
713
  end
675
714
 
676
- available_provider_id = if custom
715
+ available_provider_id = if is_custom
677
716
  provider_id
678
717
  else
679
718
  default = find_model_by_type("default")
680
719
  default && Clacky::Providers.resolve_provider(
681
- base_url: default["base_url"],
682
- api_key: default["api_key"]
720
+ base_url: default["base_url"], api_key: default["api_key"]
683
721
  )
684
722
  end
685
723
  available = available_provider_id ? Clacky::Providers.media_models(available_provider_id, kind) : []
724
+ aliases = available_provider_id ? Clacky::Providers.media_model_aliases(available_provider_id, kind) : {}
725
+
726
+ stale = !!(override_model && entry && entry["model"] != override_model)
686
727
 
687
728
  {
688
729
  "configured" => !entry.nil?,
689
- "source" => custom ? "custom" : (auto ? "auto" : "off"),
730
+ "source" => is_custom ? "custom" : (entry ? "auto" : "off"),
690
731
  "model" => entry && entry["model"],
691
732
  "base_url" => entry && entry["base_url"],
692
733
  "provider" => provider_id,
693
- "available" => available
734
+ "available" => available,
735
+ "aliases" => aliases,
736
+ "stale" => stale,
737
+ "requested_model" => stale ? override_model : nil
694
738
  }
695
739
  end
696
740
 
@@ -19,9 +19,16 @@ module Clacky
19
19
  @usage = {}
20
20
  @last_input_tokens = 0
21
21
  @last_output_tokens = 0
22
+ @parse_failures = 0
23
+ @frames_seen = 0
24
+ @bytes_seen = 0
22
25
  end
23
26
 
27
+ attr_reader :parse_failures, :frames_seen, :bytes_seen
28
+
24
29
  def handle(event, data_str)
30
+ @bytes_seen += data_str.to_s.bytesize
31
+ @frames_seen += 1
25
32
  data = parse_or_nil(data_str)
26
33
  return unless data
27
34
 
@@ -99,7 +106,16 @@ module Clacky
99
106
 
100
107
  private def parse_or_nil(s)
101
108
  JSON.parse(s)
102
- rescue JSON::ParserError
109
+ rescue JSON::ParserError => e
110
+ @parse_failures += 1
111
+ if @parse_failures == 1
112
+ Clacky::Logger.warn("stream.parse_failure",
113
+ provider: "anthropic",
114
+ error: "#{e.class}: #{e.message}",
115
+ frame_head: s.to_s[0, 200],
116
+ frame_bytes: s.to_s.bytesize
117
+ )
118
+ end
103
119
  nil
104
120
  end
105
121
 
@@ -29,9 +29,16 @@ module Clacky
29
29
  @usage = {}
30
30
  @last_input_tokens = 0
31
31
  @last_output_tokens = 0
32
+ @parse_failures = 0
33
+ @frames_seen = 0
34
+ @bytes_seen = 0
32
35
  end
33
36
 
37
+ attr_reader :parse_failures, :frames_seen, :bytes_seen
38
+
34
39
  def handle(event, data_str)
40
+ @bytes_seen += data_str.to_s.bytesize
41
+ @frames_seen += 1
35
42
  data = parse_or_nil(data_str)
36
43
  return unless data
37
44
 
@@ -101,7 +108,16 @@ module Clacky
101
108
 
102
109
  private def parse_or_nil(s)
103
110
  JSON.parse(s)
104
- rescue JSON::ParserError
111
+ rescue JSON::ParserError => e
112
+ @parse_failures += 1
113
+ if @parse_failures == 1
114
+ Clacky::Logger.warn("stream.parse_failure",
115
+ provider: "bedrock",
116
+ error: "#{e.class}: #{e.message}",
117
+ frame_head: s.to_s[0, 200],
118
+ frame_bytes: s.to_s.bytesize
119
+ )
120
+ end
105
121
  nil
106
122
  end
107
123
 
data/lib/clacky/cli.rb CHANGED
@@ -50,6 +50,7 @@ module Clacky
50
50
  option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Show detailed output"
51
51
  option :path, type: :string, desc: "Project directory path (defaults to current directory)"
52
52
  option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
53
+ option :fork, type: :string, desc: "Fork a session by number or session ID prefix (creates a copy)"
53
54
  option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
54
55
  option :attach, type: :string, aliases: "-a", desc: "Attach to session by number or keyword"
55
56
  option :json, type: :boolean, default: false, desc: "Output NDJSON to stdout (for scripting/piping)"
@@ -140,6 +141,9 @@ module Clacky
140
141
  elsif options[:attach]
141
142
  agent = load_session_by_number(client_factory.call, agent_config, session_manager, working_dir, options[:attach], profile: agent_profile)
142
143
  is_session_load = !agent.nil?
144
+ elsif options[:fork]
145
+ agent = fork_session(client_factory.call, agent_config, session_manager, working_dir, options[:fork], profile: agent_profile)
146
+ is_session_load = !agent.nil?
143
147
  end
144
148
 
145
149
  # Create new agent if no session loaded
@@ -549,8 +553,59 @@ module Clacky
549
553
  Clacky::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
550
554
  end
551
555
 
556
+ def fork_session(client, agent_config, session_manager, working_dir, identifier, profile:)
557
+ # Get a larger list to search through (for ID prefix matching)
558
+ sessions = session_manager.all_sessions(current_dir: working_dir, limit: 100)
559
+
560
+ if sessions.empty?
561
+ say "No sessions found.", :yellow
562
+ return nil
563
+ end
564
+
565
+ session_data = nil
566
+
567
+ # Same resolution logic as load_session_by_number
568
+ if identifier.match?(/^\d+$/) && identifier.to_i <= 99
569
+ index = identifier.to_i - 1
570
+ if index < 0 || index >= sessions.size
571
+ say "Invalid session number. Use -l to list available sessions.", :red
572
+ exit 1
573
+ end
574
+ session_data = sessions[index]
575
+ else
576
+ matching_sessions = sessions.select { |s| s[:session_id].start_with?(identifier) }
577
+ if matching_sessions.empty?
578
+ say "No session found matching ID prefix: #{identifier}", :red
579
+ say "Use -l to list available sessions.", :yellow
580
+ exit 1
581
+ elsif matching_sessions.size > 1
582
+ say "Multiple sessions found matching '#{identifier}':", :yellow
583
+ matching_sessions.each_with_index do |session, idx|
584
+ created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
585
+ s_id = session[:session_id][0..7]
586
+ name = session[:name].to_s.empty? ? "Unnamed session" : session[:name]
587
+ say " #{idx + 1}. [#{s_id}] #{created_at} - #{name}", :cyan
588
+ end
589
+ say "\nPlease use a more specific prefix.", :yellow
590
+ exit 1
591
+ else
592
+ session_data = matching_sessions.first
593
+ end
594
+ end
595
+
596
+ fork_data = session_manager.fork(session_data[:session_id])
597
+ return nil unless fork_data
598
+
599
+ # Fall back to CLI --agent flag for sessions that predate agent_profile
600
+ restored_profile = fork_data[:agent_profile].to_s
601
+ resolved_profile = restored_profile.empty? ? profile : restored_profile
602
+
603
+ Clacky::Agent.from_session(client, agent_config, fork_data, profile: resolved_profile)
604
+ end
605
+
552
606
  # Handle agent error/interrupt with cleanup
553
607
  def handle_agent_exception(ui_controller, agent, session_manager, exception)
608
+ Clacky::Logger.warn("[ph_debug] handle_agent_exception", klass: exception.class.name, msg: exception.message.to_s[0, 200])
554
609
  ui_controller.show_progress(phase: "done")
555
610
  ui_controller.set_idle_status
556
611
 
data/lib/clacky/client.rb CHANGED
@@ -260,6 +260,7 @@ module Clacky
260
260
  end
261
261
 
262
262
  result = aggregator.to_h
263
+ log_stream_summary("bedrock", aggregator, result["stopReason"])
263
264
  # A complete Converse stream always emits stopReason in its messageStop
264
265
  # frame. Its absence means the upstream cut the stream mid-response,
265
266
  # leaving a half-written message; retry rather than accept the truncation.
@@ -318,6 +319,7 @@ module Clacky
318
319
  end
319
320
 
320
321
  result = aggregator.to_h
322
+ log_stream_summary("anthropic", aggregator, result["stop_reason"])
321
323
  # A complete Messages stream always emits stop_reason in its message_delta
322
324
  # frame. Its absence means the upstream cut the stream mid-response,
323
325
  # leaving a half-written message; retry rather than accept the truncation.
@@ -380,6 +382,7 @@ module Clacky
380
382
  end
381
383
 
382
384
  result = aggregator.to_h
385
+ log_stream_summary("openai", aggregator, result.dig("choices", 0, "finish_reason"))
383
386
  # A complete chat-completion stream always terminates with a frame
384
387
  # carrying finish_reason. Its absence means the upstream cut the stream
385
388
  # mid-response (e.g. proxy idle-timeout, connection reset that Faraday
@@ -462,6 +465,24 @@ module Clacky
462
465
  "/model/#{model}/converse-stream"
463
466
  end
464
467
 
468
+ # Emit a one-line summary of a streaming response when something looks
469
+ # off (parse failures, missing terminal frame). No-op on the happy path
470
+ # to keep logs quiet.
471
+ private def log_stream_summary(provider, aggregator, terminal_marker)
472
+ parse_failures = aggregator.respond_to?(:parse_failures) ? aggregator.parse_failures.to_i : 0
473
+ missing_terminal = terminal_marker.nil?
474
+ return if parse_failures.zero? && !missing_terminal
475
+
476
+ Clacky::Logger.warn("stream.summary",
477
+ provider: provider,
478
+ frames_seen: aggregator.respond_to?(:frames_seen) ? aggregator.frames_seen : nil,
479
+ bytes_seen: aggregator.respond_to?(:bytes_seen) ? aggregator.bytes_seen : nil,
480
+ parse_failures: parse_failures,
481
+ saw_done: aggregator.respond_to?(:saw_done?) ? aggregator.saw_done? : nil,
482
+ terminal_marker_present: !missing_terminal
483
+ )
484
+ end
485
+
465
486
  # Pull complete SSE frames out of a buffer and yield them as (event, data).
466
487
  # An SSE frame ends at a blank line ("\n\n"); incomplete trailing data
467
488
  # stays in the buffer for the next chunk. Frames without an explicit
@@ -565,9 +586,10 @@ module Clacky
565
586
 
566
587
  error_body = JSON.parse(response.body) rescue nil
567
588
  {
568
- success: false,
569
- status: response.status,
570
- error: extract_error_message(error_body, response.body)
589
+ success: false,
590
+ status: response.status,
591
+ error: extract_error_message(error_body, response.body),
592
+ error_code: extract_error_code(error_body)
571
593
  }
572
594
  end
573
595
 
@@ -99,90 +99,95 @@ Ask:
99
99
 
100
100
  ### Feishu setup
101
101
 
102
- Feishu now offers a one-click **Agent App** (智能体应用) that auto-configures all
103
- required permissions, events, and publishing for you no Bot capability toggle,
104
- no permission JSON, no event subscription, no version/release steps. Just create
105
- the app and copy the credentials. The connection mode is unchanged (long
106
- connection / WebSocket), handled entirely by the server.
102
+ Use the setup script to create the Feishu app automatically via OAuth 2.0 Device Authorization Grant.
103
+ The user only needs to scan a QR code once.
107
104
 
108
- #### Step 1 — Open the Agent App creation page
105
+ #### Step 1 — Run setup script as a background session
109
106
 
110
- 1. Navigate: `open https://open.feishu.cn/page/launcher?from=backend_oneclick`. Pass `isolated: true`. If the browser is not configured (the `open` call fails), just give the user the URL and ask them to open it manually in any browser — the rest of the flow is fully manual and does not need browser automation.
111
- 2. If a login page or QR code is shown, tell the user to scan/log in and wait for "done".
107
+ ```
108
+ terminal(command: "ruby SKILL_DIR/feishu_setup.rb", background: true)
109
+ ```
112
110
 
113
- #### Step 2 Create the Agent App
111
+ Keep polling the session. The script will print:
112
+ - `SCAN_URL:<url>` — the QR code URL
113
+ - `EXPIRE_IN:<seconds>` — how long the URL is valid
114
114
 
115
- 3. After login, the page lands on **创建飞书智能体应用 (Create Feishu Agent App)**.
116
- Guide the user: "Enter an app name (e.g. Open Clacky), then click **立即创建 (Create Now)**. Reply done."
117
- (The avatar is auto-assigned at random and can be changed anytime it does not affect setup.)
118
- Wait for "done".
115
+ Once you see these lines, tell the user immediately:
116
+ - zh: "请在飞书中打开以下链接(或扫码)完成授权,链接 <expire_in> 秒内有效:\n<url>"
117
+ - en: "Open this link in Feishu (or scan the QR code) to authorize. Valid for <expire_in>s:\n<url>"
119
118
 
120
- #### Step 3 Copy credentials
119
+ Continue polling until the response contains an `exit_code`. When the session ends successfully, stdout will contain:
120
+ - `APP_ID:<app_id>`
121
+ - `APP_SECRET:<app_secret>`
121
122
 
122
- 4. The page jumps to **创建成功 (Created Successfully)**, showing `App ID` and `App Secret`.
123
- The Secret is masked by default. Guide the user: "Click the eye icon next to **App Secret** to reveal it,
124
- then copy both values and paste here. Reply with: App ID: xxx, App Secret: xxx"
125
- Wait for the reply. Parse `app_id` (starts with `cli_`) and `app_secret`. Trim whitespace and
126
- make sure the two values are not swapped.
123
+ Parse both values.
127
124
 
128
- #### Step 4 — Save credentials
125
+ #### Step 2 — Save credentials
129
126
 
130
- 5. Run:
131
- ```bash
132
- curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
133
- -H "Content-Type: application/json" \
134
- -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
135
- ```
136
- **CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
137
- or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
138
- and establishing the long connection.**
127
+ ```bash
128
+ curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
129
+ -H "Content-Type: application/json" \
130
+ -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
131
+ ```
132
+
133
+ **CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
134
+ or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
135
+ and establishing the long connection.**
136
+
137
+ On success: tell the user the following (zh), then **continue to Step 3 (Feishu CLI)**:
139
138
 
140
- On success: tell the user "✅ Feishu channel configured!" and **continue to Step 5 (Feishu CLI)**.
139
+ zh: "✅ 飞书通道已配置成功!现在你可以通过飞书与智能助手进行私聊和群聊,也支持阅读飞书文档。"
140
+ en: "✅ Feishu channel configured! You can now chat with the assistant via Feishu DMs or group chats, and read Feishu Docs."
141
141
 
142
142
  ---
143
143
 
144
- #### Step 5 — Optional: install Feishu CLI
144
+ #### Step 3 — Optional: install Feishu CLI
145
145
 
146
- Reach here after the channel is configured (Step 4 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
146
+ Reach here after the channel is configured (Step 2 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
147
147
 
148
148
  Call `request_user_feedback`:
149
149
 
150
150
  zh:
151
151
  ```json
152
152
  {
153
- \"question\": \"是否要安装「飞书 CLI」?装好之后 AI 可以帮你操作飞书云文档等能力。不装也 OK。\",
154
- "options": ["启用", "跳过"]
153
+ \"question\": \"是否安装飞书 CLI?安装后将解锁更多飞书能力,例如创建、编辑、删除云文档。\",
154
+ "options": ["安装", "跳过"]
155
155
  }
156
156
  ```
157
157
 
158
158
  en:
159
159
  ```json
160
160
  {
161
- "question": "Install Feishu CLI? With it, the AI can help you work with Feishu Docs and more. Skipping is fine.",
162
- "options": ["Enable", "Skip"]
161
+ "question": "Install Feishu CLI? It unlocks more Feishu capabilities, such as creating, editing, and deleting Docs.",
162
+ "options": ["Install", "Skip"]
163
163
  }
164
164
  ```
165
165
 
166
166
  If the user picks Skip, stop — setup is complete.
167
167
 
168
- If the user picks Enable, run:
168
+ If the user picks Enable, run the following **in order**:
169
169
 
170
+ **Step 3a** — Install and configure (single terminal call):
170
171
  ```bash
171
172
  lark-cli --version > /dev/null 2>&1 || npm install -g @larksuite/cli
172
173
  echo -n "<APP_SECRET>" | lark-cli config init --app-id <APP_ID> --app-secret-stdin --brand feishu
173
174
  ruby "SKILL_DIR/install_feishu_skills.rb"
174
- lark-cli auth login --recommend
175
175
  ```
176
176
 
177
- The last command blocks up to 10 minutes waiting for browser authorization make sure the runner's timeout is ≥ 600s.
177
+ **Step 3b** Start authorization as a background session:
178
+ ```
179
+ terminal(command: "lark-cli auth login --recommend", background: true)
180
+ ```
181
+
182
+ This returns a `session_id`. Keep polling with `terminal(session_id: <id>, input: "")` every few seconds.
178
183
 
179
- Once you see the authorization URL in the command's stdout, tell the user (do **not** wait for a reply — the CLI's blocking poll will return on its own when authorization completes):
184
+ Once you see the authorization URL appear in the output, tell the user immediately (do **not** wait for their reply):
180
185
  - zh: "请在浏览器中打开下方链接完成授权:\n<URL>"
181
186
  - en: "Open this URL in your browser to authorize:\n<URL>"
182
187
 
183
- **Do not kill and restart this command** restarting invalidates the device code and breaks the link the user already opened. The "hang" is just polling; wait it out.
188
+ Continue polling until the response contains an `exit_code` (meaning the session has ended). **Do not kill the session** restarting invalidates the device code.
184
189
 
185
- When `lark-cli auth login` returns successfully, tell the user:
190
+ When the session ends with `exit_code: 0`, tell the user:
186
191
  - zh: "✅ 飞书 CLI 已就绪。"
187
192
  - en: "✅ Feishu CLI is ready."
188
193