openclacky 1.5.6 → 1.5.8

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/README.md +2 -2
  7. data/README_CN.md +1 -1
  8. data/README_JA.md +1 -1
  9. data/Rakefile +15 -0
  10. data/docs/design-emit-and-background-skills.md +253 -0
  11. data/docs/mcp-architecture.md +48 -9
  12. data/docs/mcp.example.json +9 -0
  13. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  14. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  15. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  16. data/lib/clacky/agent/cost_tracker.rb +17 -2
  17. data/lib/clacky/agent/hook_manager.rb +14 -3
  18. data/lib/clacky/agent/memory_updater.rb +17 -18
  19. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  20. data/lib/clacky/agent/session_serializer.rb +136 -22
  21. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  22. data/lib/clacky/agent/skill_evolution.rb +11 -7
  23. data/lib/clacky/agent/skill_manager.rb +20 -18
  24. data/lib/clacky/agent/skill_reflector.rb +3 -6
  25. data/lib/clacky/agent.rb +113 -23
  26. data/lib/clacky/agent_config.rb +40 -18
  27. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  28. data/lib/clacky/cli.rb +4 -1
  29. data/lib/clacky/client.rb +1 -1
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
  32. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  33. data/lib/clacky/extension/api_extension.rb +11 -3
  34. data/lib/clacky/extension/api_loader.rb +4 -11
  35. data/lib/clacky/extension/packager.rb +1 -1
  36. data/lib/clacky/extension/verifier.rb +1 -1
  37. data/lib/clacky/fanout.rb +100 -0
  38. data/lib/clacky/locales/en.rb +2 -0
  39. data/lib/clacky/locales/zh.rb +2 -0
  40. data/lib/clacky/mcp/cli_commands.rb +61 -0
  41. data/lib/clacky/mcp/client.rb +18 -2
  42. data/lib/clacky/mcp/connection_manager.rb +85 -0
  43. data/lib/clacky/mcp/http_transport.rb +25 -9
  44. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  45. data/lib/clacky/mcp/oauth/config.rb +51 -0
  46. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  47. data/lib/clacky/mcp/oauth/session.rb +88 -0
  48. data/lib/clacky/media/generator.rb +3 -3
  49. data/lib/clacky/message_format/anthropic.rb +27 -13
  50. data/lib/clacky/message_history.rb +33 -2
  51. data/lib/clacky/providers.rb +13 -6
  52. data/lib/clacky/server/http_server.rb +102 -98
  53. data/lib/clacky/server/session_registry.rb +11 -15
  54. data/lib/clacky/server/web_ui_controller.rb +1 -1
  55. data/lib/clacky/session_manager.rb +21 -12
  56. data/lib/clacky/shell_hook_loader.rb +14 -8
  57. data/lib/clacky/tools/invoke_skill.rb +3 -2
  58. data/lib/clacky/tools/terminal.rb +11 -4
  59. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  60. data/lib/clacky/ui2/components/input_area.rb +1 -0
  61. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  62. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  63. data/lib/clacky/ui2/line_editor.rb +1 -0
  64. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  65. data/lib/clacky/ui2/progress_handle.rb +27 -3
  66. data/lib/clacky/ui2/ui_controller.rb +207 -6
  67. data/lib/clacky/ui_interface.rb +13 -3
  68. data/lib/clacky/utils/arguments_parser.rb +73 -4
  69. data/lib/clacky/utils/model_pricing.rb +21 -0
  70. data/lib/clacky/version.rb +1 -1
  71. data/lib/clacky/web/app.css +144 -213
  72. data/lib/clacky/web/components/chat-navigator.js +1 -1
  73. data/lib/clacky/web/features/billing/store.js +3 -3
  74. data/lib/clacky/web/features/billing/view.js +5 -61
  75. data/lib/clacky/web/features/extensions/store.js +1 -1
  76. data/lib/clacky/web/features/extensions/view.js +28 -5
  77. data/lib/clacky/web/features/new-session/view.js +43 -2
  78. data/lib/clacky/web/i18n.js +38 -22
  79. data/lib/clacky/web/index.html +19 -24
  80. data/lib/clacky/web/projects.js +96 -279
  81. data/lib/clacky/web/sessions.js +169 -119
  82. data/lib/clacky/web/settings.js +7 -1
  83. data/lib/clacky/web/ws-dispatcher.js +61 -34
  84. data/lib/clacky.rb +2 -0
  85. metadata +12 -1
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-markdown"
3
+ require "tty-screen"
4
4
  require_relative "theme_manager"
