openclacky 1.5.11 → 1.5.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
data/lib/clacky/agent.rb CHANGED
@@ -113,6 +113,7 @@ module Clacky
113
113
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
114
114
  @pending_error_rollback = false # Deferred rollback flag set by restore_session on error
115
115
  @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)
116
+ @cancel_flag = CancelFlag.new # Cooperative cancel: set by fan_out_labeled when the parent is interrupted; subagents on worker threads observe it via check_stale!
116
117
 
117
118
  # Compression tracking
118
119
  @compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
@@ -473,7 +474,7 @@ module Clacky
473
474
  @name = new_name.to_s.strip
474
475
  end
475
476
 
476
- def run(user_input, files: [], display_text: nil, created_at: nil)
477
+ def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
477
478
  # Intercept /goal ... commands before any task/LLM work. Control-plane
478
479
  # commands (status/pause/resume/clear) return immediately without a turn;
479
480
  # `/goal <text>` sets the goal, then falls through to run the first turn.
@@ -497,6 +498,10 @@ module Clacky
497
498
  # idempotent update on the same progress UI element.
498
499
  @ui&.show_progress
499
500
 
501
+ # A reused agent may carry a cancel flag set by a previously-interrupted
502
+ # fan-out batch; a fresh task must start uncancelled.
503
+ @cancel_flag = CancelFlag.new
504
+
500
505
  # Start new task for Time Machine
501
506
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
502
507
 
@@ -563,9 +568,12 @@ module Clacky
563
568
  all_disk_files = disk_files + downgraded
564
569
 
565
570
  # Format user message — text + inline vision images
566
- # Store the tmp path alongside the data_url so the history replay can
567
- # reconstruct the image if the base64 was stripped (e.g. after compression).
568
- user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
571
+ # Store the tmp path and original name alongside the data_url: the path supports
572
+ # normal replay, while the name becomes the lightweight badge after compression.
573
+ user_content = format_user_content(
574
+ user_input,
575
+ vision_images.map { |v| { url: v[:url], path: v[:path], name: v[:name] } }
576
+ )
569
577
 
570
578
  # Parse disk files — agent's responsibility, not the upload layer.
571
579
  # process_path runs the parser script and returns a FileRef with preview_path or parse_error.
@@ -579,10 +587,19 @@ module Clacky
579
587
  # "too large" note for downgraded images.
580
588
  downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
581
589
  ocr_text = f[:ocr_text] || f["ocr_text"]
590
+ reference = f[:reference] || f["reference"]
591
+
592
+ # Directory references: capture only the path so the LLM can explore
593
+ # on demand with the read/shell tools.
594
+ if File.directory?(path.to_s)
595
+ next { name: name || File.basename(path.to_s), type: "directory", path: path.to_s,
596
+ reference: reference }
597
+ end
598
+
582
599
  ref = Utils::FileProcessor.process_path(path, name: name)
583
600
  { name: ref.name, type: ref.type.to_s, path: ref.original_path,
584
601
  preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path,
585
- downgrade_reason: downgrade_reason, ocr_text: ocr_text }
602
+ downgrade_reason: downgrade_reason, ocr_text: ocr_text, reference: reference }
586
603
  end
587
604
 
588
605
  # Build display_files for replay: lightweight metadata so the UI can reconstruct
@@ -591,6 +608,10 @@ module Clacky
591
608
  # images (provider has no vision / too large / OCR'd) DO need path here so the
592
609
  # UI can re-render them from the on-disk copy across session switches.
593
610
  display_files = all_disk_files.filter_map do |f|
611
+ # @mention file/directory references are replayed from display_references
612
+ # (with mention badges), so skip them here to avoid double-rendering as
613
+ # plain attachment badges.
614
+ next if f[:reference] || f["reference"]
594
615
  name = f[:name] || f["name"]
595
616
  next unless name
596
617
  { name: name, type: f[:type] || f["type"] || "file",
@@ -614,7 +635,8 @@ module Clacky
614
635
  display_text: display_text,
615
636
  skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
616
637
  skill_command_display: skill_command_display,
617
- display_files: display_files.empty? ? nil : display_files })
638
+ display_files: display_files.empty? ? nil : display_files,
639
+ display_references: Array(references_display).empty? ? nil : references_display })
618
640
  @total_tasks += 1
619
641
 
620
642
  # Inject disk file references as a system_injected message so:
