openclacky 1.5.5 → 1.5.7
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/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +58 -0
- data/CONTRIBUTING.md +9 -0
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +162 -25
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +312 -22
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +19 -15
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/extension/api_extension.rb +37 -3
- data/lib/clacky/extension/packager.rb +54 -5
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/idle_compression_timer.rb +1 -3
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +15 -13
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +136 -37
- data/lib/clacky/server/session_registry.rb +21 -18
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +13 -11
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/tools/web_search.rb +45 -6
- data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
- data/lib/clacky/ui2/components/input_area.rb +26 -2
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +223 -6
- data/lib/clacky/ui_interface.rb +22 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/gitignore_parser.rb +7 -1
- data/lib/clacky/utils/model_pricing.rb +21 -10
- data/lib/clacky/utils/searcher_manager.rb +87 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +243 -33
- data/lib/clacky/web/app.js +12 -1
- data/lib/clacky/web/components/chat-navigator.js +260 -0
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/brand/view.js +5 -1
- data/lib/clacky/web/features/extensions/view.js +19 -19
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/features/skills/store.js +4 -2
- data/lib/clacky/web/i18n.js +64 -14
- data/lib/clacky/web/index.html +40 -9
- data/lib/clacky/web/projects.js +62 -19
- data/lib/clacky/web/sessions.js +329 -115
- data/lib/clacky/web/settings.js +86 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/ws-dispatcher.js +86 -35
- data/lib/clacky.rb +4 -0
- metadata +18 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
3
5
|
module Clacky
|
|
4
6
|
class Agent
|
|
5
7
|
# Session serialization for saving and restoring agent state
|
|
@@ -11,7 +13,6 @@ module Clacky
|
|
|
11
13
|
@session_id = session_data[:session_id]
|
|
12
14
|
@name = session_data[:name] || ""
|
|
13
15
|
@pinned = session_data[:pinned] || false
|
|
14
|
-
@hidden = session_data[:hidden] || false
|
|
15
16
|
@history = MessageHistory.new(session_data[:messages] || [])
|
|
16
17
|
@todos = session_data[:todos] || [] # Restore todos from session
|
|
17
18
|
@iterations = session_data.dig(:stats, :total_iterations) || 0
|
|
@@ -33,6 +34,12 @@ module Clacky
|
|
|
33
34
|
# Restore project association (nil = no project)
|
|
34
35
|
@project_id = session_data[:project_id]
|
|
35
36
|
|
|
37
|
+
# Restore standing goal state (nil = no goal). The judge client/model
|
|
38
|
+
# are rebound lazily via #goal_manager when first accessed after
|
|
39
|
+
# restore — but the persisted state must be reattached here so a
|
|
40
|
+
# restored active goal keeps its progress and continues.
|
|
41
|
+
restore_goal_state(session_data[:goal])
|
|
42
|
+
|
|
36
43
|
# Restore cache statistics if available
|
|
37
44
|
@cache_stats = session_data.dig(:stats, :cache_stats) || {
|
|
38
45
|
cache_creation_input_tokens: 0,
|
|
@@ -120,6 +127,22 @@ module Clacky
|
|
|
120
127
|
refresh_system_prompt
|
|
121
128
|
end
|
|
122
129
|
|
|
130
|
+
# Reattach persisted goal state after a restore. Builds a fresh
|
|
131
|
+
# GoalManager (bound to this agent's judge client/model) and injects the
|
|
132
|
+
# deserialized GoalState so a restored active goal keeps its progress.
|
|
133
|
+
private def restore_goal_state(goal_data)
|
|
134
|
+
return if goal_data.nil? || goal_data.empty?
|
|
135
|
+
|
|
136
|
+
state = GoalState.from_h(goal_data)
|
|
137
|
+
@goal_manager = GoalManager.new(
|
|
138
|
+
judge_client: @client,
|
|
139
|
+
judge_model: judge_model_name,
|
|
140
|
+
state: state
|
|
141
|
+
)
|
|
142
|
+
rescue => e
|
|
143
|
+
Clacky::Logger.warn("restore_goal_state failed: #{e.message}")
|
|
144
|
+
end
|
|
145
|
+
|
|
123
146
|
# Fill missing entries in @task_meta from @history (for sessions saved
|
|
124
147
|
# before task_meta existed, or for tasks whose meta was lost). The first
|
|
125
148
|
# real user message of each task supplies the title; created_at becomes
|
|
@@ -168,7 +191,7 @@ module Clacky
|
|
|
168
191
|
latest_path = chunks.last[:path]
|
|
169
192
|
broken.each { |m| m[:chunk_path] = latest_path }
|
|
170
193
|
|
|
171
|
-
session_manager.save(to_session_data
|
|
194
|
+
session_manager.save(to_session_data)
|
|
172
195
|
rescue => e
|
|
173
196
|
Clacky::Logger.warn("heal_missing_chunk_paths! failed: #{e.message}")
|
|
174
197
|
end
|
|
@@ -183,7 +206,7 @@ module Clacky
|
|
|
183
206
|
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
184
207
|
# @param error_message [String] Error message if status is :error
|
|
185
208
|
# @return [Hash] Session data ready for serialization
|
|
186
|
-
def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil
|
|
209
|
+
def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil)
|
|
187
210
|
stats_data = {
|
|
188
211
|
total_tasks: @total_tasks,
|
|
189
212
|
total_iterations: @iterations,
|
|
@@ -204,9 +227,8 @@ module Clacky
|
|
|
204
227
|
session_id: @session_id,
|
|
205
228
|
name: @name,
|
|
206
229
|
pinned: @pinned,
|
|
207
|
-
hidden: @hidden,
|
|
208
230
|
created_at: @created_at,
|
|
209
|
-
updated_at: (updated_at ||
|
|
231
|
+
updated_at: (updated_at || @persisted_updated_at || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
|
|
210
232
|
working_dir: @working_dir,
|
|
211
233
|
source: @source.to_s, # "manual" | "cron" | "channel" | "setup"
|
|
212
234
|
agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom
|
|
@@ -241,6 +263,7 @@ module Clacky
|
|
|
241
263
|
},
|
|
242
264
|
channel_info: @channel_info,
|
|
243
265
|
project_id: @project_id,
|
|
266
|
+
goal: @goal_manager&.to_h,
|
|
244
267
|
stats: stats_data,
|
|
245
268
|
messages: @history.to_a
|
|
246
269
|
}
|
|
@@ -370,6 +393,7 @@ module Clacky
|
|
|
370
393
|
all_files = image_files + disk_files
|
|
371
394
|
ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
|
|
372
395
|
editable: round[:editable] != false)
|
|
396
|
+
replay_ext_events(msg, ui)
|
|
373
397
|
|
|
374
398
|
round[:events].each do |ev|
|
|
375
399
|
# Skip system-injected messages (e.g. synthetic skill content, memory prompts)
|
|
@@ -499,6 +523,7 @@ module Clacky
|
|
|
499
523
|
|
|
500
524
|
sections.each do |sec|
|
|
501
525
|
text = sec[:lines].join.strip
|
|
526
|
+
text, sec_ext_events = extract_ext_events_from_text(text)
|
|
502
527
|
|
|
503
528
|
# Nested chunk: expand it recursively, prepend before current rounds
|
|
504
529
|
if sec[:nested_chunk]
|
|
@@ -511,7 +536,7 @@ module Clacky
|
|
|
511
536
|
next
|
|
512
537
|
end
|
|
513
538
|
|
|
514
|
-
next if text.empty?
|
|
539
|
+
next if text.empty? && sec_ext_events.empty?
|
|
515
540
|
|
|
516
541
|
if sec[:role] == "user"
|
|
517
542
|
round_index += 1
|
|
@@ -522,6 +547,7 @@ module Clacky
|
|
|
522
547
|
role: "user",
|
|
523
548
|
content: text,
|
|
524
549
|
created_at: synthetic_ts,
|
|
550
|
+
ext_events: sec_ext_events,
|
|
525
551
|
_from_chunk: true
|
|
526
552
|
},
|
|
527
553
|
events: [],
|
|
@@ -546,19 +572,7 @@ module Clacky
|
|
|
546
572
|
stripped = line.strip
|
|
547
573
|
if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
|
|
548
574
|
raw = m[1]
|
|
549
|
-
|
|
550
|
-
# Old format uses "," with no JSON part.
|
|
551
|
-
entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
|
|
552
|
-
entries.each do |entry|
|
|
553
|
-
entry = entry.strip
|
|
554
|
-
if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
|
|
555
|
-
tool_name = parts[1].strip
|
|
556
|
-
args = JSON.parse(parts[2]) rescue {}
|
|
557
|
-
pending_tool_entries << { name: tool_name, args: args }
|
|
558
|
-
else
|
|
559
|
-
pending_tool_entries << { name: entry, args: {} }
|
|
560
|
-
end
|
|
561
|
-
end
|
|
575
|
+
pending_tool_entries.concat(parse_tool_calls_line(raw))
|
|
562
576
|
else
|
|
563
577
|
remaining_lines << line
|
|
564
578
|
end
|
|
@@ -576,8 +590,11 @@ module Clacky
|
|
|
576
590
|
tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
|
|
577
591
|
}
|
|
578
592
|
end
|
|
593
|
+
|
|
594
|
+
attach_ext_events_to_last(current_round, sec_ext_events)
|
|
579
595
|
else
|
|
580
596
|
current_round[:events] << { role: "tool", content: text }
|
|
597
|
+
attach_ext_events_to_last(current_round, sec_ext_events)
|
|
581
598
|
end
|
|
582
599
|
end
|
|
583
600
|
end
|
|
@@ -589,6 +606,108 @@ module Clacky
|
|
|
589
606
|
end
|
|
590
607
|
|
|
591
608
|
|
|
609
|
+
# Pull the "_Ext events: type | {json}; ..._" line out of a chunk section,
|
|
610
|
+
# returning the remaining text plus the parsed events. Counterpart to
|
|
611
|
+
# MessageCompressorHelper#append_ext_events_line.
|
|
612
|
+
def extract_ext_events_from_text(text)
|
|
613
|
+
return [text, []] unless text.include?("_Ext events:")
|
|
614
|
+
|
|
615
|
+
events = []
|
|
616
|
+
kept = text.each_line.reject do |line|
|
|
617
|
+
m = line.strip.match(/\A_Ext events?:\s*(.+?)_?\z/i)
|
|
618
|
+
next false unless m
|
|
619
|
+
|
|
620
|
+
parse_piped_entries(m[1]).each do |entry|
|
|
621
|
+
next if entry[:args].nil?
|
|
622
|
+
|
|
623
|
+
events << { type: entry[:name], data: entry[:args] }
|
|
624
|
+
end
|
|
625
|
+
true
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
[kept.join.strip, events]
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Split a "_Tool calls:_" payload into entries. The separator ";" also
|
|
632
|
+
# occurs inside args JSON (shell commands, JS snippets), so entry
|
|
633
|
+
# boundaries are found by brace balancing rather than String#split.
|
|
634
|
+
def parse_tool_calls_line(raw)
|
|
635
|
+
parse_piped_entries(raw).map { |e| { name: e[:name], args: e[:args] || {} } }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Scan a "name | {json}; name | {json}" payload. Shared by the
|
|
639
|
+
# "_Tool calls:" and "_Ext events:" chunk MD lines, whose ";" separator
|
|
640
|
+
# is ambiguous with semicolons inside the JSON values. Entries with no
|
|
641
|
+
# JSON payload yield args: nil so callers can tell "absent" from "empty".
|
|
642
|
+
def parse_piped_entries(raw)
|
|
643
|
+
return [] if raw.nil?
|
|
644
|
+
|
|
645
|
+
scanner = StringScanner.new(raw)
|
|
646
|
+
entries = []
|
|
647
|
+
|
|
648
|
+
until scanner.eos?
|
|
649
|
+
scanner.skip(/[;,]?\s*/)
|
|
650
|
+
break if scanner.eos?
|
|
651
|
+
|
|
652
|
+
name = scanner.scan(/[^|;,]+?(?=\s*\|\s*\{)/)
|
|
653
|
+
unless name
|
|
654
|
+
plain = scanner.scan(/[^;,]+/)
|
|
655
|
+
break unless plain
|
|
656
|
+
|
|
657
|
+
plain = plain.strip
|
|
658
|
+
entries << { name: plain, args: nil } unless plain.empty?
|
|
659
|
+
next
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
scanner.skip(/\s*\|\s*/)
|
|
663
|
+
json = scan_balanced_json(scanner)
|
|
664
|
+
next unless json
|
|
665
|
+
|
|
666
|
+
parsed = JSON.parse(json) rescue nil
|
|
667
|
+
entries << { name: name.strip, args: parsed.is_a?(Hash) ? parsed : {} }
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
entries
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Consume one balanced {...} literal, ignoring braces inside JSON strings.
|
|
674
|
+
def scan_balanced_json(scanner)
|
|
675
|
+
start = scanner.pos
|
|
676
|
+
return nil unless scanner.getch == "{"
|
|
677
|
+
|
|
678
|
+
depth = 1
|
|
679
|
+
in_string = false
|
|
680
|
+
escaped = false
|
|
681
|
+
|
|
682
|
+
while (ch = scanner.getch)
|
|
683
|
+
if escaped
|
|
684
|
+
escaped = false
|
|
685
|
+
elsif ch == "\\"
|
|
686
|
+
escaped = true
|
|
687
|
+
elsif ch == '"'
|
|
688
|
+
in_string = !in_string
|
|
689
|
+
elsif !in_string && ch == "{"
|
|
690
|
+
depth += 1
|
|
691
|
+
elsif !in_string && ch == "}"
|
|
692
|
+
depth -= 1
|
|
693
|
+
return scanner.string[start...scanner.pos] if depth.zero?
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
nil
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Anchor chunk-restored events to the last message of the round so they
|
|
701
|
+
# replay after it, matching where they were emitted live.
|
|
702
|
+
def attach_ext_events_to_last(round, events)
|
|
703
|
+
return if events.nil? || events.empty?
|
|
704
|
+
|
|
705
|
+
target = round[:events].last || round[:user_msg]
|
|
706
|
+
return unless target
|
|
707
|
+
|
|
708
|
+
target[:ext_events] = Array(target[:ext_events]) + events
|
|
709
|
+
end
|
|
710
|
+
|
|
592
711
|
# Render a single non-user message into the UI.
|
|
593
712
|
# Used by both the normal round-based replay and the compressed-session fallback.
|
|
594
713
|
def _replay_single_message(msg, ui)
|
|
@@ -641,20 +760,38 @@ module Clacky
|
|
|
641
760
|
|
|
642
761
|
when "user"
|
|
643
762
|
# Anthropic-format tool results (role: user, content: array of tool_result blocks)
|
|
644
|
-
|
|
763
|
+
if msg[:content].is_a?(Array)
|
|
764
|
+
msg[:content].each do |blk|
|
|
765
|
+
next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
|
|
645
766
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
ui.show_tool_result(blk[:content].to_s)
|
|
767
|
+
ui.show_tool_result(blk[:content].to_s)
|
|
768
|
+
end
|
|
769
|
+
replay_subagent_transcript(msg, ui)
|
|
650
770
|
end
|
|
651
|
-
replay_subagent_transcript(msg, ui)
|
|
652
771
|
|
|
653
772
|
when "tool"
|
|
654
773
|
# OpenAI-format tool result
|
|
655
774
|
ui.show_tool_result(msg[:content].to_s)
|
|
656
775
|
replay_subagent_transcript(msg, ui)
|
|
657
776
|
end
|
|
777
|
+
|
|
778
|
+
replay_ext_events(msg, ui)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Re-emit custom extension events recorded on a message so extension
|
|
782
|
+
# panels can rebuild their state after a page reload. Mirrors what
|
|
783
|
+
# Agent#emit_event pushed live. No-op when the message carries none.
|
|
784
|
+
def replay_ext_events(msg, ui)
|
|
785
|
+
Array(msg[:ext_events]).each do |ev|
|
|
786
|
+
next unless ev.is_a?(Hash)
|
|
787
|
+
|
|
788
|
+
type = ev[:type] || ev["type"]
|
|
789
|
+
next if type.nil? || type.to_s.empty?
|
|
790
|
+
|
|
791
|
+
data = ev[:data] || ev["data"] || {}
|
|
792
|
+
data = data.is_a?(Hash) ? data.transform_keys(&:to_sym) : {}
|
|
793
|
+
ui.emit(type.to_s, **data)
|
|
794
|
+
end
|
|
658
795
|
end
|
|
659
796
|
|
|
660
797
|
# Replay a subagent transcript stored on a tool result message. Emits a
|
|
@@ -20,11 +20,10 @@ module Clacky
|
|
|
20
20
|
def maybe_create_skill_from_task
|
|
21
21
|
return unless should_auto_create_skill?
|
|
22
22
|
|
|
23
|
-
@ui&.show_info("Analyzing task for skill creation opportunity...")
|
|
24
|
-
|
|
25
23
|
# Fork an isolated subagent to evaluate + create — does NOT touch main history
|
|
26
24
|
subagent = fork_subagent
|
|
27
25
|
subagent.run(build_skill_creation_prompt)
|
|
26
|
+
nil
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
# Determine if this task is a candidate for skill auto-creation
|
|
@@ -26,10 +26,11 @@ module Clacky
|
|
|
26
26
|
def run_skill_evolution_hooks
|
|
27
27
|
return unless skill_evolution_enabled?
|
|
28
28
|
return if @is_subagent
|
|
29
|
-
return unless skill_evolution_visible? || skill_evolution_has_work?
|
|
30
29
|
|
|
31
30
|
with_skill_evolution_phase do
|
|
32
|
-
if
|
|
31
|
+
if !skill_evolution_has_work?
|
|
32
|
+
Clacky::I18n.t("phase.skipped")
|
|
33
|
+
elsif @skill_execution_context
|
|
33
34
|
maybe_reflect_on_skill
|
|
34
35
|
else
|
|
35
36
|
maybe_create_skill_from_task
|
|
@@ -37,10 +38,6 @@ module Clacky
|
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
private def skill_evolution_visible?
|
|
41
|
-
@config.respond_to?(:verbose) && @config.verbose
|
|
42
|
-
end
|
|
43
|
-
|
|
44
41
|
private def skill_evolution_has_work?
|
|
45
42
|
if @skill_execution_context
|
|
46
43
|
should_reflect_on_skill?
|
|
@@ -52,7 +49,14 @@ module Clacky
|
|
|
52
49
|
private def with_skill_evolution_phase
|
|
53
50
|
return yield unless @ui.respond_to?(:with_phase)
|
|
54
51
|
|
|
55
|
-
@ui.
|
|
52
|
+
pid = @ui.phase_start(kind: "skill_evolution", label: Clacky::I18n.t("phase.skill_evolution"))
|
|
53
|
+
summary = nil
|
|
54
|
+
begin
|
|
55
|
+
summary = yield
|
|
56
|
+
ensure
|
|
57
|
+
@ui.phase_end(pid, summary: summary)
|
|
58
|
+
end
|
|
59
|
+
summary
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
# Check if skill evolution is enabled in config
|
|
@@ -576,8 +576,10 @@ module Clacky
|
|
|
576
576
|
# Execute a skill in a forked subagent
|
|
577
577
|
# @param skill [Skill] The skill to execute
|
|
578
578
|
# @param arguments [String] Arguments for the skill
|
|
579
|
+
# @param tool_call_id [String, nil] The invoke_skill call this run belongs to;
|
|
580
|
+
# nil for the slash-command path, which has no tool result to attach to.
|
|
579
581
|
# @return [String] Summary of subagent execution
|
|
580
|
-
def execute_skill_with_subagent(skill, arguments)
|
|
582
|
+
def execute_skill_with_subagent(skill, arguments, tool_call_id: nil)
|
|
581
583
|
touch_skill_for_lru(skill)
|
|
582
584
|
# For encrypted brand skills with supporting scripts: decrypt to a tmpdir.
|
|
583
585
|
# Subagent path has a clear boundary (subagent.run returns), so we shred inline
|
|
@@ -603,17 +605,22 @@ module Clacky
|
|
|
603
605
|
system_prompt_suffix: skill_instructions
|
|
604
606
|
)
|
|
605
607
|
|
|
606
|
-
# Log which model the subagent is actually using (may differ from requested
|
|
607
|
-
# when "lite" falls back to default due to no lite model configured)
|
|
608
|
-
@ui&.show_info("Subagent start: #{skill.identifier}#{skill.name_zh.to_s.empty? ? "" : " (#{skill.name_zh})"} [#{subagent.current_model_info[:model]}]")
|
|
609
|
-
|
|
610
608
|
# Run subagent with the actual task as the sole user turn.
|
|
611
609
|
# If the user typed the skill command with no arguments (e.g. "/jade-appraisal"),
|
|
612
610
|
# use a generic trigger phrase so the user message is never empty.
|
|
613
611
|
task_input = arguments.to_s.strip.empty? ? "Please proceed." : arguments
|
|
614
612
|
|
|
615
613
|
begin
|
|
616
|
-
|
|
614
|
+
# The model may differ from the requested one ("lite" falls back to
|
|
615
|
+
# default when no lite model is configured), so name it on the card.
|
|
616
|
+
phase_label = "#{skill.identifier} · #{subagent.current_model_info[:model]}"
|
|
617
|
+
|
|
618
|
+
result = within_phase(phase_label, kind: "subagent", concurrent: false) do
|
|
619
|
+
run_result = subagent.run(task_input)
|
|
620
|
+
cost = absorb_subagent_cost(run_result)
|
|
621
|
+
@ui&.show_info("Completed in #{run_result[:iterations]} iterations · $#{cost.round(4)}")
|
|
622
|
+
run_result
|
|
623
|
+
end
|
|
617
624
|
rescue Clacky::AgentInterrupted
|
|
618
625
|
# Subagent was interrupted by user (Ctrl+C).
|
|
619
626
|
# Write an interrupted summary into history so the parent agent's history
|
|
@@ -642,9 +649,13 @@ module Clacky
|
|
|
642
649
|
# Capture the subagent's own message trail (everything it appended after
|
|
643
650
|
# the fork) so the parent session can persist and later replay the full
|
|
644
651
|
# subagent process — not just the collapsed summary. Stored transiently
|
|
645
|
-
# on the parent agent
|
|
646
|
-
#
|
|
647
|
-
|
|
652
|
+
# on the parent agent, keyed by the invoke_skill call it belongs to;
|
|
653
|
+
# observe() attaches each trail to its own tool result message.
|
|
654
|
+
# Kept out of the LLM payload via INTERNAL_FIELDS.
|
|
655
|
+
if tool_call_id
|
|
656
|
+
@pending_subagent_transcripts[tool_call_id] =
|
|
657
|
+
extract_subagent_transcript(subagent, skill.identifier)
|
|
658
|
+
end
|
|
648
659
|
|
|
649
660
|
# Mutate the subagent_instructions message in-place to become the result summary
|
|
650
661
|
@history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
|
|
@@ -654,15 +665,6 @@ module Clacky
|
|
|
654
665
|
m[:skill_name] = skill.identifier
|
|
655
666
|
end
|
|
656
667
|
|
|
657
|
-
# Merge subagent cost into parent agent's total so the sessionbar reflects
|
|
658
|
-
# the real cumulative spend across all subagents
|
|
659
|
-
subagent_cost = result[:total_cost_usd] || 0.0
|
|
660
|
-
@total_cost += subagent_cost
|
|
661
|
-
@ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
|
|
662
|
-
|
|
663
|
-
# Log completion
|
|
664
|
-
@ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{subagent_cost.round(4)} (total: $#{@total_cost.round(4)})")
|
|
665
|
-
|
|
666
668
|
# Return summary as the skill execution result
|
|
667
669
|
summary
|
|
668
670
|
end
|
|
@@ -23,17 +23,14 @@ module Clacky
|
|
|
23
23
|
|
|
24
24
|
skill_name = @skill_execution_context[:skill_name]
|
|
25
25
|
|
|
26
|
-
@ui&.show_info("Reflecting on skill execution: #{skill_name}")
|
|
27
26
|
subagent = fork_subagent
|
|
28
27
|
result = subagent.run(build_skill_reflection_prompt(skill_name))
|
|
29
28
|
|
|
30
|
-
if result
|
|
31
|
-
subagent_cost = result[:total_cost_usd] || 0.0
|
|
32
|
-
@total_cost += subagent_cost
|
|
33
|
-
@ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
|
|
34
|
-
end
|
|
29
|
+
cost = absorb_subagent_cost(result) if result
|
|
35
30
|
|
|
36
31
|
@skill_execution_context = nil
|
|
32
|
+
|
|
33
|
+
"#{skill_name} · $#{cost.round(4)}" if cost
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
private def should_reflect_on_skill?
|