5
5
 
6
6
  module Clacky
@@ -14,6 +14,10 @@ module Clacky
14
14
  def render(content)
15
15
  return content if content.nil? || content.empty?
16
16
 
17
+ # tty-markdown pulls in rouge + kramdown (~0.4s). Server mode renders
18
+ # markdown in the browser, so only pay that cost on a real terminal render.
19
+ require "tty-markdown" unless defined?(TTY::Markdown)
20
+
17
21
  # Get current theme colors
18
22
  theme = ThemeManager.current_theme
19
23
 
@@ -131,6 +131,7 @@ module Clacky
131
131
  @metadata = {}
132
132
  @last_chunk_at = nil
133
133
  @monitor = Monitor.new
134
+ @closed_cond = @monitor.new_cond
134
135
  end
135
136
 
136
137
  # Start rendering. Registers with the owner (allocating an entry id
@@ -185,7 +186,10 @@ module Clacky
185
186
  snapshot = @monitor.synchronize do
186
187
  return if @unregistered
187
188
  first_close = @state == :running
188
- @state = :closed if first_close
189
+ if first_close
190
+ @state = :closed
191
+ @closed_cond.broadcast
192
+ end
189
193
  {
190
194
  first_close: first_close,
191
195
  message: final_message || @message,
@@ -205,6 +209,21 @@ module Clacky
205
209
  end
206
210
  alias_method :cancel, :finish
207
211
 
212
+ # Close without leaving any trace: the owner removes the entry instead
213
+ # of committing a final frame. For work whose outcome was "nothing to
214
+ # report", where a lingering line is pure noise.
215
+ def discard
216
+ @monitor.synchronize do
217
+ return if @unregistered
218
+ @state = :closed
219
+ @closed_cond.broadcast
220
+ end
221
+
222
+ stop_ticker
223
+ @owner.unregister_progress(self, final_frame: nil)
224
+ @monitor.synchronize { @unregistered = true }
225
+ end
226
+
208
227
  # True while the ticker thread is alive.
209
228
  def ticker_alive?
210
229
  t = @ticker
@@ -261,8 +280,13 @@ module Clacky
261
280
  Thread.current.name = "progress-ticker-#{object_id}"
262
281
  begin
263
282
  loop do
264
- sleep @tick_interval
265
- break if @monitor.synchronize { @state != :running }
283
+ # wait, not sleep: finish/discard signals the condition so a
284
+ # long tick_interval never delays shutdown.
285
+ closed = @monitor.synchronize do
286
+ @closed_cond.wait(@tick_interval) if @state == :running
287
+ @state != :running
288
+ end
289
+ break if closed
266
290
  render_now
267
291
  end
268
292
  rescue StandardError
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pastel"
3
4
  require_relative "layout_manager"
4
5
  require_relative "view_renderer"
5
6
  require_relative "progress_handle"
@@ -226,6 +227,9 @@ module Clacky
226
227
  # trigger a full output repaint (visible flicker) and the visual
227
228
  # order would have business messages appearing below the spinner.
228
229
  def append_output(content)
230
+ return nil if Thread.current[:clacky_chore_phase]
231
+
232
+ content = prefix_with_phase_label(content)
229
233
  @progress_mutex.synchronize do
230
234
  top = @progress_stack.last
231
235
  if top && top.entry_id
@@ -249,6 +253,17 @@ module Clacky
249
253
  @layout.append_output(content)
250
254
  end
251
255
 
256
+ # With several phases running at once the CLI's line-oriented output
257
+ # interleaves, so each line needs its origin stamped on it. A single
258
+ # active phase reads fine unprefixed (the banners already delimit it).
259
+ private def prefix_with_phase_label(content)
260
+ label = Thread.current[:clacky_phase_label]
261
+ return content unless label
262
+
263
+ tag = "[#{label}] "
264
+ content.to_s.split("\n", -1).map { |line| line.empty? ? line : "#{tag}#{line}" }.join("\n")
265
+ end
266
+
252
267
  # Log message to output area (use instead of puts)
253
268
  # @param message [String] Message to log
254
269
  # @param level [Symbol] Log level (:debug, :info, :warning, :error)
@@ -392,6 +407,8 @@ module Clacky
392
407
  # @param path [String] File path
393
408
  # @param is_new_file [Boolean] Whether this is a new file
394
409
  def show_file_write_preview(path, is_new_file:)
410
+ return if chore_capture(:file, path)
411
+
395
412
  theme = ThemeManager.current_theme
396
413
  file_label = theme.format_symbol(:file)
397
414
  status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
@@ -402,6 +419,8 @@ module Clacky
402
419
  # Show file operation preview (Edit tool)
403
420
  # @param path [String] File path
404
421
  def show_file_edit_preview(path)
422
+ return if chore_capture(:file, path)
423
+
405
424
  theme = ThemeManager.current_theme
406
425
  file_label = theme.format_symbol(:file)
407
426
  append_output("\n#{file_label} #{path || '(unknown)'}")
@@ -410,6 +429,8 @@ module Clacky
410
429
  # Show file operation error
411
430
  # @param error_message [String] Error message
412
431
  def show_file_error(error_message)
432
+ return if chore_capture(:error, error_message)
433
+
413
434
  theme = ThemeManager.current_theme
414
435
  append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
415
436
  end
@@ -453,10 +474,69 @@ module Clacky
453
474
  filtered.strip
454
475
  end
455
476
 
477
+ # Record a noteworthy event that happened inside a chore phase. The raw
478
+ # transcript is suppressed (a line-oriented terminal has no folding),
479
+ # but the CLI still owes the user a digest — the collapsed-card
480
+ # equivalent of what the web UI lets you expand.
481
+ #
482
+ # @return [Boolean] true if a chore phase absorbed it.
483
+ private def chore_capture(kind, text)
484
+ return false unless Thread.current[:clacky_chore_phase]
485
+ text = text.to_s.strip
486
+ return true if text.empty?
487
+
488
+ @phase_mutex ||= Mutex.new
489
+ @phase_mutex.synchronize do
490
+ @chore_digest ||= { tools: Hash.new(0), files: [], errors: [] }
491
+ case kind
492
+ when :tool then @chore_digest[:tools][text] += 1
493
+ when :file then @chore_digest[:files] << text unless @chore_digest[:files].include?(text)
494
+ when :error then @chore_digest[:errors] << text
495
+ end
496
+ end
497
+ true
498
+ end
499
+
500
+ # Turn the captured digest into the handful of lines the CLI prints
501
+ # after a chore finishes. Errors are always listed in full; tools and
502
+ # files are condensed, since a memory update touching 20 files should
503
+ # not out-scroll the answer the user actually asked for.
504
+ private def chore_digest_lines
505
+ @phase_mutex ||= Mutex.new
506
+ digest = @phase_mutex.synchronize { @chore_digest }
507
+ return [] if digest.nil?
508
+
509
+ lines = []
510
+ digest[:files].first(CHORE_DIGEST_MAX_FILES).each { |f| lines << "· #{f}" }
511
+ if (extra = digest[:files].size - CHORE_DIGEST_MAX_FILES) > 0
512
+ lines << "· …#{extra} more file#{"s" if extra > 1}"
513
+ end
514
+
515
+ if digest[:tools].any?
516
+ total = digest[:tools].values.sum
517
+ ranked = digest[:tools].sort_by { |_n, c| -c }
518
+ names = ranked.first(CHORE_DIGEST_MAX_TOOLS).map do |name, count|
519
+ count > 1 ? "#{name}×#{count}" : name
520
+ end
521
+ if (extra = ranked.size - CHORE_DIGEST_MAX_TOOLS) > 0
522
+ names << "+#{extra} more"
523
+ end
524
+ lines << "· #{total} tool call#{"s" if total > 1}: #{names.join(", ")}"
525
+ end
526
+
527
+ digest[:errors].first(CHORE_DIGEST_MAX_ERRORS).each { |e| lines << "! #{e}" }
528
+ if (extra = digest[:errors].size - CHORE_DIGEST_MAX_ERRORS) > 0
529
+ lines << "! …#{extra} more error#{"s" if extra > 1}"
530
+ end
531
+ lines
532
+ end
533
+
456
534
  # Show tool call
457
535
  # @param name [String] Tool name
458
536
  # @param args [String, Hash] Tool arguments (JSON string or Hash)
459
537
  def show_tool_call(name, args)
538
+ return if chore_capture(:tool, name)
539
+
460
540
  # Reset stdout buffer on each new tool call so previous command output
461
541
  # doesn't bleed into the next one, and so the buffer is ready before
462
542
  # on_output starts firing (which can happen before show_progress is called).
@@ -506,6 +586,8 @@ module Clacky
506
586
  # @param error [String, Exception] Error message or exception
507
587
  def show_tool_error(error)
508
588
  error_msg = error.is_a?(Exception) ? error.message : error.to_s
589
+ return if chore_capture(:error, error_msg)
590
+
509
591
  output = @renderer.render_tool_error(error: error_msg)
510
592
  append_output(output)
511
593
  end
@@ -655,6 +737,22 @@ module Clacky
655
737
 
656
738
  # Called by ProgressHandle#start.
657
739
  def register_progress(handle)
740
+ # Inside a chore phase only the phase's own indicator occupies a line.
741
+ # Nested handles (the subagent's LLM calls and tool runs) get no entry
742
+ # of their own — otherwise each one commits a permanent final frame on
743
+ # finish, since unregister_progress writes straight into the buffer and
744
+ # never passes through append_output's chore gate. Their activity is
745
+ # instead folded into the chore line by +fold_into_chore_line+.
746
+ if Thread.current[:clacky_chore_phase] && @chore_progress
747
+ @progress_mutex.synchronize do
748
+ @chore_nested ||= []
749
+ @chore_nested << handle
750
+ @chore_steps = @chore_steps.to_i + 1 if handle.style == :primary
751
+ fold_into_chore_line(handle)
752
+ end
753
+ return nil
754
+ end
755
+
658
756
  @progress_mutex.synchronize do
659
757
  prev_top = @progress_stack.last
660
758
  if prev_top
@@ -675,6 +773,17 @@ module Clacky
675
773
  # Called by ProgressHandle#finish.
676
774
  def unregister_progress(handle, final_frame:)
677
775
  @progress_mutex.synchronize do
776
+ if @chore_nested&.delete(handle)
777
+ # Folded handles own no entry; hand the chore line back to
778
+ # whatever is still running underneath (or the bare label).
779
+ fold_into_chore_line(@chore_nested.last)
780
+ next
781
+ end
782
+
783
+ # A handle that was never stacked owns nothing here: restoring
784
+ # "the new top" would duplicate what is legitimately on screen.
785
+ next unless @progress_stack.include?(handle)
786
+
678
787
  if handle.entry_id
679
788
  if final_frame && !final_frame.to_s.strip.empty?
680
789
  update_entry(handle.entry_id, @renderer.render_progress(final_frame))
@@ -705,6 +814,19 @@ module Clacky
705
814
  # +register_progress+).
706
815
  def render_frame(handle, frame)
707
816
  @progress_mutex.synchronize do
817
+ if @chore_nested&.include?(handle)
818
+ fold_into_chore_line(handle)
819
+ return
820
+ end
821
+
822
+ # The chore's own ticker must render the folded view too, otherwise
823
+ # every tick repaints the bare label and the line flickers between
824
+ # "<label>…" and "<label> · <activity>…".
825
+ if @chore_progress && @chore_progress == handle && @chore_nested&.any?
826
+ fold_into_chore_line(@chore_nested.last)
827
+ return
828
+ end
829
+
708
830
  return unless @progress_stack.last == handle
709
831
  return unless handle.entry_id
710
832
 
@@ -726,6 +848,27 @@ module Clacky
726
848
  end
727
849
  end
728
850
 
851
+ # Repaint the chore phase's single line to reflect the innermost nested
852
+ # activity: "<label> · <what it's doing> (Ns · step K)". Keeps the user
853
+ # informed without letting the chore's subagent spam one permanent line
854
+ # per LLM call. Must be called with @progress_mutex held.
855
+ private def fold_into_chore_line(inner)
856
+ chore = @chore_progress
857
+ return unless chore&.entry_id
858
+
859
+ parts = [chore.message]
860
+ parts << inner.message.to_s.strip if inner && !inner.message.to_s.strip.empty?
861
+
862
+ suffix = []
863
+ elapsed = chore.start_time ? (Time.now - chore.start_time).to_i : 0
864
+ suffix << "#{elapsed}s" if elapsed > 0
865
+ suffix << "step #{@chore_steps}" if @chore_steps.to_i > 0
866
+
867
+ line = parts.join(" · ")
868
+ line = "#{line}… (#{suffix.join(" · ")})" unless suffix.empty?
869
+ update_entry(chore.entry_id, @renderer.render_progress(line))
870
+ end
871
+
729
872
  # Render the very first frame of +handle+ (used when registering or
730
873
  # restoring). Mirrors +render_frame+'s formatting minus the implicit
731
874
  # elapsed-time tick (handle hasn't ticked yet).
@@ -888,28 +1031,86 @@ module Clacky
888
1031
  append_output(output)
889
1032
  end
890
1033
 
891
- def phase_start(kind:, label:)
1034
+ # Phases the user never asked for: post-task chores that run after the
1035
+ # answer is already on screen. The web UI folds them into a card; a
1036
+ # line-oriented terminal has no folding, so their transcript is
1037
+ # swallowed and only a one-line progress indicator remains.
1038
+ CHORE_PHASE_KINDS = %w[memory_update skill_evolution].freeze
1039
+
1040
+ # Caps for the post-chore digest. A chore is background work the user
1041
+ # never asked for, so its recap must stay far shorter than the answer.
1042
+ CHORE_DIGEST_MAX_FILES = 5
1043
+ CHORE_DIGEST_MAX_TOOLS = 4
1044
+ CHORE_DIGEST_MAX_ERRORS = 3
1045
+
1046
+ def phase_start(kind:, label:, concurrent: false)
892
1047
  phase_id = SecureRandom.uuid
893
- @active_phases ||= {}
894
- @active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now }
1048
+ chore = CHORE_PHASE_KINDS.include?(kind.to_s) && !verbose_phases?
1049
+ @phase_mutex ||= Mutex.new
1050
+ @phase_mutex.synchronize do
1051
+ @active_phases ||= {}
1052
+ @active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now, chore: chore }
1053
+ end
895
1054
  Thread.current[:clacky_phase_id] = phase_id
