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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/exe/swarm3 +11 -0
  4. data/lib/swarm_cli/v3/activity_indicator.rb +168 -0
  5. data/lib/swarm_cli/v3/ansi_colors.rb +70 -0
  6. data/lib/swarm_cli/v3/cli.rb +721 -0
  7. data/lib/swarm_cli/v3/command_completer.rb +112 -0
  8. data/lib/swarm_cli/v3/display.rb +607 -0
  9. data/lib/swarm_cli/v3/dropdown.rb +130 -0
  10. data/lib/swarm_cli/v3/event_renderer.rb +161 -0
  11. data/lib/swarm_cli/v3/file_completer.rb +143 -0
  12. data/lib/swarm_cli/v3/raw_input_reader.rb +304 -0
  13. data/lib/swarm_cli/v3/reboot_tool.rb +123 -0
  14. data/lib/swarm_cli/v3/text_input.rb +235 -0
  15. data/lib/swarm_cli/v3.rb +52 -0
  16. metadata +30 -245
  17. data/exe/swarm +0 -6
  18. data/lib/swarm_cli/cli.rb +0 -201
  19. data/lib/swarm_cli/command_registry.rb +0 -61
  20. data/lib/swarm_cli/commands/mcp_serve.rb +0 -130
  21. data/lib/swarm_cli/commands/mcp_tools.rb +0 -148
  22. data/lib/swarm_cli/commands/migrate.rb +0 -55
  23. data/lib/swarm_cli/commands/run.rb +0 -173
  24. data/lib/swarm_cli/config_loader.rb +0 -98
  25. data/lib/swarm_cli/formatters/human_formatter.rb +0 -811
  26. data/lib/swarm_cli/formatters/json_formatter.rb +0 -62
  27. data/lib/swarm_cli/interactive_repl.rb +0 -924
  28. data/lib/swarm_cli/mcp_serve_options.rb +0 -44
  29. data/lib/swarm_cli/mcp_tools_options.rb +0 -59
  30. data/lib/swarm_cli/migrate_options.rb +0 -54
  31. data/lib/swarm_cli/migrator.rb +0 -132
  32. data/lib/swarm_cli/options.rb +0 -151
  33. data/lib/swarm_cli/ui/components/agent_badge.rb +0 -33
  34. data/lib/swarm_cli/ui/components/content_block.rb +0 -120
  35. data/lib/swarm_cli/ui/components/divider.rb +0 -57
  36. data/lib/swarm_cli/ui/components/panel.rb +0 -62
  37. data/lib/swarm_cli/ui/components/usage_stats.rb +0 -70
  38. data/lib/swarm_cli/ui/formatters/cost.rb +0 -49
  39. data/lib/swarm_cli/ui/formatters/number.rb +0 -58
  40. data/lib/swarm_cli/ui/formatters/text.rb +0 -77
  41. data/lib/swarm_cli/ui/formatters/time.rb +0 -73
  42. data/lib/swarm_cli/ui/icons.rb +0 -36
  43. data/lib/swarm_cli/ui/renderers/event_renderer.rb +0 -188
  44. data/lib/swarm_cli/ui/state/agent_color_cache.rb +0 -45
  45. data/lib/swarm_cli/ui/state/depth_tracker.rb +0 -40
  46. data/lib/swarm_cli/ui/state/spinner_manager.rb +0 -170
  47. data/lib/swarm_cli/ui/state/usage_tracker.rb +0 -62
  48. data/lib/swarm_cli/version.rb +0 -5
  49. 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