@@ -640,6 +662,12 @@ module Clacky
640
662
 
641
663
  next unless name
642
664
 
665
+ # Directory reference: emit only the path so the LLM can explore on
666
+ # demand with the read/shell tools.
667
+ if type == "directory"
668
+ next ["[Directory: #{name}]", "Path: #{path}"].join("\n")
669
+ end
670
+
643
671
  lines = ["[File: #{name}]", "Type: #{type || "file"}"]
644
672
  lines << "Size: #{format_size(size_bytes)}" if size_bytes
645
673
  lines << "Original: #{path}" if path
@@ -680,17 +708,32 @@ module Clacky
680
708
  end
681
709
  end
682
710
 
683
- # If the user typed a slash command targeting a skill with disable-model-invocation: true,
684
- # inject the skill content as a synthetic assistant message so the LLM can act on it.
685
- # Skills already in the system prompt (model_invocation_allowed?) are skipped.
686
- inject_skill_command_as_assistant_message(skill_command, task_id)
687
-
688
- @hooks.trigger(:on_start, user_input)
711
+ # Inject referenced past chats (the @mention "send as reference" behavior)
712
+ # as a system_injected message same mechanism as file references: the LLM
713
+ # sees the context, but replay_history skips it and no user bubble renders.
714
+ Array(reference_contexts).each do |ctx|
715
+ next if ctx.to_s.strip.empty?
716
+ @history.append({ role: "user", content: ctx, system_injected: true, task_id: task_id })
717
+ end
689
718
 
690
719
  result = nil
691
720
  begin
721
+ # If the user typed a slash command targeting a skill with disable-model-invocation: true,
722
+ # inject the skill content as a synthetic assistant message so the LLM can act on it.
723
+ # Skills already in the system prompt (model_invocation_allowed?) are skipped.
724
+ # Inside the begin block so a fork_subagent failure (e.g. skill-declared model
725
+ # not found) still reaches the ensure block that stops the progress spinner.
726
+ inject_skill_command_as_assistant_message(skill_command, task_id)
727
+
728
+ @hooks.trigger(:on_start, user_input)
729
+
692
730
  # Track if ask_user was called
693
731
  awaiting_user_feedback = false
732
+ # Heuristic sibling of the above: the reply merely ended with a question
733
+ # mark. Kept separate because it must never reach build_result — the
734
+ # session status it feeds shows a "waiting" badge to the user, and a
735
+ # rhetorical closing question is not a request for input.
736
+ turn_unfinished = false
694
737
  # Track if task was interrupted by user (denied tool execution)
695
738
  task_interrupted = false
696
739
 
@@ -795,7 +838,7 @@ module Clacky
795
838
  )
796
839
  end
797
840
  if response[:content] && !response[:content].empty?
798
- emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
841
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], created_at: response[:created_at])
799
842
  end
800
843
 
801
844
  # Show token usage after the assistant message so WebUI renders it below the bubble
@@ -814,14 +857,14 @@ module Clacky
814
857
  # If the assistant ended its turn with a question, treat this as
815
858
  # an in-flight conversation (agent is awaiting the user's reply)
816
859
  # and skip skill evolution — the task isn't truly complete yet.
817
- awaiting_user_feedback = true if ends_with_question
860
+ turn_unfinished = true if ends_with_question
818
861
 
819
862
  break
820
863
  end
821
864
 
822
865
  # Show assistant message if there's content before tool calls
823
866
  if response[:content] && !response[:content].empty?
824
- emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true)
867
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true, created_at: response[:created_at])
825
868
  end
826
869
 
827
870
  # Show token usage after assistant message (or immediately if no message).
@@ -873,7 +916,7 @@ module Clacky
873
916
  # Run skill evolution hooks after main loop completes
874
917
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
875
918
  # Only for main agent (not subagents) to avoid recursive evolution
876
- unless @is_subagent || task_interrupted || awaiting_user_feedback
919
+ unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
877
920
  run_skill_evolution_hooks
878
921
  end
879
922
 
@@ -884,7 +927,7 @@ module Clacky
884
927
  # and only then [OK] Task Complete is printed. No cleanup dance,
885
928
  # no cross-method progress handle holding.
886
929
  # Skip on interrupt / feedback / subagent (self-guarded inside too).
887
- unless @is_subagent || task_interrupted || awaiting_user_feedback
930
+ unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
888
931
  run_memory_update_subagent
889
932
  end
890
933
 