896
1055
 
1056
+ if chore
1057
+ # Start the indicator before arming the gate: this handle is the
1058
+ # one thing that stays visible during the chore.
1059
+ @chore_progress = start_progress(message: label, style: :quiet)
1060
+ Thread.current[:clacky_chore_phase] = phase_id
1061
+ return phase_id
1062
+ end
1063
+
897
1064
  banner = "──────── ▼ #{label} ────────"
898
1065
  append_output(@renderer.render_system_message(banner, prefix_newline: true))
1066
+ # Only concurrent phases interleave, so only they need per-line tagging.
1067
+ Thread.current[:clacky_phase_label] = concurrent ? label : nil
899
1068
  phase_id
900
1069
  end
901
1070
 
902
1071
  def phase_end(phase_id, summary: nil)
903
- Thread.current[:clacky_phase_id] = nil
904
- return unless @active_phases&.key?(phase_id)
1072
+ Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
1073
+ Thread.current[:clacky_phase_label] = nil
1074
+ @phase_mutex ||= Mutex.new
1075
+ info = @phase_mutex.synchronize { @active_phases&.delete(phase_id) }
1076
+ return unless info
905
1077
 
906
- info = @active_phases.delete(phase_id)
907
1078
  label = info[:label]
1079
+
1080
+ if info[:chore]
1081
+ Thread.current[:clacky_chore_phase] = nil
1082
+ handle = @chore_progress
1083
+ @chore_progress = nil
1084
+ @chore_nested = nil
1085
+ @chore_steps = 0
1086
+ handle&.discard
1087
+
1088
+ digest = chore_digest_lines
1089
+ @phase_mutex.synchronize { @chore_digest = nil }
1090
+
1091
+ # No summary and nothing captured means nothing worth reporting
1092
+ # happened (the common "no memory updates needed" path) — leave no
1093
+ # trace at all.
1094
+ has_summary = summary && !summary.to_s.strip.empty?
1095
+ return if !has_summary && digest.empty?
1096
+
1097
+ elapsed = (Time.now - info[:started_at]).round
1098
+ head = has_summary ? "#{label} — #{summary.to_s.strip} (#{elapsed}s)" : "#{label} (#{elapsed}s)"
1099
+ append_output(@renderer.render_system_message(head, prefix_newline: false))
1100
+ theme = ThemeManager.current_theme
1101
+ digest.each { |line| append_output(theme.format_text(" #{line}", :thinking)) }
1102
+ return
1103
+ end
1104
+
908
1105
  tail = summary && !summary.to_s.strip.empty? ? " — #{summary.to_s.strip}" : ""
