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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- 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:
|
|
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
|
|
567
|
-
#
|
|
568
|
-
user_content = format_user_content(
|
|
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
|
-
#
|
|
684
|
-
#
|
|
685
|
-
#
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
#
|
|
2278
|
-
#
|
|
2279
|
-
#
|
|
2280
|
-
#
|
|
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,
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -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.
|
data/lib/clacky/agent_profile.rb
CHANGED
|
@@ -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
|
|