@@ -892,6 +935,7 @@ module Clacky
892
935
  # Parent agent (skill_manager) prints the completion summary; skip here.
893
936
  else
894
937
  @ui&.show_complete(
938
+ task_id: result[:task_id],
895
939
  iterations: result[:iterations],
896
940
  cost: result[:total_cost_usd],
897
941
  cost_source: result[:cost_source],
@@ -915,6 +959,10 @@ module Clacky
915
959
 
916
960
  result
917
961
  rescue Clacky::AgentInterrupted
962
+ # A cancelled fan-out captured its subagents' progress but never reached
963
+ # observe() to persist it — anchor those trails now so a page reload
964
+ # after the interrupt still shows what the subagents did.
965
+ flush_pending_subagent_transcripts_on_interrupt
918
966
  # Mark this run as interrupted so the next run() (e.g. user's
919
967
  # supplementary message during a running task) keeps the existing
920
968
  # task-start snapshot — the completion summary should reflect the
@@ -1085,7 +1133,8 @@ module Clacky
1085
1133
  "- Break down large tasks into multiple smaller tool calls\n" \
1086
1134
  "- Keep each tool call argument under 2000 characters\n" \
1087
1135
  "- Use multiple tool calls instead of one large call",
1088
- truncated: true
1136
+ truncated: true,
1137
+ system_injected: true
1089
1138
  })
1090
1139
 
1091
1140
  # Close the current spinner so the warning appears cleanly;
@@ -1098,7 +1147,11 @@ module Clacky
1098
1147
  end
1099
1148
 
1100
1149
  # Add assistant response to history
1101
- msg = { role: "assistant", task_id: @current_task_id }
1150
+ created_at = Time.now.to_f
1151
+ msg = { role: "assistant", task_id: @current_task_id, created_at: created_at }
1152
+ # Surface the storage timestamp to the caller so the live UI emit uses
1153
+ # the same created_at that replay will later read back from history.
1154
+ response[:created_at] = created_at
1102
1155
  # Always include content field (some APIs require it even with tool_calls)
1103
1156
  # Use empty string instead of null for better compatibility
1104
1157
  msg[:content] = response[:content] || ""
@@ -1145,6 +1198,7 @@ module Clacky
1145
1198
  # to history — guarantees a stale thread cannot corrupt history with
1146
1199
  # tool messages that no longer have a matching assistant tool_calls.
1147
1200
  private def check_stale!
1201
+ raise Clacky::AgentInterrupted, "Fan-out batch cancelled by a newer task" if @cancel_flag&.cancelled?
1148
1202
  return unless @task_thread
1149
1203
  return if Thread.current == @task_thread
1150
1204
  raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
@@ -1488,6 +1542,26 @@ module Clacky
1488
1542
  end
1489
1543
  end
1490
1544
 
1545
+ # Interrupt-path counterpart to attach_pending_subagent_transcripts: a
1546
+ # cancelled fan-out never reaches observe(), so its captured job trails
1547
+ # would otherwise die in @pending_subagent_transcripts (memory only) and
1548
+ # vanish on the next page reload. Anchor whatever was captured onto the
1549
+ # last history message so it persists to session.json and replays.
1550
+ private def flush_pending_subagent_transcripts_on_interrupt
1551
+ pending = @subagent_transcripts_mutex.synchronize do
1552
+ snapshot = @pending_subagent_transcripts.dup
1553
+ @pending_subagent_transcripts.clear
1554
+ snapshot
1555
+ end
1556
+ return if pending.empty?
1557
+
1558
+ by_id = {}
1559
+ pending.each { |id, trails| by_id[id] = Array(trails).sort_by { |t| t[:index] || 0 } }
1560
+ @history.settle_interrupted_tool_calls(by_id)
1561
+ rescue StandardError => e
1562
+ Clacky::Logger.warn("agent.flush_subagent_transcripts_failed", error: e.message)
1563
+ end
1564
+
1491
1565
  # Cap oversized tool result content to keep a single tool message from
1492
1566
  # blowing up the prompt budget (issue #218: a 7350-path glob produced a
1493
1567
  # ~890k-char result that pushed history past the model context window