909
1106
  banner = "──────── ▲ #{label} done#{tail} ────────"
910
1107
  append_output(@renderer.render_system_message(banner, prefix_newline: false))
911
1108
  end
912
1109
 
1110
+ private def verbose_phases?
1111
+ @config.respond_to?(:verbose) && @config.verbose
1112
+ end
1113
+
913
1114
  # Set workspace status to idle (called when agent stops working)
914
1115
  def set_idle_status
915
1116
  # Safety net: close any legacy progress slots that were opened via
@@ -159,19 +159,29 @@ module Clacky
159
159
  # === Lifecycle ===
160
160
  def stop(clear_screen: false); end
161
161
 
162
+ # === Custom events (extension bus) ===
163
+ # Push an arbitrary typed event to the UI transport. Streaming UIs (web,
164
+ # json) override this; UIs with no event channel (CLI, null) drop it.
165
+ #
166
+ # Extensions must namespace `type` as "ext.<extension>.<event>" so custom
167
+ # events never collide with the built-in protocol. The web UI forwards
168
+ # namespaced events straight to the `Clacky.ext` bus, where a subscriber
169
+ # receives them under that same name.
170
+ def emit(type, **data); end
171
+
162
172
  # === Phase grouping (optional, web UI uses this to fold subagent runs) ===
