openclacky 1.5.11 → 1.5.12
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 +34 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +37 -17
- data/lib/clacky/agent/time_machine.rb +1 -0
- data/lib/clacky/agent.rb +96 -19
- 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 +148 -0
- data/lib/clacky/cancel_flag.rb +23 -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 +28 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +34 -3
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/panels/git/view.js +10 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_history.rb +38 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +8 -3
- 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/http_server.rb +305 -25
- 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 +3 -0
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +22 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +506 -37
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +277 -0
- data/lib/clacky/web/components/mentions.js +599 -0
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +12 -10
- data/lib/clacky/web/features/new-session/view.js +49 -12
- data/lib/clacky/web/features/workspace/view.js +712 -77
- data/lib/clacky/web/i18n.js +34 -6
- data/lib/clacky/web/index.html +33 -8
- data/lib/clacky/web/sessions.js +182 -71
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 858730306a6e6647d79a5ebb35160c52fba7ed26c62ec97043373e7f052332e2
|
|
4
|
+
data.tar.gz: b412bc405daae81f1b6d71dd4dd2dffc197a00ac97540ecfd4916f7daf58843d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 685d67556e68efd81a8bc446edd473a57939f0404b754e4432c7d29e073d2c47adc0c939fc5da3ea32144f3c6026aa17d73ff4766420399515089a3a8a4bc98d
|
|
7
|
+
data.tar.gz: dc85168d4b103185d20087635cfc5972c4e3e23b8dee95cb2ba28017d276dda425d8d92d23159dad463bd6cdcc29b8e02404d50dd4a79e5e6345193e8b4167c8
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.12] - 2026-08-27
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Add VS Code-style file tree with inline file viewer
|
|
11
|
+
- Add quick-access sidebar for directory picker with WSL support
|
|
12
|
+
- Add Web UI @mention references for files, directories and sessions
|
|
13
|
+
- Add Gemini 3.7 Flash model with STT/video support; switch OCR default to DeepSeek vision
|
|
14
|
+
- Add timestamps to assistant messages
|
|
15
|
+
- Merge platform usage API data with local billing records
|
|
16
|
+
- Refresh git panel on session completion
|
|
17
|
+
- Allow disabling local extensions
|
|
18
|
+
- Expose task mappings in core events (#496 - @YeEmrick)
|
|
19
|
+
|
|
20
|
+
### Improved
|
|
21
|
+
- Move file-trash sweep off the session save path and speed up session creation
|
|
22
|
+
- Make recharge link a visible button on model cards
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Fix ask_user free-text input: render without options, guard IME composition, route Enter, render as text in IM channels, keep questions concise (#497 - @afumu)
|
|
26
|
+
- Fix advisor recommendations: send structured options, keep card state per session, dismiss button no longer permanently suppresses
|
|
27
|
+
- Fix home favorite pointing at the Windows profile under WSL
|
|
28
|
+
- Fix session list pagination and pin truncation
|
|
29
|
+
- Preserve task ids in compressed history (#500 - @YeEmrick)
|
|
30
|
+
- Clear stale media model override when reverting to auto
|
|
31
|
+
- Normalize gemini-3.7-flash name for pricing
|
|
32
|
+
- Fix sidebar scrollbar visibility and session list styling
|
|
33
|
+
- Fix messages loading created_at
|
|
34
|
+
- Render parallel fan-out subagent phases as flat, isolated cards
|
|
35
|
+
- Preserve subagent transcripts when an interrupted fan-out is resumed
|
|
36
|
+
- Fix spinner in subagent raise process
|
|
37
|
+
- Truncate messages carrying the system_injected flag
|
|
38
|
+
- Stop reporting awaiting_feedback for question-mark endings
|
|
39
|
+
- Detach channel_ui and keep channel_info unique on /bind and /unbind
|
|
40
|
+
|
|
7
41
|
## [1.5.11] - 2026-08-23
|
|
8
42
|
|
|
9
43
|
### Added
|
|
@@ -136,11 +136,13 @@ module Clacky
|
|
|
136
136
|
# This ensures all API calls are recorded for statistics purposes.
|
|
137
137
|
billing_model = model || current_model
|
|
138
138
|
effective_cost = cost || 0.0 # Use 0 if pricing is unknown
|
|
139
|
+
provider = @config.provider_id_for(@config.current_model)
|
|
139
140
|
|
|
140
141
|
record = Billing::BillingRecord.new(
|
|
141
142
|
session_id: @session_id,
|
|
142
143
|
timestamp: Time.now,
|
|
143
144
|
model: billing_model,
|
|
145
|
+
provider: provider,
|
|
144
146
|
prompt_tokens: usage[:prompt_tokens] || 0,
|
|
145
147
|
completion_tokens: usage[:completion_tokens] || 0,
|
|
146
148
|
cache_read_tokens: usage[:cache_read_input_tokens] || 0,
|
|
@@ -391,7 +391,11 @@ module Clacky
|
|
|
391
391
|
preview_path: f[:preview_path] || f["preview_path"] }
|
|
392
392
|
}
|
|
393
393
|
all_files = image_files + disk_files
|
|
394
|
-
|
|
394
|
+
references = Array(msg[:display_references])
|
|
395
|
+
ref_options = references.empty? ? {} : { references: references }
|
|
396
|
+
task_options = msg[:task_id] ? { task_id: msg[:task_id] } : {}
|
|
397
|
+
ui.show_user_message(raw_text, **task_options, created_at: msg[:created_at], files: all_files,
|
|
398
|
+
**ref_options,
|
|
395
399
|
editable: round[:editable] != false,
|
|
396
400
|
skill_command: msg[:skill_command],
|
|
397
401
|
skill_command_display: msg[:skill_command_display])
|
|
@@ -490,30 +494,34 @@ module Clacky
|
|
|
490
494
|
current_role = nil
|
|
491
495
|
current_lines = []
|
|
492
496
|
current_nested_chunk = nil # chunk reference from a Compressed Summary heading
|
|
497
|
+
current_task_id = nil
|
|
493
498
|
|
|
494
499
|
raw.each_line do |line|
|
|
495
500
|
stripped = line.chomp
|
|
496
501
|
if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
|
|
497
502
|
# Nested chunk reference — record it, treat as assistant section
|
|
498
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
503
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
499
504
|
current_role = "assistant"
|
|
500
505
|
current_lines = []
|
|
501
506
|
current_nested_chunk = File.join(chunk_dir, m[1])
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
current_role
|
|
507
|
+
current_task_id = nil
|
|
508
|
+
elsif (m = stripped.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
|
|
509
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
510
|
+
current_role = m[1].downcase
|
|
505
511
|
current_lines = []
|
|
506
512
|
current_nested_chunk = nil
|
|
513
|
+
current_task_id = m[2]&.to_i
|
|
507
514
|
elsif stripped.match?(/\A### Tool Result:/)
|
|
508
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
515
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
509
516
|
current_role = "tool"
|
|
510
517
|
current_lines = []
|
|
511
518
|
current_nested_chunk = nil
|
|
519
|
+
current_task_id = nil
|
|
512
520
|
else
|
|
513
521
|
current_lines << line
|
|
514
522
|
end
|
|
515
523
|
end
|
|
516
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
524
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
517
525
|
|
|
518
526
|
# Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
|
|
519
527
|
sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
|
|
@@ -544,14 +552,16 @@ module Clacky
|
|
|
544
552
|
round_index += 1
|
|
545
553
|
# Synthetic timestamp: spread rounds backwards from archived_at
|
|
546
554
|
synthetic_ts = base_time - (sections.size - round_index) * 1.0
|
|
555
|
+
user_msg = {
|
|
556
|
+
role: "user",
|
|
557
|
+
content: text,
|
|
558
|
+
created_at: synthetic_ts,
|
|
559
|
+
ext_events: sec_ext_events,
|
|
560
|
+
_from_chunk: true
|
|
561
|
+
}
|
|
562
|
+
user_msg[:task_id] = sec[:task_id] if sec[:task_id]
|
|
547
563
|
current_round = {
|
|
548
|
-
user_msg:
|
|
549
|
-
role: "user",
|
|
550
|
-
content: text,
|
|
551
|
-
created_at: synthetic_ts,
|
|
552
|
-
ext_events: sec_ext_events,
|
|
553
|
-
_from_chunk: true
|
|
554
|
-
},
|
|
564
|
+
user_msg: user_msg,
|
|
555
565
|
events: [],
|
|
556
566
|
# editable: false — this message was archived into a chunk MD and no
|
|
557
567
|
# longer exists in the active in-memory @history, so it cannot be
|
|
@@ -733,7 +743,7 @@ module Clacky
|
|
|
733
743
|
else
|
|
734
744
|
raw_text
|
|
735
745
|
end
|
|
736
|
-
ui.show_assistant_message(text, files: [])
|
|
746
|
+
ui.show_assistant_message(text, files: [], created_at: msg[:created_at])
|
|
737
747
|
end
|
|
738
748
|
|
|
739
749
|
# Tool calls embedded in assistant message
|
|
@@ -761,6 +771,11 @@ module Clacky
|
|
|
761
771
|
# Emit token usage stored on this message (for history replay display)
|
|
762
772
|
ui.show_token_usage(msg[:token_usage]) if msg[:token_usage]
|
|
763
773
|
|
|
774
|
+
# Interrupted fan-out anchors its captured subagent trails onto the
|
|
775
|
+
# last message, which is usually this assistant turn (its tool result
|
|
776
|
+
# never got written). Replay them here so they survive a reload.
|
|
777
|
+
replay_subagent_transcript(msg, ui)
|
|
778
|
+
|
|
764
779
|
when "user"
|
|
765
780
|
# Anthropic-format tool results (role: user, content: array of tool_result blocks)
|
|
766
781
|
if msg[:content].is_a?(Array)
|
|
@@ -817,8 +832,12 @@ module Clacky
|
|
|
817
832
|
events = transcript[:events] || transcript["events"] || []
|
|
818
833
|
return if events.empty?
|
|
819
834
|
|
|
835
|
+
skill = transcript[:skill] || transcript["skill"]
|
|
836
|
+
supports_phase = ui.respond_to?(:phase_start) && ui.respond_to?(:phase_end)
|
|
837
|
+
pid = ui.phase_start(kind: "fanout_subagent", label: skill) if supports_phase
|
|
838
|
+
|
|
820
839
|
ui.show_subagent_start(
|
|
821
|
-
skill:
|
|
840
|
+
skill: skill,
|
|
822
841
|
iterations: transcript[:iterations] || transcript["iterations"],
|
|
823
842
|
cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
|
|
824
843
|
)
|
|
@@ -831,7 +850,7 @@ module Clacky
|
|
|
831
850
|
case role
|
|
832
851
|
when "assistant"
|
|
833
852
|
text = extract_text_from_content(content).to_s.strip
|
|
834
|
-
ui.show_assistant_message(text, files: []) unless text.empty?
|
|
853
|
+
ui.show_assistant_message(text, files: [], created_at: ev[:created_at] || ev["created_at"]) unless text.empty?
|
|
835
854
|
Array(tool_calls).each do |tc|
|
|
836
855
|
name = tc[:name] || tc["name"] || ""
|
|
837
856
|
args_raw = tc[:arguments] || tc["arguments"] || {}
|
|
@@ -844,6 +863,7 @@ module Clacky
|
|
|
844
863
|
end
|
|
845
864
|
|
|
846
865
|
ui.show_subagent_end
|
|
866
|
+
ui.phase_end(pid) if supports_phase && pid
|
|
847
867
|
end
|
|
848
868
|
|
|
849
869
|
# Replace the system message in @messages with a freshly built system prompt.
|
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
|
|
|
@@ -579,10 +584,19 @@ module Clacky
|
|
|
579
584
|
# "too large" note for downgraded images.
|
|
580
585
|
downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
|
|
581
586
|
ocr_text = f[:ocr_text] || f["ocr_text"]
|
|
587
|
+
reference = f[:reference] || f["reference"]
|
|
588
|
+
|
|
589
|
+
# Directory references: capture only the path so the LLM can explore
|
|
590
|
+
# on demand with the read/shell tools.
|
|
591
|
+
if File.directory?(path.to_s)
|
|
592
|
+
next { name: name || File.basename(path.to_s), type: "directory", path: path.to_s,
|
|
593
|
+
reference: reference }
|
|
594
|
+
end
|
|
595
|
+
|
|
582
596
|
ref = Utils::FileProcessor.process_path(path, name: name)
|
|
583
597
|
{ name: ref.name, type: ref.type.to_s, path: ref.original_path,
|
|
584
598
|
preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path,
|
|
585
|
-
downgrade_reason: downgrade_reason, ocr_text: ocr_text }
|
|
599
|
+
downgrade_reason: downgrade_reason, ocr_text: ocr_text, reference: reference }
|
|
586
600
|
end
|
|
587
601
|
|
|
588
602
|
# Build display_files for replay: lightweight metadata so the UI can reconstruct
|
|
@@ -591,6 +605,10 @@ module Clacky
|
|
|
591
605
|
# images (provider has no vision / too large / OCR'd) DO need path here so the
|
|
592
606
|
# UI can re-render them from the on-disk copy across session switches.
|
|
593
607
|
display_files = all_disk_files.filter_map do |f|
|
|
608
|
+
# @mention file/directory references are replayed from display_references
|
|
609
|
+
# (with mention badges), so skip them here to avoid double-rendering as
|
|
610
|
+
# plain attachment badges.
|
|
611
|
+
next if f[:reference] || f["reference"]
|
|
594
612
|
name = f[:name] || f["name"]
|
|
595
613
|
next unless name
|
|
596
614
|
{ name: name, type: f[:type] || f["type"] || "file",
|
|
@@ -614,7 +632,8 @@ module Clacky
|
|
|
614
632
|
display_text: display_text,
|
|
615
633
|
skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
|
|
616
634
|
skill_command_display: skill_command_display,
|
|
617
|
-
display_files: display_files.empty? ? nil : display_files
|
|
635
|
+
display_files: display_files.empty? ? nil : display_files,
|
|
636
|
+
display_references: Array(references_display).empty? ? nil : references_display })
|
|
618
637
|
@total_tasks += 1
|
|
619
638
|
|
|
620
639
|
# Inject disk file references as a system_injected message so:
|
|
@@ -640,6 +659,12 @@ module Clacky
|
|
|
640
659
|
|
|
641
660
|
next unless name
|
|
642
661
|
|
|
662
|
+
# Directory reference: emit only the path so the LLM can explore on
|
|
663
|
+
# demand with the read/shell tools.
|
|
664
|
+
if type == "directory"
|
|
665
|
+
next ["[Directory: #{name}]", "Path: #{path}"].join("\n")
|
|
666
|
+
end
|
|
667
|
+
|
|
643
668
|
lines = ["[File: #{name}]", "Type: #{type || "file"}"]
|
|
644
669
|
lines << "Size: #{format_size(size_bytes)}" if size_bytes
|
|
645
670
|
lines << "Original: #{path}" if path
|
|
@@ -680,17 +705,32 @@ module Clacky
|
|
|
680
705
|
end
|
|
681
706
|
end
|
|
682
707
|
|
|
683
|
-
#
|
|
684
|
-
#
|
|
685
|
-
#
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
708
|
+
# Inject referenced past chats (the @mention "send as reference" behavior)
|
|
709
|
+
# as a system_injected message — same mechanism as file references: the LLM
|
|
710
|
+
# sees the context, but replay_history skips it and no user bubble renders.
|
|
711
|
+
Array(reference_contexts).each do |ctx|
|
|
712
|
+
next if ctx.to_s.strip.empty?
|
|
713
|
+
@history.append({ role: "user", content: ctx, system_injected: true, task_id: task_id })
|
|
714
|
+
end
|
|
689
715
|
|
|
690
716
|
result = nil
|
|
691
717
|
begin
|
|
718
|
+
# If the user typed a slash command targeting a skill with disable-model-invocation: true,
|
|
719
|
+
# inject the skill content as a synthetic assistant message so the LLM can act on it.
|
|
720
|
+
# Skills already in the system prompt (model_invocation_allowed?) are skipped.
|
|
721
|
+
# Inside the begin block so a fork_subagent failure (e.g. skill-declared model
|
|
722
|
+
# not found) still reaches the ensure block that stops the progress spinner.
|
|
723
|
+
inject_skill_command_as_assistant_message(skill_command, task_id)
|
|
724
|
+
|
|
725
|
+
@hooks.trigger(:on_start, user_input)
|
|
726
|
+
|
|
692
727
|
# Track if ask_user was called
|
|
693
728
|
awaiting_user_feedback = false
|
|
729
|
+
# Heuristic sibling of the above: the reply merely ended with a question
|
|
730
|
+
# mark. Kept separate because it must never reach build_result — the
|
|
731
|
+
# session status it feeds shows a "waiting" badge to the user, and a
|
|
732
|
+
# rhetorical closing question is not a request for input.
|
|
733
|
+
turn_unfinished = false
|
|
694
734
|
# Track if task was interrupted by user (denied tool execution)
|
|
695
735
|
task_interrupted = false
|
|
696
736
|
|
|
@@ -795,7 +835,7 @@ module Clacky
|
|
|
795
835
|
)
|
|
796
836
|
end
|
|
797
837
|
if response[:content] && !response[:content].empty?
|
|
798
|
-
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
|
|
838
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], created_at: response[:created_at])
|
|
799
839
|
end
|
|
800
840
|
|
|
801
841
|
# Show token usage after the assistant message so WebUI renders it below the bubble
|
|
@@ -814,14 +854,14 @@ module Clacky
|
|
|
814
854
|
# If the assistant ended its turn with a question, treat this as
|
|
815
855
|
# an in-flight conversation (agent is awaiting the user's reply)
|
|
816
856
|
# and skip skill evolution — the task isn't truly complete yet.
|
|
817
|
-
|
|
857
|
+
turn_unfinished = true if ends_with_question
|
|
818
858
|
|
|
819
859
|
break
|
|
820
860
|
end
|
|
821
861
|
|
|
822
862
|
# Show assistant message if there's content before tool calls
|
|
823
863
|
if response[:content] && !response[:content].empty?
|
|
824
|
-
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true)
|
|
864
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true, created_at: response[:created_at])
|
|
825
865
|
end
|
|
826
866
|
|
|
827
867
|
# Show token usage after assistant message (or immediately if no message).
|
|
@@ -873,7 +913,7 @@ module Clacky
|
|
|
873
913
|
# Run skill evolution hooks after main loop completes
|
|
874
914
|
# Skip if task was interrupted by user (denied tool) or awaiting user feedback
|
|
875
915
|
# Only for main agent (not subagents) to avoid recursive evolution
|
|
876
|
-
unless @is_subagent || task_interrupted || awaiting_user_feedback
|
|
916
|
+
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
877
917
|
run_skill_evolution_hooks
|
|
878
918
|
end
|
|
879
919
|
|
|
@@ -884,7 +924,7 @@ module Clacky
|
|
|
884
924
|
# and only then [OK] Task Complete is printed. No cleanup dance,
|
|
885
925
|
# no cross-method progress handle holding.
|
|
886
926
|
# Skip on interrupt / feedback / subagent (self-guarded inside too).
|
|
887
|
-
unless @is_subagent || task_interrupted || awaiting_user_feedback
|
|
927
|
+
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
888
928
|
run_memory_update_subagent
|
|
889
929
|
end
|
|
890
930
|
|
|
@@ -892,6 +932,7 @@ module Clacky
|
|
|
892
932
|
# Parent agent (skill_manager) prints the completion summary; skip here.
|
|
893
933
|
else
|
|
894
934
|
@ui&.show_complete(
|
|
935
|
+
task_id: result[:task_id],
|
|
895
936
|
iterations: result[:iterations],
|
|
896
937
|
cost: result[:total_cost_usd],
|
|
897
938
|
cost_source: result[:cost_source],
|
|
@@ -915,6 +956,10 @@ module Clacky
|
|
|
915
956
|
|
|
916
957
|
result
|
|
917
958
|
rescue Clacky::AgentInterrupted
|
|
959
|
+
# A cancelled fan-out captured its subagents' progress but never reached
|
|
960
|
+
# observe() to persist it — anchor those trails now so a page reload
|
|
961
|
+
# after the interrupt still shows what the subagents did.
|
|
962
|
+
flush_pending_subagent_transcripts_on_interrupt
|
|
918
963
|
# Mark this run as interrupted so the next run() (e.g. user's
|
|
919
964
|
# supplementary message during a running task) keeps the existing
|
|
920
965
|
# task-start snapshot — the completion summary should reflect the
|
|
@@ -1085,7 +1130,8 @@ module Clacky
|
|
|
1085
1130
|
"- Break down large tasks into multiple smaller tool calls\n" \
|
|
1086
1131
|
"- Keep each tool call argument under 2000 characters\n" \
|
|
1087
1132
|
"- Use multiple tool calls instead of one large call",
|
|
1088
|
-
truncated: true
|
|
1133
|
+
truncated: true,
|
|
1134
|
+
system_injected: true
|
|
1089
1135
|
})
|
|
1090
1136
|
|
|
1091
1137
|
# Close the current spinner so the warning appears cleanly;
|
|
@@ -1098,7 +1144,11 @@ module Clacky
|
|
|
1098
1144
|
end
|
|
1099
1145
|
|
|
1100
1146
|
# Add assistant response to history
|
|
1101
|
-
|
|
1147
|
+
created_at = Time.now.to_f
|
|
1148
|
+
msg = { role: "assistant", task_id: @current_task_id, created_at: created_at }
|
|
1149
|
+
# Surface the storage timestamp to the caller so the live UI emit uses
|
|
1150
|
+
# the same created_at that replay will later read back from history.
|
|
1151
|
+
response[:created_at] = created_at
|
|
1102
1152
|
# Always include content field (some APIs require it even with tool_calls)
|
|
1103
1153
|
# Use empty string instead of null for better compatibility
|
|
1104
1154
|
msg[:content] = response[:content] || ""
|
|
@@ -1145,6 +1195,7 @@ module Clacky
|
|
|
1145
1195
|
# to history — guarantees a stale thread cannot corrupt history with
|
|
1146
1196
|
# tool messages that no longer have a matching assistant tool_calls.
|
|
1147
1197
|
private def check_stale!
|
|
1198
|
+
raise Clacky::AgentInterrupted, "Fan-out batch cancelled by a newer task" if @cancel_flag&.cancelled?
|
|
1148
1199
|
return unless @task_thread
|
|
1149
1200
|
return if Thread.current == @task_thread
|
|
1150
1201
|
raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
|
|
@@ -1488,6 +1539,26 @@ module Clacky
|
|
|
1488
1539
|
end
|
|
1489
1540
|
end
|
|
1490
1541
|
|
|
1542
|
+
# Interrupt-path counterpart to attach_pending_subagent_transcripts: a
|
|
1543
|
+
# cancelled fan-out never reaches observe(), so its captured job trails
|
|
1544
|
+
# would otherwise die in @pending_subagent_transcripts (memory only) and
|
|
1545
|
+
# vanish on the next page reload. Anchor whatever was captured onto the
|
|
1546
|
+
# last history message so it persists to session.json and replays.
|
|
1547
|
+
private def flush_pending_subagent_transcripts_on_interrupt
|
|
1548
|
+
pending = @subagent_transcripts_mutex.synchronize do
|
|
1549
|
+
snapshot = @pending_subagent_transcripts.dup
|
|
1550
|
+
@pending_subagent_transcripts.clear
|
|
1551
|
+
snapshot
|
|
1552
|
+
end
|
|
1553
|
+
return if pending.empty?
|
|
1554
|
+
|
|
1555
|
+
by_id = {}
|
|
1556
|
+
pending.each { |id, trails| by_id[id] = Array(trails).sort_by { |t| t[:index] || 0 } }
|
|
1557
|
+
@history.settle_interrupted_tool_calls(by_id)
|
|
1558
|
+
rescue StandardError => e
|
|
1559
|
+
Clacky::Logger.warn("agent.flush_subagent_transcripts_failed", error: e.message)
|
|
1560
|
+
end
|
|
1561
|
+
|
|
1491
1562
|
# Cap oversized tool result content to keep a single tool message from
|
|
1492
1563
|
# blowing up the prompt budget (issue #218: a 7350-path glob produced a
|
|
1493
1564
|
# ~890k-char result that pushed history past the model context window
|
|
@@ -1611,6 +1682,7 @@ module Clacky
|
|
|
1611
1682
|
{
|
|
1612
1683
|
status: status,
|
|
1613
1684
|
session_id: @session_id,
|
|
1685
|
+
task_id: @current_task_id,
|
|
1614
1686
|
model: current_model,
|
|
1615
1687
|
provider: current_provider,
|
|
1616
1688
|
iterations: task_iterations,
|
|
@@ -1773,7 +1845,8 @@ module Clacky
|
|
|
1773
1845
|
end
|
|
1774
1846
|
end
|
|
1775
1847
|
|
|
1776
|
-
Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
|
|
1848
|
+
Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
|
|
1849
|
+
.run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
|
|
1777
1850
|
end
|
|
1778
1851
|
|
|
1779
1852
|
private def within_phase(label, kind:, concurrent:, &block)
|
|
@@ -1899,6 +1972,10 @@ module Clacky
|
|
|
1899
1972
|
)
|
|
1900
1973
|
subagent.instance_variable_set(:@is_subagent, true)
|
|
1901
1974
|
|
|
1975
|
+
# Share the parent's cancel flag so a fan-out interrupt reaches this
|
|
1976
|
+
# subagent on its worker thread — its own check_stale! polls the same flag.
|
|
1977
|
+
subagent.instance_variable_set(:@cancel_flag, @cancel_flag)
|
|
1978
|
+
|
|
1902
1979
|
# Inherit previous_total_tokens so the first iteration delta is calculated correctly
|
|
1903
1980
|
subagent.instance_variable_set(:@previous_total_tokens, @previous_total_tokens)
|
|
1904
1981
|
|
|
@@ -2400,7 +2477,7 @@ module Clacky
|
|
|
2400
2477
|
# and cannot load file:// directly) and must stay scoped to the Web UI
|
|
2401
2478
|
# controller. IM channel subscribers need the original file:// markdown so
|
|
2402
2479
|
# parse_file_links can extract paths and deliver images as native attachments.
|
|
2403
|
-
private def emit_assistant_message(content, reasoning_content: nil, interim: false)
|
|
2480
|
+
private def emit_assistant_message(content, reasoning_content: nil, interim: false, created_at: nil)
|
|
2404
2481
|
# Prepend reasoning/thinking content (from thinking-mode providers like
|
|
2405
2482
|
# DeepSeek V4, Kimi K2) wrapped in <think> tags so the Web UI renders it
|
|
2406
2483
|
# as a collapsible thinking block (see sessions.js _renderMarkdown).
|
|
@@ -2413,7 +2490,7 @@ module Clacky
|
|
|
2413
2490
|
return if full_content.nil? || full_content.to_s.strip.empty?
|
|
2414
2491
|
|
|
2415
2492
|
parsed = parse_file_links(content)
|
|
2416
|
-
@ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim)
|
|
2493
|
+
@ui&.show_assistant_message(full_content, files: parsed[:files], interim: interim, created_at: created_at)
|
|
2417
2494
|
end
|
|
2418
2495
|
|
|
2419
2496
|
# Record BEFORE-change snapshots for any file a tool is about to mutate,
|
|
@@ -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
|
|