@@ -1611,6 +1685,7 @@ module Clacky
1611
1685
  {
1612
1686
  status: status,
1613
1687
  session_id: @session_id,
1688
+ task_id: @current_task_id,
1614
1689
  model: current_model,
1615
1690
  provider: current_provider,
1616
1691
  iterations: task_iterations,
@@ -1773,7 +1848,8 @@ module Clacky
1773
1848
  end
1774
1849
  end
1775
1850
 
1776
- Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(wrapped)
1851
+ Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
1852
+ .run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
1777
1853
  end
1778
1854
 
1779
1855
  private def within_phase(label, kind:, concurrent:, &block)
@@ -1899,6 +1975,10 @@ module Clacky
1899
1975
  )
1900
1976
  subagent.instance_variable_set(:@is_subagent, true)
1901
1977
 
1978
+ # Share the parent's cancel flag so a fan-out interrupt reaches this
1979
+ # subagent on its worker thread — its own check_stale! polls the same flag.
1980
+ subagent.instance_variable_set(:@cancel_flag, @cancel_flag)
1981
+
1902
1982
  # Inherit previous_total_tokens so the first iteration delta is calculated correctly
1903
1983
  subagent.instance_variable_set(:@previous_total_tokens, @previous_total_tokens)
1904
1984
 
@@ -2274,10 +2354,10 @@ module Clacky
2274
2354
 
2275
2355
  # Build user message content for LLM.
2276
2356
  # Returns plain String when no vision images; Array of content parts otherwise.
2277
- # Build user message content for LLM.
2278
- # vision_images: Array of String (plain url) OR Hash { url:, path: }
2279
- # path is stored in the block so history replay can reconstruct the image
2280
- # from the tmp file when the base64 data_url is no longer available.
2357
+ # vision_images: Array of String (plain url) OR Hash { url:, path:, name: }
2358
+ # path is stored so normal history replay can reconstruct the image; name is
2359
+ # lightweight metadata used for an archived badge after compression. Both
2360
+ # fields are stripped by MessageHistory before the content reaches the API.
2281
2361
  private def format_user_content(text, vision_images)
2282
2362
  vision_images ||= []
2283
2363
 
@@ -2289,6 +2369,7 @@ module Clacky
2289
2369
  if img.is_a?(Hash)
2290
2370
  block = { type: "image_url", image_url: { url: img[:url] } }
2291
2371
  block[:image_path] = img[:path] if img[:path]
2372
+ block[:image_name] = img[:name] if img[:name]
2292
2373
  content << block
2293
2374
  else
2294
2375
  content << { type: "image_url", image_url: { url: img } }
@@ -2400,7 +2481,7 @@ module Clacky
2400
2481
  # and cannot load file:// directly) and must stay scoped to the Web UI
2401
2482
  # controller. IM channel subscribers need the original file:// markdown so
2402
2483
  # parse_file_links can extract paths and deliver images as native attachments.
2403
- private def emit_assistant_message(content, reasoning_content: nil, interim: false)
2484
+ private def emit_assistant_message(content, reasoning_content: nil, interim: false, created_at: nil)
2404
2485
  # Prepend reasoning/thinking content (from thinking-mode providers like
2405
2486
  # DeepSeek V4, Kimi K2) wrapped in <think> tags so the Web UI renders it
2406
2487
  # as a collapsible thinking block (see sessions.js _renderMarkdown).
@@ -2413,7 +2494,7 @@ module Clacky
2413
2494
  return if full_content.nil? || full_content.to_s.strip.empty?
2414
2495
 
2415
2496
  parsed = parse_file_links(content)
2416
- @ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim)
2497
+ @ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim, created_at: created_at)
2417
2498
  end
2418
2499
 
2419
2500
  # Record BEFORE-change snapshots for any file a tool is about to mutate,
@@ -402,6 +402,46 @@ module Clacky
402
402
  Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
403
403
  end
404
404
 