163
173
  # Begin a logical phase. Events emitted between phase_start and phase_end
164
174
  # carry the phase_id so the UI can group them visually.
165
175
  # Returns the phase_id (caller is responsible for passing it to phase_end).
166
- def phase_start(kind:, label: nil)
176
+ def phase_start(kind:, label: nil, concurrent: false)
167
177
  SecureRandom.uuid
168
178
  end
169
179
 
170
180
  def phase_end(phase_id, summary: nil); end
171
181
 
172
182
  # Run block within a phase. Always closes via ensure.
173
- def with_phase(kind:, label: nil)
174
- pid = phase_start(kind: kind, label: label)
183
+ def with_phase(kind:, label: nil, concurrent: false)
184
+ pid = phase_start(kind: kind, label: label, concurrent: concurrent)
175
185
  begin
176
186
  yield pid
177
187
  ensure
@@ -88,12 +88,78 @@ module Clacky
88
88
  result
89
89
  end
90
90
 
91
+ # Undo one layer of accidental double-serialization, guided by the tool's
92
+ # parameter schema.
93
+ #
94
+ # Some LLMs (e.g. glm-5.2) emit array/object parameters as a JSON *string*
95
+ # (e.g. {"task":"[\"a\",\"b\"]"}) instead of a native JSON value. JSON.parse
96
+ # only strips the outer layer, so such values arrive here as a literal
97
+ # String and break downstream tools that expect an Array/Hash.
98
+ #
99
+ # To stay safe we rely on the declared schema type instead of a blind
100
+ # "looks like JSON" heuristic:
101
+ # - a parameter declared `type: "string"` is NEVER parsed, so a value
102
+ # that merely happens to be valid JSON text (file content, a code
103
+ # snippet, a JSON blob to be written) is preserved verbatim;
104
+ # - a parameter declared `type: "array"`/`"object"` that still arrives
105
+ # as a String is unwrapped exactly once;
106
+ # - additionally, when a concrete type IS declared, the parsed result
107
+ # must match it (an array-typed param that receives a JSON object
108
+ # string is left untouched rather than silently coerced to a Hash);
109
+ # - a parameter with no declared `type` (e.g. flexible params such as
110
+ # todo_manager's task/id, which may be a scalar or a list) falls back
111
+ # to a conservative heuristic: unwrap only if the stripped value
112
+ # starts with "["/"{" and parses cleanly.
113
+ # Strings that fail JSON.parse are always left untouched.
114
+ def self.undouble_serialize_args(args, properties = {})
115
+ args.to_h do |key, value|
116
+ expected = schema_type(properties, key)
117
+ # Explicit string params: never unwrap (protects content / code / JSON text).
118
+ next [key, value] if expected == "string"
119
+ # Scalar-typed params (integer/number/boolean): no JSON shape to recover.
120
+ next [key, value] if expected && expected != "array" && expected != "object"
121
+ # For array/object/untyped params, only unwrap a String that looks like JSON.
122
+ next [key, value] unless value.is_a?(String)
123
+ stripped = value.strip
124
+ next [key, value] unless stripped.start_with?("[") || stripped.start_with?("{")
125
+ begin
126
+ # symbolize_names keeps unwrapped Hash keys consistent with the
127
+ # outer JSON.parse(call[:arguments], symbolize_names: true).
128
+ parsed = JSON.parse(value, symbolize_names: true)
129
+ # Type-match guard: if the schema declares a concrete type, the
130
+ # parsed value must match it. Prevents silently turning a Hash into
131
+ # an Array (or vice-versa) when an array-typed param receives a
132
+ # JSON object string, etc.
133
+ if expected == "array" && !parsed.is_a?(Array)
134
+ next [key, value]
135
+ elsif expected == "object" && !parsed.is_a?(Hash)
136
+ next [key, value]
137
+ end
138
+ [key, parsed]
139
+ rescue JSON::ParserError
140
+ [key, value]
141
+ end
142
+ end
143
+ end
144
+
145
+ # Look up a parameter's declared JSON-Schema type, tolerating string/symbol
146
+ # keys in both +properties+ and the per-param spec hash.
147
+ def self.schema_type(properties, key)
148
+ spec = properties[key] || properties[key.to_s] || properties[key.to_sym]
149
+ return nil unless spec.is_a?(Hash)
150
+ spec[:type] || spec["type"]
151
+ end
152
+
91
153
  # Validate required parameters and filter unknown parameters
