swarm_cli 2.1.12 → 3.0.0.alpha2
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/LICENSE +21 -0
- data/exe/swarm3 +11 -0
- data/lib/swarm_cli/v3/activity_indicator.rb +168 -0
- data/lib/swarm_cli/v3/ansi_colors.rb +70 -0
- data/lib/swarm_cli/v3/cli.rb +721 -0
- data/lib/swarm_cli/v3/command_completer.rb +112 -0
- data/lib/swarm_cli/v3/display.rb +607 -0
- data/lib/swarm_cli/v3/dropdown.rb +130 -0
- data/lib/swarm_cli/v3/event_renderer.rb +161 -0
- data/lib/swarm_cli/v3/file_completer.rb +143 -0
- data/lib/swarm_cli/v3/raw_input_reader.rb +304 -0
- data/lib/swarm_cli/v3/reboot_tool.rb +123 -0
- data/lib/swarm_cli/v3/text_input.rb +235 -0
- data/lib/swarm_cli/v3.rb +52 -0
- metadata +30 -245
- data/exe/swarm +0 -6
- data/lib/swarm_cli/cli.rb +0 -201
- data/lib/swarm_cli/command_registry.rb +0 -61
- data/lib/swarm_cli/commands/mcp_serve.rb +0 -130
- data/lib/swarm_cli/commands/mcp_tools.rb +0 -148
- data/lib/swarm_cli/commands/migrate.rb +0 -55
- data/lib/swarm_cli/commands/run.rb +0 -173
- data/lib/swarm_cli/config_loader.rb +0 -98
- data/lib/swarm_cli/formatters/human_formatter.rb +0 -811
- data/lib/swarm_cli/formatters/json_formatter.rb +0 -62
- data/lib/swarm_cli/interactive_repl.rb +0 -924
- data/lib/swarm_cli/mcp_serve_options.rb +0 -44
- data/lib/swarm_cli/mcp_tools_options.rb +0 -59
- data/lib/swarm_cli/migrate_options.rb +0 -54
- data/lib/swarm_cli/migrator.rb +0 -132
- data/lib/swarm_cli/options.rb +0 -151
- data/lib/swarm_cli/ui/components/agent_badge.rb +0 -33
- data/lib/swarm_cli/ui/components/content_block.rb +0 -120
- data/lib/swarm_cli/ui/components/divider.rb +0 -57
- data/lib/swarm_cli/ui/components/panel.rb +0 -62
- data/lib/swarm_cli/ui/components/usage_stats.rb +0 -70
- data/lib/swarm_cli/ui/formatters/cost.rb +0 -49
- data/lib/swarm_cli/ui/formatters/number.rb +0 -58
- data/lib/swarm_cli/ui/formatters/text.rb +0 -77
- data/lib/swarm_cli/ui/formatters/time.rb +0 -73
- data/lib/swarm_cli/ui/icons.rb +0 -36
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +0 -188
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +0 -45
- data/lib/swarm_cli/ui/state/depth_tracker.rb +0 -40
- data/lib/swarm_cli/ui/state/spinner_manager.rb +0 -170
- data/lib/swarm_cli/ui/state/usage_tracker.rb +0 -62
- data/lib/swarm_cli/version.rb +0 -5
- data/lib/swarm_cli.rb +0 -46
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Entry point for the V3 agent chat CLI.
|
|
6
|
+
#
|
|
7
|
+
# Supports three run modes:
|
|
8
|
+
# - **Interactive**: REPL with always-available input, history
|
|
9
|
+
# - **Prompt**: Run a single prompt and exit (non-interactive)
|
|
10
|
+
# - **Maintenance**: Run memory defrag and exit
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# SwarmCLI::V3::CLI.start(ARGV)
|
|
14
|
+
class CLI
|
|
15
|
+
HISTORY_FILE = File.expand_path(ENV.fetch("SWARM_V3_HISTORY", "~/.swarm/v3_history"))
|
|
16
|
+
HISTORY_SIZE = 1000
|
|
17
|
+
CONFIG_FILE = File.expand_path("~/.config/swarm/cli.json")
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Parse arguments and run the appropriate mode.
|
|
21
|
+
#
|
|
22
|
+
# @param argv [Array<String>] command-line arguments
|
|
23
|
+
# @return [void]
|
|
24
|
+
def start(argv)
|
|
25
|
+
new(argv).run
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param argv [Array<String>] command-line arguments
|
|
30
|
+
def initialize(argv)
|
|
31
|
+
@original_argv = strip_reboot_flag(argv.dup).freeze
|
|
32
|
+
@argv = argv.dup
|
|
33
|
+
@options = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Parse arguments, load agent, and dispatch to the appropriate mode.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def run
|
|
40
|
+
parse_options!
|
|
41
|
+
configure_providers!
|
|
42
|
+
agent = load_agent(@argv.first)
|
|
43
|
+
register_reboot_tool!
|
|
44
|
+
|
|
45
|
+
handle_reboot_continuation(agent) if @options[:reboot_from]
|
|
46
|
+
|
|
47
|
+
if @options[:defrag]
|
|
48
|
+
run_maintenance(agent)
|
|
49
|
+
elsif @options[:prompt] && !@options[:reboot_from]
|
|
50
|
+
run_prompt(agent)
|
|
51
|
+
else
|
|
52
|
+
run_interactive(agent)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Expand @file mentions to include file contents.
|
|
57
|
+
# This is a public utility method that can be used for testing.
|
|
58
|
+
#
|
|
59
|
+
# @param prompt [String] the prompt text with @file mentions
|
|
60
|
+
# @param display [Display, nil] optional display to show read status
|
|
61
|
+
# @return [String] the expanded prompt with file contents
|
|
62
|
+
def expand_file_mentions(prompt, display: nil)
|
|
63
|
+
prompt.gsub(%r{@([\w/.~-]+)}) do |match|
|
|
64
|
+
path = Regexp.last_match(1)
|
|
65
|
+
|
|
66
|
+
if File.file?(path)
|
|
67
|
+
content = File.read(path, encoding: "UTF-8")
|
|
68
|
+
display&.agent_print(ANSIColors.dim(" \u{1F4C4} Read @#{path}"))
|
|
69
|
+
"\n\n<file path=\"#{path}\">\n#{content}\n</file>\n"
|
|
70
|
+
elsif File.directory?(path)
|
|
71
|
+
entries = Dir.children(path).sort
|
|
72
|
+
display&.agent_print(ANSIColors.dim(" \u{1F4C2} Read @#{path}/ (#{entries.size} entries)"))
|
|
73
|
+
"\n\n<directory path=\"#{path}\">\n#{entries.join("\n")}\n</directory>\n"
|
|
74
|
+
else
|
|
75
|
+
match # Keep original if path doesn't exist
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError => _e
|
|
78
|
+
match # Keep original on error
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# ----------------------------------------------------------------
|
|
85
|
+
# Argument parsing
|
|
86
|
+
# ----------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def parse_options!
|
|
89
|
+
parser = OptionParser.new do |opts|
|
|
90
|
+
opts.banner = "Usage: swarm3 <agent_definition.rb> [options]"
|
|
91
|
+
|
|
92
|
+
opts.on("-p", "--prompt PROMPT", "Run a single prompt and exit") do |p|
|
|
93
|
+
@options[:prompt] = p
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
opts.on("--defrag", "Run memory maintenance and exit") do
|
|
97
|
+
@options[:defrag] = true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
opts.on("--reboot-from PID", Integer, "Internal: consume reboot signal") do |pid|
|
|
101
|
+
@options[:reboot_from] = pid
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
opts.on("-h", "--help", "Show this help") do
|
|
105
|
+
puts opts
|
|
106
|
+
exit
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
parser.parse!(@argv)
|
|
111
|
+
|
|
112
|
+
return unless @argv.empty?
|
|
113
|
+
|
|
114
|
+
warn(parser.banner)
|
|
115
|
+
warn("Example: swarm3 experiments/v3/agents/example.rb")
|
|
116
|
+
warn(" swarm3 experiments/v3/agents/example.rb -p \"Hello\"")
|
|
117
|
+
warn(" swarm3 experiments/v3/agents/example.rb --defrag")
|
|
118
|
+
exit(1)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def strip_reboot_flag(args)
|
|
122
|
+
if (idx = args.index("--reboot-from"))
|
|
123
|
+
args.slice!(idx, 2)
|
|
124
|
+
end
|
|
125
|
+
args
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ----------------------------------------------------------------
|
|
129
|
+
# Configuration & loading
|
|
130
|
+
# ----------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def configure_providers!
|
|
133
|
+
SwarmSDK::V3.configure do |config|
|
|
134
|
+
config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] if ENV["ANTHROPIC_API_KEY"]
|
|
135
|
+
config.openai_api_key = ENV["OPENAI_API_KEY"] if ENV["OPENAI_API_KEY"]
|
|
136
|
+
config.gemini_api_key = ENV["GEMINI_API_KEY"] if ENV["GEMINI_API_KEY"]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def load_agent(path)
|
|
141
|
+
unless path && File.exist?(path)
|
|
142
|
+
warn("File not found: #{path}")
|
|
143
|
+
exit(1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
agent = eval(File.read(path), binding, path) # rubocop:disable Security/Eval
|
|
147
|
+
unless agent.is_a?(SwarmSDK::V3::Agent)
|
|
148
|
+
warn("File must return a SwarmSDK::V3::Agent (got #{agent.class})")
|
|
149
|
+
exit(1)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
agent
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def register_reboot_tool!
|
|
156
|
+
SwarmSDK::V3::Tools::Registry.register(:Reboot, RebootTool)
|
|
157
|
+
RebootTool.session_pid = Process.pid
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ----------------------------------------------------------------
|
|
161
|
+
# Non-interactive mode
|
|
162
|
+
# ----------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def run_prompt(agent)
|
|
165
|
+
expanded_prompt = expand_file_mentions(@options[:prompt])
|
|
166
|
+
ask_with_display(agent, expanded_prompt)
|
|
167
|
+
check_and_reboot!(agent)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def ask_with_display(agent, prompt)
|
|
171
|
+
full_content = +""
|
|
172
|
+
|
|
173
|
+
agent.ask(prompt) do |event|
|
|
174
|
+
case event[:type]
|
|
175
|
+
when "content_chunk"
|
|
176
|
+
full_content << event[:content] if event[:content]
|
|
177
|
+
else
|
|
178
|
+
line = EventRenderer.format(event)
|
|
179
|
+
puts line if line
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return if full_content.empty?
|
|
184
|
+
|
|
185
|
+
puts
|
|
186
|
+
puts ANSIColors.cyan("#{agent.name}> ")
|
|
187
|
+
puts TTY::Markdown.parse(full_content)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ----------------------------------------------------------------
|
|
191
|
+
# Maintenance mode
|
|
192
|
+
# ----------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def run_maintenance(agent, printer: method(:puts))
|
|
195
|
+
unless agent.definition.memory_enabled?
|
|
196
|
+
printer.call(ANSIColors.yellow("Memory not enabled for this agent."))
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
printer.call(ANSIColors.cyan("--- Running Maintenance ---"))
|
|
201
|
+
result = agent.defrag!
|
|
202
|
+
format_maintenance_result(result, agent.memory.stats).each { |line| printer.call(line) }
|
|
203
|
+
printer.call(ANSIColors.cyan("---------------------------"))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Format defrag results and memory stats into display lines.
|
|
207
|
+
#
|
|
208
|
+
# @param result [Hash] defrag result
|
|
209
|
+
# @param stats [Hash] memory stats
|
|
210
|
+
# @return [Array<String>] formatted lines
|
|
211
|
+
def format_maintenance_result(result, stats)
|
|
212
|
+
lines = [
|
|
213
|
+
" #{ANSIColors.bold("Duplicates merged:")} #{result[:duplicates_merged]}",
|
|
214
|
+
" #{ANSIColors.bold("Conflicts detected:")} #{result[:conflicts_detected]}",
|
|
215
|
+
" #{ANSIColors.bold("Cards compressed:")} #{result[:cards_compressed]}",
|
|
216
|
+
" #{ANSIColors.bold("Cards promoted:")} #{result[:cards_promoted]}",
|
|
217
|
+
" #{ANSIColors.bold("Cards pruned:")} #{result[:cards_pruned]}",
|
|
218
|
+
"",
|
|
219
|
+
ANSIColors.dim("Cards: #{stats[:total_cards]}, Clusters: #{stats[:total_clusters]}"),
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
if stats[:compression_levels].any?
|
|
223
|
+
levels = stats[:compression_levels].sort.map { |lvl, cnt| "L#{lvl}=#{cnt}" }.join(", ")
|
|
224
|
+
lines << ANSIColors.dim("Compression levels: #{levels}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if stats[:card_types].any?
|
|
228
|
+
types = stats[:card_types].sort_by { |_, cnt| -cnt }.map { |type, cnt| "#{type}=#{cnt}" }.join(", ")
|
|
229
|
+
lines << ANSIColors.dim("Card types: #{types}")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
lines
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ----------------------------------------------------------------
|
|
236
|
+
# Reboot support
|
|
237
|
+
# ----------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def handle_reboot_continuation(agent)
|
|
240
|
+
signal = RebootTool.consume_signal(agent.definition.directory, @options[:reboot_from])
|
|
241
|
+
return unless signal
|
|
242
|
+
|
|
243
|
+
puts ANSIColors.cyan("\u{1F504} [Reboot] Continuing after reboot...")
|
|
244
|
+
puts ANSIColors.dim("Reason: #{signal[:reason]}") if signal[:reason]
|
|
245
|
+
puts
|
|
246
|
+
|
|
247
|
+
ask_with_display(agent, signal[:continuation_message])
|
|
248
|
+
check_and_reboot!(agent)
|
|
249
|
+
|
|
250
|
+
exit(0) if @options[:prompt] || @options[:defrag]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def check_and_reboot!(agent)
|
|
254
|
+
return unless RebootTool.signal_pending?(agent.definition.directory, Process.pid)
|
|
255
|
+
|
|
256
|
+
puts ANSIColors.yellow("\n\u{1F504} [Reboot] Restarting process to reload changes...")
|
|
257
|
+
exec(RbConfig.ruby, File.expand_path($PROGRAM_NAME), *@original_argv, "--reboot-from", Process.pid.to_s)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# ----------------------------------------------------------------
|
|
261
|
+
# Interactive mode
|
|
262
|
+
# ----------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def run_interactive(agent)
|
|
265
|
+
print_banner(agent)
|
|
266
|
+
|
|
267
|
+
display = Display.new
|
|
268
|
+
reader = RawInputReader.new(display)
|
|
269
|
+
reader.load_history_file(HISTORY_FILE)
|
|
270
|
+
emitter = build_sync_emitter(agent, display)
|
|
271
|
+
@follow_up_queue = []
|
|
272
|
+
@defrag_queue = []
|
|
273
|
+
@defrag_active = false
|
|
274
|
+
@autocomplete_state = nil # [pre, target, post] when autocomplete is active
|
|
275
|
+
@autocomplete_mode = :manual # :manual (Tab-triggered) or :auto (as-you-type)
|
|
276
|
+
load_settings # Load saved settings from config file
|
|
277
|
+
|
|
278
|
+
reader.start
|
|
279
|
+
|
|
280
|
+
begin
|
|
281
|
+
Async do |reactor|
|
|
282
|
+
SwarmSDK::V3::EventStream.emitter = emitter
|
|
283
|
+
display.activate
|
|
284
|
+
interactive_loop(reactor, agent, display, reader)
|
|
285
|
+
end
|
|
286
|
+
ensure
|
|
287
|
+
display.deactivate
|
|
288
|
+
reader.stop
|
|
289
|
+
reader.save_history_file(HISTORY_FILE)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def print_banner(agent)
|
|
294
|
+
puts ANSIColors.bold("\u{1F41D} V3 Agent Chat")
|
|
295
|
+
puts ANSIColors.dim("\u{1F916} Agent: #{agent.name} | Model: #{agent.definition.model}")
|
|
296
|
+
mem_status = agent.definition.memory_enabled? ? "\u{1F4C1} #{agent.definition.memory_directory}" : "\u{274C} disabled"
|
|
297
|
+
puts ANSIColors.dim("\u{1F9E0} Memory: #{mem_status}")
|
|
298
|
+
puts ANSIColors.dim("Option+Enter for newline. Ctrl-C interrupts agent, double Ctrl-C quits.")
|
|
299
|
+
puts ANSIColors.dim("Type while agent is working to steer. /queue <prompt> for follow-up tasks.")
|
|
300
|
+
puts ANSIColors.dim("Commands: /clear /memory /defrag /queue /reboot /exit")
|
|
301
|
+
puts
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def build_sync_emitter(agent, display)
|
|
305
|
+
lambda { |event|
|
|
306
|
+
activity = EventRenderer.format_activity(event)
|
|
307
|
+
if activity
|
|
308
|
+
display.update_activity(activity)
|
|
309
|
+
else
|
|
310
|
+
line = EventRenderer.format(event)
|
|
311
|
+
display.agent_print(line) if line
|
|
312
|
+
end
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def interactive_loop(reactor, agent, display, reader)
|
|
317
|
+
agent_active = false
|
|
318
|
+
|
|
319
|
+
loop do
|
|
320
|
+
event = reader.next_event
|
|
321
|
+
|
|
322
|
+
case event
|
|
323
|
+
in [:dropdown_enter]
|
|
324
|
+
# Enter pressed with dropdown open: accept selection without submitting
|
|
325
|
+
handle_dropdown_enter_accept(display)
|
|
326
|
+
in [:line, line]
|
|
327
|
+
line = line.strip
|
|
328
|
+
next if line.empty?
|
|
329
|
+
|
|
330
|
+
# Handle slash commands - may return true, false, [:process, prompt], or [:defrag]
|
|
331
|
+
cmd_result = handle_slash_command(line, agent, display, reader, agent_active || @defrag_active)
|
|
332
|
+
case cmd_result
|
|
333
|
+
when true
|
|
334
|
+
next
|
|
335
|
+
when [:defrag]
|
|
336
|
+
# Run defrag asynchronously
|
|
337
|
+
@defrag_active = true
|
|
338
|
+
reactor.async do
|
|
339
|
+
run_defrag_with_progress(agent, display)
|
|
340
|
+
rescue StandardError => e
|
|
341
|
+
display.agent_print(ANSIColors.red("Defrag error: #{e.class}: #{e.message}"))
|
|
342
|
+
ensure
|
|
343
|
+
@defrag_active = false
|
|
344
|
+
end
|
|
345
|
+
next
|
|
346
|
+
when Array
|
|
347
|
+
# [:process, prompt] - command wants us to process this prompt
|
|
348
|
+
line = cmd_result[1]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Queue input during defrag
|
|
352
|
+
if @defrag_active
|
|
353
|
+
@defrag_queue << line
|
|
354
|
+
display.agent_print(ANSIColors.cyan(" \u{1F4CB} Queued for after defrag: #{EventRenderer.truncate(line, 60)}"))
|
|
355
|
+
next
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
if agent.running? || agent_active
|
|
359
|
+
agent.steer(line)
|
|
360
|
+
display.agent_print(ANSIColors.yellow(" \u{1F3AF} Steering: #{EventRenderer.truncate(line, 80)}"))
|
|
361
|
+
else
|
|
362
|
+
agent_active = true
|
|
363
|
+
reactor.async do
|
|
364
|
+
ask_with_display_sync(agent, line, display)
|
|
365
|
+
|
|
366
|
+
# Process queued follow-ups one at a time
|
|
367
|
+
while (follow_up = @follow_up_queue.shift)
|
|
368
|
+
display.agent_print(ANSIColors.cyan(" \u{1F4CB} Processing queued: #{EventRenderer.truncate(follow_up, 60)}"))
|
|
369
|
+
ask_with_display_sync(agent, follow_up, display)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
check_and_reboot_sync!(agent, display, reader)
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
display.agent_print(ANSIColors.red("Error: #{e.class}: #{e.message}"))
|
|
375
|
+
ensure
|
|
376
|
+
agent_active = false
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
in [:interrupt]
|
|
380
|
+
if agent.running? || agent_active
|
|
381
|
+
agent.interrupt!
|
|
382
|
+
display.agent_print(ANSIColors.dim("(interrupted)"))
|
|
383
|
+
end
|
|
384
|
+
# Hint is now shown in the status area below the input
|
|
385
|
+
in [:tab]
|
|
386
|
+
# Tab triggers autocomplete (navigation handled in RawInputReader)
|
|
387
|
+
handle_autocomplete_trigger(agent, display)
|
|
388
|
+
in [:toggle_autocomplete]
|
|
389
|
+
# Toggle between auto and manual autocomplete modes
|
|
390
|
+
@autocomplete_mode = @autocomplete_mode == :auto ? :manual : :auto
|
|
391
|
+
mode_name = @autocomplete_mode == :auto ? "auto" : "manual"
|
|
392
|
+
display.clear_hint # Clear any existing hint first
|
|
393
|
+
display.show_hint("Autocomplete: #{mode_name}", duration: 2.0)
|
|
394
|
+
save_settings # Persist to config file
|
|
395
|
+
in [:char_typed, _char]
|
|
396
|
+
# Auto-trigger autocomplete if in auto mode
|
|
397
|
+
if @autocomplete_mode == :auto
|
|
398
|
+
handle_autocomplete_trigger(agent, display)
|
|
399
|
+
elsif display.dropdown_active?
|
|
400
|
+
# Manual mode: close dropdown on typing
|
|
401
|
+
display.dropdown_close
|
|
402
|
+
end
|
|
403
|
+
in [:eof] | [:exit]
|
|
404
|
+
display.agent_print(ANSIColors.dim("Goodbye!"))
|
|
405
|
+
break
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Trigger autocomplete: extract word, find matches, show dropdown
|
|
411
|
+
def handle_autocomplete_trigger(agent, display)
|
|
412
|
+
buffer = display.current_buffer
|
|
413
|
+
cursor = display.text_input.cursor
|
|
414
|
+
|
|
415
|
+
# Check if this is a command (starts with /)
|
|
416
|
+
if buffer.start_with?("/")
|
|
417
|
+
result = CommandCompleter.extract_command_word(buffer, cursor)
|
|
418
|
+
return unless result
|
|
419
|
+
|
|
420
|
+
pre, target, post = result
|
|
421
|
+
|
|
422
|
+
# Only show dropdown if user typed at least one character after /
|
|
423
|
+
return if target.length < 2 # Need at least "/x"
|
|
424
|
+
|
|
425
|
+
# Include available skills in autocomplete
|
|
426
|
+
skill_names = available_skill_names(agent)
|
|
427
|
+
matches = CommandCompleter.find_matches(target, max: 5, skills: skill_names)
|
|
428
|
+
|
|
429
|
+
else
|
|
430
|
+
# File autocomplete (with @)
|
|
431
|
+
result = FileCompleter.extract_completion_word(buffer, cursor)
|
|
432
|
+
return unless result # No @ prefix found
|
|
433
|
+
|
|
434
|
+
pre, target, post = result
|
|
435
|
+
|
|
436
|
+
# Only show dropdown if user typed at least one character after @
|
|
437
|
+
return if target.length < 2 # Need at least "@x"
|
|
438
|
+
|
|
439
|
+
matches = FileCompleter.find_matches(target, max: 5)
|
|
440
|
+
|
|
441
|
+
end
|
|
442
|
+
return if matches.empty?
|
|
443
|
+
|
|
444
|
+
dropdown = Dropdown.new(items: matches, max_visible: 5)
|
|
445
|
+
display.show_dropdown(dropdown)
|
|
446
|
+
@autocomplete_state = [pre, target, post]
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Handle Enter when dropdown is open: select current + add space
|
|
450
|
+
def handle_dropdown_enter_accept(display)
|
|
451
|
+
selected = display.dropdown_accept
|
|
452
|
+
return unless selected && @autocomplete_state
|
|
453
|
+
|
|
454
|
+
pre, _target, post = @autocomplete_state
|
|
455
|
+
new_buffer = "#{pre}#{selected} #{post}" # Add space after selection
|
|
456
|
+
display.replace_buffer(new_buffer)
|
|
457
|
+
@autocomplete_state = nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def ask_with_display_sync(agent, prompt, display)
|
|
461
|
+
# Expand file mentions before sending to agent (shows read status)
|
|
462
|
+
expanded_prompt = expand_file_mentions(prompt, display: display)
|
|
463
|
+
|
|
464
|
+
display.start_working
|
|
465
|
+
full_content = +""
|
|
466
|
+
input_tokens = expanded_prompt.length / 4
|
|
467
|
+
output_chars = 0
|
|
468
|
+
|
|
469
|
+
begin
|
|
470
|
+
# Only handle content_chunk in the block for content accumulation.
|
|
471
|
+
# Other events flow through the global emitter (build_sync_emitter)
|
|
472
|
+
# to avoid double-rendering.
|
|
473
|
+
agent.ask(expanded_prompt) do |event|
|
|
474
|
+
next unless event[:type] == "content_chunk" && event[:content]
|
|
475
|
+
|
|
476
|
+
full_content << event[:content]
|
|
477
|
+
output_chars += event[:content].length
|
|
478
|
+
display.update_activity("\u{2193} ~#{input_tokens + (output_chars / 4)} tokens")
|
|
479
|
+
end
|
|
480
|
+
ensure
|
|
481
|
+
display.stop_working
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
return if full_content.empty?
|
|
485
|
+
|
|
486
|
+
rendered = TTY::Markdown.parse(full_content)
|
|
487
|
+
display.agent_print("\n#{ANSIColors.cyan("#{agent.name}> ")}\n#{rendered}")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Run defrag with progress display and handle queued input after completion.
|
|
491
|
+
#
|
|
492
|
+
# @param agent [SwarmSDK::V3::Agent] the agent
|
|
493
|
+
# @param display [Display] the display coordinator
|
|
494
|
+
# @return [void]
|
|
495
|
+
def run_defrag_with_progress(agent, display)
|
|
496
|
+
unless agent.definition.memory_enabled?
|
|
497
|
+
display.agent_print(ANSIColors.yellow("Memory not enabled for this agent."))
|
|
498
|
+
return
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
display.start_working
|
|
502
|
+
|
|
503
|
+
begin
|
|
504
|
+
result = agent.defrag! do |event|
|
|
505
|
+
case event[:type]
|
|
506
|
+
when "memory_defrag_progress"
|
|
507
|
+
activity = EventRenderer.format_defrag_progress(event)
|
|
508
|
+
display.update_activity(activity) if activity
|
|
509
|
+
when "memory_defrag_start", "memory_defrag_complete"
|
|
510
|
+
line = EventRenderer.format(event)
|
|
511
|
+
display.agent_print(line) if line
|
|
512
|
+
when /^memory_.*_error$/
|
|
513
|
+
# Display memory error events (compression_batch_error, promotion_llm_error, etc.)
|
|
514
|
+
display.agent_print(ANSIColors.red(" \u{26A0}\u{FE0F} #{event[:type]}: #{event[:error]}"))
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
format_maintenance_result(result, agent.memory.stats).each { |line| display.agent_print(line) }
|
|
518
|
+
ensure
|
|
519
|
+
display.stop_working
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Process queued messages after defrag (merged into single prompt)
|
|
523
|
+
process_defrag_queue(agent, display) if @defrag_queue.any?
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Process messages queued during defrag by merging them into a single prompt.
|
|
527
|
+
#
|
|
528
|
+
# @param agent [SwarmSDK::V3::Agent] the agent
|
|
529
|
+
# @param display [Display] the display coordinator
|
|
530
|
+
# @return [void]
|
|
531
|
+
def process_defrag_queue(agent, display)
|
|
532
|
+
return if @defrag_queue.empty?
|
|
533
|
+
|
|
534
|
+
messages = @defrag_queue.dup
|
|
535
|
+
@defrag_queue.clear
|
|
536
|
+
|
|
537
|
+
merged_prompt = messages.join("\n\n")
|
|
538
|
+
display.agent_print(ANSIColors.cyan(" \u{1F4CB} Processing #{messages.size} queued message#{"s" if messages.size != 1}..."))
|
|
539
|
+
|
|
540
|
+
ask_with_display_sync(agent, merged_prompt, display)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# ----------------------------------------------------------------
|
|
544
|
+
# Slash commands (interactive mode)
|
|
545
|
+
# ----------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
def handle_slash_command(input, agent, display, reader, agent_active = false)
|
|
548
|
+
case input
|
|
549
|
+
when "/clear"
|
|
550
|
+
agent.clear
|
|
551
|
+
@follow_up_queue&.clear
|
|
552
|
+
display.agent_print(ANSIColors.yellow("Conversation and queue cleared (memory preserved)."))
|
|
553
|
+
true
|
|
554
|
+
when "/memory"
|
|
555
|
+
show_memory_stats_sync(agent, display)
|
|
556
|
+
true
|
|
557
|
+
when "/defrag"
|
|
558
|
+
# Return special marker to run defrag asynchronously
|
|
559
|
+
[:defrag]
|
|
560
|
+
when "/reboot"
|
|
561
|
+
display.deactivate
|
|
562
|
+
reader.stop
|
|
563
|
+
reader.save_history_file(HISTORY_FILE)
|
|
564
|
+
puts ANSIColors.yellow("\u{1F504} Rebooting to reload code changes...")
|
|
565
|
+
exec(RbConfig.ruby, File.expand_path($PROGRAM_NAME), *@original_argv)
|
|
566
|
+
when "/exit", "/quit"
|
|
567
|
+
display.agent_print(ANSIColors.dim("Goodbye!"))
|
|
568
|
+
display.deactivate
|
|
569
|
+
reader.stop
|
|
570
|
+
reader.save_history_file(HISTORY_FILE)
|
|
571
|
+
exit(0)
|
|
572
|
+
when %r{^/queue\s+(.+)}i
|
|
573
|
+
prompt = Regexp.last_match(1).strip
|
|
574
|
+
if agent.running? || agent_active
|
|
575
|
+
# Agent is busy - queue for later
|
|
576
|
+
@follow_up_queue << prompt
|
|
577
|
+
display.agent_print(ANSIColors.cyan(" \u{1F4CB} Queued: #{EventRenderer.truncate(prompt, 80)}"))
|
|
578
|
+
true
|
|
579
|
+
else
|
|
580
|
+
# Agent is idle - process immediately
|
|
581
|
+
[:process, prompt]
|
|
582
|
+
end
|
|
583
|
+
when "/queue"
|
|
584
|
+
display.agent_print(ANSIColors.red("Usage: /queue <prompt>"))
|
|
585
|
+
true
|
|
586
|
+
when %r{^/([a-zA-Z0-9_-]+)(?:\s+(.+))?}
|
|
587
|
+
# Check if this is a skill invocation (supports hyphens in names)
|
|
588
|
+
skill_name = Regexp.last_match(1)
|
|
589
|
+
user_prompt = Regexp.last_match(2) || ""
|
|
590
|
+
|
|
591
|
+
# Try to find matching skill
|
|
592
|
+
skill = find_skill(agent, skill_name)
|
|
593
|
+
if skill
|
|
594
|
+
# Read skill file and combine with user prompt
|
|
595
|
+
skill_content = File.read(skill.location, encoding: "UTF-8")
|
|
596
|
+
combined_prompt = "#{skill_content}\n\n#{user_prompt}".strip
|
|
597
|
+
display.agent_print(ANSIColors.dim(" \u{1F4DA} Skill: /#{skill.name}"))
|
|
598
|
+
[:process, combined_prompt]
|
|
599
|
+
else
|
|
600
|
+
# Unknown command
|
|
601
|
+
display.agent_print(ANSIColors.red("Unknown command: #{input}"))
|
|
602
|
+
display.agent_print(ANSIColors.dim("Commands: /clear /memory /defrag /queue /reboot /exit"))
|
|
603
|
+
true
|
|
604
|
+
end
|
|
605
|
+
else
|
|
606
|
+
false
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def show_memory_stats_sync(agent, display)
|
|
611
|
+
unless agent.definition.memory_enabled?
|
|
612
|
+
display.agent_print(ANSIColors.yellow("Memory not enabled for this agent."))
|
|
613
|
+
return
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
tokens = agent.tokens
|
|
617
|
+
lines = [
|
|
618
|
+
ANSIColors.cyan("--- Memory Stats ---"),
|
|
619
|
+
" Messages: #{agent.messages.size}",
|
|
620
|
+
" Input tokens: #{tokens[:input]}",
|
|
621
|
+
" Output tokens: #{tokens[:output]}",
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
# Memory store is lazily initialized on first ask()
|
|
625
|
+
if agent.memory
|
|
626
|
+
cards = agent.memory.adapter.list_cards
|
|
627
|
+
lines.insert(1, " Cards: #{cards.size}")
|
|
628
|
+
else
|
|
629
|
+
lines.insert(1, " Cards: (not loaded yet)")
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
lines << ANSIColors.cyan("--------------------")
|
|
633
|
+
display.agent_print(lines.join("\n"))
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# ----------------------------------------------------------------
|
|
637
|
+
# Interactive reboot
|
|
638
|
+
# ----------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
def check_and_reboot_sync!(agent, display, reader)
|
|
641
|
+
return unless RebootTool.signal_pending?(agent.definition.directory, Process.pid)
|
|
642
|
+
|
|
643
|
+
display.deactivate
|
|
644
|
+
reader.stop
|
|
645
|
+
reader.save_history_file(HISTORY_FILE)
|
|
646
|
+
puts ANSIColors.yellow("\n\u{1F504} [Reboot] Restarting process to reload changes...")
|
|
647
|
+
exec(
|
|
648
|
+
RbConfig.ruby,
|
|
649
|
+
File.expand_path($PROGRAM_NAME),
|
|
650
|
+
*@original_argv,
|
|
651
|
+
"--reboot-from",
|
|
652
|
+
Process.pid.to_s,
|
|
653
|
+
)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Load CLI settings from config file
|
|
657
|
+
def load_settings
|
|
658
|
+
# Ensure config directory exists on startup
|
|
659
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
|
|
660
|
+
|
|
661
|
+
return unless File.exist?(CONFIG_FILE)
|
|
662
|
+
|
|
663
|
+
data = JSON.parse(File.read(CONFIG_FILE))
|
|
664
|
+
@autocomplete_mode = data["autocomplete_mode"]&.to_sym || :manual
|
|
665
|
+
rescue StandardError => _e
|
|
666
|
+
@autocomplete_mode = :manual
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Save CLI settings to config file
|
|
670
|
+
def save_settings
|
|
671
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
|
|
672
|
+
data = {
|
|
673
|
+
"autocomplete_mode" => @autocomplete_mode.to_s,
|
|
674
|
+
}
|
|
675
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(data))
|
|
676
|
+
rescue StandardError => _e
|
|
677
|
+
# Silently ignore save errors
|
|
678
|
+
nil
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Get list of available skill names for autocomplete
|
|
682
|
+
#
|
|
683
|
+
# @param agent [SwarmSDK::V3::Agent] the agent
|
|
684
|
+
# @return [Array<String>] array of skill names
|
|
685
|
+
def available_skill_names(agent)
|
|
686
|
+
# If agent has loaded skills, use them
|
|
687
|
+
if agent.loaded_skills
|
|
688
|
+
return agent.loaded_skills.map(&:name)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Otherwise, scan skill directories directly
|
|
692
|
+
return [] if agent.definition.skills.empty?
|
|
693
|
+
|
|
694
|
+
manifests = SwarmSDK::V3::Skills::Loader.scan(agent.definition.skills)
|
|
695
|
+
manifests.map(&:name)
|
|
696
|
+
rescue StandardError => _e
|
|
697
|
+
[]
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Find a skill by name from the agent's loaded skills
|
|
701
|
+
#
|
|
702
|
+
# @param agent [SwarmSDK::V3::Agent] the agent
|
|
703
|
+
# @param name [String] the skill name to find
|
|
704
|
+
# @return [SwarmSDK::V3::Skills::Manifest, nil] the skill manifest or nil
|
|
705
|
+
def find_skill(agent, name)
|
|
706
|
+
# If agent has loaded skills, search them
|
|
707
|
+
if agent.loaded_skills
|
|
708
|
+
return agent.loaded_skills.find { |s| s.name.downcase == name.downcase }
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Otherwise, scan skill directories directly
|
|
712
|
+
return if agent.definition.skills.empty?
|
|
713
|
+
|
|
714
|
+
manifests = SwarmSDK::V3::Skills::Loader.scan(agent.definition.skills)
|
|
715
|
+
manifests.find { |s| s.name.downcase == name.downcase }
|
|
716
|
+
rescue StandardError => _e
|
|
717
|
+
nil
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
end
|