405
+ # Re-read config.yml from disk and apply it to this instance in place.
406
+ #
407
+ # Two invariants make this safe for a running server:
408
+ # 1. @models is mutated with #replace, never reassigned — every session
409
+ # built via #deep_copy shares that array by design (see #deep_copy).
410
+ # 2. Model ids are regenerated on every load and are not persisted, so
411
+ # fresh entries inherit the id of the matching old entry (same model
412
+ # name + base_url). Otherwise every session's @current_model_id
413
+ # would dangle after a reload.
414
+ #
415
+ # Returns true when applied, false when the file could not be parsed —
416
+ # in-memory config is left untouched rather than wiped.
417
+ def reload!(config_file = CONFIG_FILE)
418
+ fresh = self.class.load(config_file)
419
+
420
+ fresh.models.each do |m|
421
+ previous = find_model_by_name_and_url(m["model"], m["base_url"])
422
+ m["id"] = previous["id"] if previous
423
+ end
424
+
425
+ @models.replace(fresh.models)
426
+
427
+ CONFIG_SETTINGS_KEYS.each do |key|
428
+ send("#{key}=", fresh.send(key))
429
+ end
430
+
431
+ # A session may have been pinned to a model the user just deleted.
432
+ unless @models.any? { |m| m["id"] == @current_model_id }
433
+ fallback = find_model_by_type("default") || @models.first
434
+ @current_model_id = fallback && fallback["id"]
435
+ @current_model_index = fallback ? @models.index(fallback).to_i : 0
436
+ end
437
+
438
+ Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
439
+ true
440
+ rescue StandardError => e
441
+ Clacky::Logger.warn("[AgentConfig] reload! failed: #{e.class}: #{e.message}") if defined?(Clacky::Logger)
442
+ false
443
+ end
444
+
405
445
  # Convert to YAML format (top-level array)
406
446
  # Auto-injected lite models (auto_injected: true) are excluded from persistence —
407
447
  # they are regenerated at load time from the provider preset.
@@ -88,11 +88,11 @@ module Clacky
88
88
  # @param recency [Hash<String, Integer>] agent id => epoch of last use,
89
89
  # used to order third-party extension agents by most-recently-used
90
90
  # instead of their static `order` field.
91
- # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }
91
+ # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar:, hidden: }
92
92
  def self.all(recency: {})
93
93
  out = {}
94
94
 
95
- add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
95
+ add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar, hidden|
96
96
  next if id.nil? || id.empty?
97
97
  out[id] = {
98
98
  id: id,
@@ -105,13 +105,13 @@ module Clacky
105
105
  layer: layer,
106
106
  author: author,
107
107
  avatar: avatar,
108
+ hidden: hidden,
108
109
  }
109
110
  end
110
111
 
111
112
  ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
112
113
  ext_result&.agents&.each do |unit|
113
114
  spec = unit.spec || {}
114
- next if spec["hidden"]
115
115
  title = spec["title"].to_s
116
116
  title = unit.id if title.empty?
117
117
  avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
@@ -119,7 +119,7 @@ module Clacky
119
119
  unit.id, title, spec["title_zh"].to_s,
120
120
  spec["description"].to_s, spec["description_zh"].to_s,
121
121
  "extension", spec["order"], unit.layer.to_s,
122
- spec["author"].to_s, avatar
122
+ spec["author"].to_s, avatar, !!spec["hidden"]
123
123
  )
124
124
  end
125
125
 
@@ -134,7 +134,7 @@ module Clacky
134
134
  id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
135
135
  meta["description"].to_s, meta["description_zh"].to_s,
136
136
  "user", meta["order"], "user",
137
- meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar
137
+ meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar, false
138
138
  )
139
139
  end
140
140
 
@@ -9,6 +9,7 @@ module Clacky
9
9
  :session_id, # Associated session ID
10
10
  :timestamp, # Time of the API call
11
11
  :model, # Model used (e.g., "claude-sonnet-4.5")
12
+ :provider, # Provider id (e.g., "openclacky", "anthropic")
12
13
  :prompt_tokens, # Input tokens
13
14
  :completion_tokens, # Output tokens
14
15
  :cache_read_tokens, # Tokens read from cache
@@ -24,6 +25,7 @@ module Clacky
24
25
  session_id: session_id,
25
26
  timestamp: timestamp.is_a?(Time) ? timestamp.iso8601 : timestamp,
26
27
  model: model,
28
+ provider: provider,
27
29
  prompt_tokens: prompt_tokens || 0,
28
30
  completion_tokens: completion_tokens || 0,
29
31
  cache_read_tokens: cache_read_tokens || 0,
@@ -40,6 +42,7 @@ module Clacky
40
42
  session_id: hash[:session_id] || hash["session_id"],
41
43
  timestamp: parse_timestamp(hash[:timestamp] || hash["timestamp"]),
42
44
  model: hash[:model] || hash["model"],
45
+ provider: hash[:provider] || hash["provider"],
43
46
  prompt_tokens: hash[:prompt_tokens] || hash["prompt_tokens"] || 0,
44
47
  completion_tokens: hash[:completion_tokens] || hash["completion_tokens"] || 0,