92
154
  def self.validate_required_params(call, args, tool_registry)
93
155
  tool = tool_registry.get(call[:name])
94
156
  required = tool.parameters&.dig(:required) || []
95
157
  properties = tool.parameters&.dig(:properties) || {}
96
158
 
159
+ # Undo accidental double-serialization BEFORE filtering, using the schema
160
+ # so that parameters declared as "string" are never touched.
161
+ args = undouble_serialize_args(args, properties)
162
+
97
163
  missing = required.reject { |param|
98
164
  args.key?(param.to_sym) || args.key?(param.to_s)
99
165
  }
@@ -161,11 +227,14 @@ module Clacky
161
227
  lines << " Name: #{tool.name}"
162
228
  lines << " Description: #{tool.description}"
163
229
 
164
- if tool.parameters[:properties]
230
+ params = tool.parameters
231
+ properties = params && params[:properties]
232
+ if properties
165
233
  lines << " Parameters:"
166
- tool.parameters[:properties].each do |param, spec|
167
- required_mark = tool.parameters[:required]&.include?(param.to_s) ? " (required)" : ""
168
- lines << " - #{param}#{required_mark}: #{spec[:description]}"
234
+ properties.each do |param, spec|
235
+ required_mark = params[:required]&.include?(param.to_s) ? " (required)" : ""
236
+ desc = spec.is_a?(Hash) ? spec[:description] : spec.to_s
237
+ lines << " - #{param}#{required_mark}: #{desc}"
169
238
  end
170
239
  end
171
240
 
@@ -377,6 +377,25 @@ module Clacky
377
377
  }
378
378
  },
379
379
 
380
+ # Gemini 3.6 Flash (GA 2026-07-21). Flat pricing, 1M context, 64K max output.
381
+ # Source: https://deepmind.google/models/gemini/flash/
382
+ # Cache write billed at input rate (Vertex doesn't expose a separate
383
+ # cache-write charge in the OpenAI shim usage response).
384
+ "gemini-3.6-flash" => {
385
+ input: {
386
+ default: 1.50,
387
+ over_200k: 1.50
388
+ },
389
+ output: {
390
+ default: 7.50,
391
+ over_200k: 7.50
392
+ },
393
+ cache: {
394
+ write: 1.50,
395
+ read: 0.15
396
+ }
397
+ },
398
+
380
399
  # OpenAI GPT-5.5 / GPT-5.4 — breakpoint at 272K input tokens
381
400
  # Source: https://openai.com/api/pricing/ (USD / 1M tokens)
382
401
  # Note: OpenAI's actual tiered-pricing threshold is 272K, not the
@@ -882,6 +901,8 @@ module Clacky
882
901
  "gemini-3.1-pro"
883
902
  when /^or-gemini-3-5-flash$/i, /^gemini-3\.5-flash$/i, /^gemini-3-flash(-preview)?$/i
884
903
  "gemini-3-flash"
904
+ when /^or-gemini-3-6-flash$/i, /^gemini-3\.6-flash$/i
905
+ "gemini-3.6-flash"
885
906
 
886
907
  # OpenAI GPT-5.x models — match various dashed/dotted/compact forms
887
908
  # (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.6"
4
+ VERSION = "1.5.8"
5
5
  end