45
48
  cache_read_tokens: hash[:cache_read_tokens] || hash["cache_read_tokens"] || 0,
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "securerandom"
6
6
  require_relative "billing_record"
7
7
  require_relative "../session_manager"
8
+ require_relative "../providers"
8
9
  module Clacky
9
10
  module Billing
10
11
  # Persistent storage for billing records using JSONL files
@@ -12,6 +13,27 @@ module Clacky
12
13
  class BillingStore
13
14
  BILLING_DIR = File.join(Dir.home, ".clacky", "billing")
14
15
 
16
+ # Preset keys that hold the openclacky model lineup (chat + media sidecars).
17
+ OPENCLACKY_MODEL_KINDS = %w[models image_models video_models audio_models stt_models video_understanding_models].freeze
18
+
19
+ # All openclacky model aliases, collected from the provider preset so the
20
+ # list never drifts from providers.rb as models are added or removed.
21
+ def openclacky_models
22
+ @openclacky_models ||= begin
23
+ preset = Clacky::Providers.get("openclacky")
24
+ return [] unless preset
25
+ OPENCLACKY_MODEL_KINDS.flat_map { |kind| preset[kind] || [] }.uniq
26
+ end
27
+ end
28
+
29
+ # True when a record belongs to the openclacky provider. New records
30
+ # carry an explicit provider id; legacy records (written before the
31
+ # field existed) are detected by matching the full openclacky model list.
32
+ def openclacky_record?(record)
33
+ return true if record.provider == "openclacky"
34
+ openclacky_models.include?(record.model.to_s)
35
+ end
36
+
15
37
  def initialize(billing_dir: nil)
16
38
  @billing_dir = billing_dir || ENV["CLACKY_BILLING_DIR"] || BILLING_DIR
17
39
  ensure_billing_dir
@@ -38,9 +60,10 @@ module Clacky
38
60
  # @param to [Time, nil] End time (inclusive)
39
61
  # @param model [String, nil] Filter by model name
40
62
  # @param session_id [String, nil] Filter by session ID
63
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
41
64
  # @param limit [Integer, nil] Maximum number of records to return
42
65
  # @return [Array<BillingRecord>] Matching records, newest first
43
- def query(from: nil, to: nil, model: nil, session_id: nil, limit: nil)
66
+ def query(from: nil, to: nil, model: nil, session_id: nil, exclude_openclacky: false, limit: nil)
44
67
  records = []
45
68
 
46
69
  billing_files.each do |file|
@@ -56,6 +79,7 @@ module Clacky
56
79
  next if to && record.timestamp > to
57
80
  next if model && record.model != model
58
81
  next if session_id && record.session_id != session_id
82
+ next if exclude_openclacky && openclacky_record?(record)
59
83
 
60
84
  records << record
61
85
  rescue JSON::ParserError
@@ -75,10 +99,11 @@ module Clacky
75
99
  # Get summary statistics for a time period
76
100
  # @param period [Symbol] :day, :week, :month, :year, or :all
77
101
  # @param model [String, nil] Filter by model name
102
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
78
103
  # @return [Hash] Summary with total_cost, total_tokens, by_model, etc.
79
- def summary(period: :month, model: nil)
104
+ def summary(period: :month, model: nil, exclude_openclacky: false)
80
105
  from_time = period_start(period)
81
- records = query(from: from_time, model: model)
106
+ records = query(from: from_time, model: model, exclude_openclacky: exclude_openclacky)
82
107
 
83
108
  total_cost = records.sum { |r| r.cost_usd || 0 }
84
109
  total_prompt = records.sum { |r| r.prompt_tokens || 0 }
@@ -222,10 +247,11 @@ module Clacky
222
247
 
223
248
  # Get daily cost breakdown for the last N days # @param days [Integer] Number of days to include
224
249
  # @param model [String, nil] Filter by model name
250
+ # @param exclude_openclacky [Boolean] Skip openclacky-provider records
225
251
  # @return [Array<Hash>] Daily summaries with date and cost
226
- def daily_breakdown(days: 30, model: nil)
252
+ def daily_breakdown(days: 30, model: nil, exclude_openclacky: false)
227
253
  from_time = Time.now - (days * 24 * 60 * 60)
228
- records = query(from: from_time, model: model)
254
+ records = query(from: from_time, model: model, exclude_openclacky: exclude_openclacky)
229
255
 
230
256
  by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }
231
257