swarm_cli 2.1.13 → 3.0.0.alpha3

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 +737 -0
  7. data/lib/swarm_cli/v3/command_completer.rb +112 -0
  8. data/lib/swarm_cli/v3/display.rb +614 -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 -895
  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,737 @@
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
+ # Auto-close dropdown if exact unique match
400
+ auto_close_on_exact_match(display)
401
+ elsif display.dropdown_active?
402
+ # Manual mode: close dropdown on typing
403
+ display.dropdown_close
404
+ end
405
+ in [:eof] | [:exit]
406
+ display.agent_print(ANSIColors.dim("Goodbye!"))
407
+ break
408
+ end
409
+ end
410
+ end
411
+
412
+ # Auto-close dropdown if buffer exactly matches a unique result.
413
+ # This prevents needing to press Enter twice (once to accept, once to submit).
414
+ def auto_close_on_exact_match(display)
415
+ return unless display.dropdown_active?
416
+
417
+ buffer = display.current_buffer.strip
418
+ dropdown_items = display.dropdown_items || []
419
+
420
+ # If buffer exactly matches the only item, auto-close dropdown
421
+ if dropdown_items.size == 1 && dropdown_items.first == buffer
422
+ display.dropdown_close
423
+ end
424
+ end
425
+
426
+ # Trigger autocomplete: extract word, find matches, show dropdown
427
+ def handle_autocomplete_trigger(agent, display)
428
+ buffer = display.current_buffer
429
+ cursor = display.text_input.cursor
430
+
431
+ # Check if this is a command (starts with /)
432
+ if buffer.start_with?("/")
433
+ result = CommandCompleter.extract_command_word(buffer, cursor)
434
+ return unless result
435
+
436
+ pre, target, post = result
437
+
438
+ # Only show dropdown if user typed at least one character after /
439
+ return if target.length < 2 # Need at least "/x"
440
+
441
+ # Include available skills in autocomplete
442
+ skill_names = available_skill_names(agent)
443
+ matches = CommandCompleter.find_matches(target, max: 5, skills: skill_names)
444
+
445
+ else
446
+ # File autocomplete (with @)
447
+ result = FileCompleter.extract_completion_word(buffer, cursor)
448
+ return unless result # No @ prefix found
449
+
450
+ pre, target, post = result
451
+
452
+ # Only show dropdown if user typed at least one character after @
453
+ return if target.length < 2 # Need at least "@x"
454
+
455
+ matches = FileCompleter.find_matches(target, max: 5)
456
+
457
+ end
458
+ return if matches.empty?
459
+
460
+ dropdown = Dropdown.new(items: matches, max_visible: 5)
461
+ display.show_dropdown(dropdown)
462
+ @autocomplete_state = [pre, target, post]
463
+ end
464
+
465
+ # Handle Enter when dropdown is open: select current + add space
466
+ def handle_dropdown_enter_accept(display)
467
+ selected = display.dropdown_accept
468
+ return unless selected && @autocomplete_state
469
+
470
+ pre, _target, post = @autocomplete_state
471
+ new_buffer = "#{pre}#{selected} #{post}" # Add space after selection
472
+ display.replace_buffer(new_buffer)
473
+ @autocomplete_state = nil
474
+ end
475
+
476
+ def ask_with_display_sync(agent, prompt, display)
477
+ # Expand file mentions before sending to agent (shows read status)
478
+ expanded_prompt = expand_file_mentions(prompt, display: display)
479
+
480
+ display.start_working
481
+ full_content = +""
482
+ input_tokens = expanded_prompt.length / 4
483
+ output_chars = 0
484
+
485
+ begin
486
+ # Only handle content_chunk in the block for content accumulation.
487
+ # Other events flow through the global emitter (build_sync_emitter)
488
+ # to avoid double-rendering.
489
+ agent.ask(expanded_prompt) do |event|
490
+ next unless event[:type] == "content_chunk" && event[:content]
491
+
492
+ full_content << event[:content]
493
+ output_chars += event[:content].length
494
+ display.update_activity("\u{2193} ~#{input_tokens + (output_chars / 4)} tokens")
495
+ end
496
+ ensure
497
+ display.stop_working
498
+ end
499
+
500
+ return if full_content.empty?
501
+
502
+ rendered = TTY::Markdown.parse(full_content)
503
+ display.agent_print("\n#{ANSIColors.cyan("#{agent.name}> ")}\n#{rendered}")
504
+ end
505
+
506
+ # Run defrag with progress display and handle queued input after completion.
507
+ #
508
+ # @param agent [SwarmSDK::V3::Agent] the agent
509
+ # @param display [Display] the display coordinator
510
+ # @return [void]
511
+ def run_defrag_with_progress(agent, display)
512
+ unless agent.definition.memory_enabled?
513
+ display.agent_print(ANSIColors.yellow("Memory not enabled for this agent."))
514
+ return
515
+ end
516
+
517
+ display.start_working
518
+
519
+ begin
520
+ result = agent.defrag! do |event|
521
+ case event[:type]
522
+ when "memory_defrag_progress"
523
+ activity = EventRenderer.format_defrag_progress(event)
524
+ display.update_activity(activity) if activity
525
+ when "memory_defrag_start", "memory_defrag_complete"
526
+ line = EventRenderer.format(event)
527
+ display.agent_print(line) if line
528
+ when /^memory_.*_error$/
529
+ # Display memory error events (compression_batch_error, promotion_llm_error, etc.)
530
+ display.agent_print(ANSIColors.red(" \u{26A0}\u{FE0F} #{event[:type]}: #{event[:error]}"))
531
+ end
532
+ end
533
+ format_maintenance_result(result, agent.memory.stats).each { |line| display.agent_print(line) }
534
+ ensure
535
+ display.stop_working
536
+ end
537
+
538
+ # Process queued messages after defrag (merged into single prompt)
539
+ process_defrag_queue(agent, display) if @defrag_queue.any?
540
+ end
541
+
542
+ # Process messages queued during defrag by merging them into a single prompt.
543
+ #
544
+ # @param agent [SwarmSDK::V3::Agent] the agent
545
+ # @param display [Display] the display coordinator
546
+ # @return [void]
547
+ def process_defrag_queue(agent, display)
548
+ return if @defrag_queue.empty?
549
+
550
+ messages = @defrag_queue.dup
551
+ @defrag_queue.clear
552
+
553
+ merged_prompt = messages.join("\n\n")
554
+ display.agent_print(ANSIColors.cyan(" \u{1F4CB} Processing #{messages.size} queued message#{"s" if messages.size != 1}..."))
555
+
556
+ ask_with_display_sync(agent, merged_prompt, display)
557
+ end
558
+
559
+ # ----------------------------------------------------------------
560
+ # Slash commands (interactive mode)
561
+ # ----------------------------------------------------------------
562
+
563
+ def handle_slash_command(input, agent, display, reader, agent_active = false)
564
+ case input
565
+ when "/clear"
566
+ agent.clear
567
+ @follow_up_queue&.clear
568
+ display.agent_print(ANSIColors.yellow("Conversation and queue cleared (memory preserved)."))
569
+ true
570
+ when "/memory"
571
+ show_memory_stats_sync(agent, display)
572
+ true
573
+ when "/defrag"
574
+ # Return special marker to run defrag asynchronously
575
+ [:defrag]
576
+ when "/reboot"
577
+ display.deactivate
578
+ reader.stop
579
+ reader.save_history_file(HISTORY_FILE)
580
+ puts ANSIColors.yellow("\u{1F504} Rebooting to reload code changes...")
581
+ exec(RbConfig.ruby, File.expand_path($PROGRAM_NAME), *@original_argv)
582
+ when "/exit", "/quit"
583
+ display.agent_print(ANSIColors.dim("Goodbye!"))
584
+ display.deactivate
585
+ reader.stop
586
+ reader.save_history_file(HISTORY_FILE)
587
+ exit(0)
588
+ when %r{^/queue\s+(.+)}i
589
+ prompt = Regexp.last_match(1).strip
590
+ if agent.running? || agent_active
591
+ # Agent is busy - queue for later
592
+ @follow_up_queue << prompt
593
+ display.agent_print(ANSIColors.cyan(" \u{1F4CB} Queued: #{EventRenderer.truncate(prompt, 80)}"))
594
+ true
595
+ else
596
+ # Agent is idle - process immediately
597
+ [:process, prompt]
598
+ end
599
+ when "/queue"
600
+ display.agent_print(ANSIColors.red("Usage: /queue <prompt>"))
601
+ true
602
+ when %r{^/([a-zA-Z0-9_-]+)(?:\s+(.+))?}
603
+ # Check if this is a skill invocation (supports hyphens in names)
604
+ skill_name = Regexp.last_match(1)
605
+ user_prompt = Regexp.last_match(2) || ""
606
+
607
+ # Try to find matching skill
608
+ skill = find_skill(agent, skill_name)
609
+ if skill
610
+ # Read skill file and combine with user prompt
611
+ skill_content = File.read(skill.location, encoding: "UTF-8")
612
+ combined_prompt = "#{skill_content}\n\n#{user_prompt}".strip
613
+ display.agent_print(ANSIColors.dim(" \u{1F4DA} Skill: /#{skill.name}"))
614
+ [:process, combined_prompt]
615
+ else
616
+ # Unknown command
617
+ display.agent_print(ANSIColors.red("Unknown command: #{input}"))
618
+ display.agent_print(ANSIColors.dim("Commands: /clear /memory /defrag /queue /reboot /exit"))
619
+ true
620
+ end
621
+ else
622
+ false
623
+ end
624
+ end
625
+
626
+ def show_memory_stats_sync(agent, display)
627
+ unless agent.definition.memory_enabled?
628
+ display.agent_print(ANSIColors.yellow("Memory not enabled for this agent."))
629
+ return
630
+ end
631
+
632
+ tokens = agent.tokens
633
+ lines = [
634
+ ANSIColors.cyan("--- Memory Stats ---"),
635
+ " Messages: #{agent.messages.size}",
636
+ " Input tokens: #{tokens[:input]}",
637
+ " Output tokens: #{tokens[:output]}",
638
+ ]
639
+
640
+ # Memory store is lazily initialized on first ask()
641
+ if agent.memory
642
+ cards = agent.memory.adapter.list_cards
643
+ lines.insert(1, " Cards: #{cards.size}")
644
+ else
645
+ lines.insert(1, " Cards: (not loaded yet)")
646
+ end
647
+
648
+ lines << ANSIColors.cyan("--------------------")
649
+ display.agent_print(lines.join("\n"))
650
+ end
651
+
652
+ # ----------------------------------------------------------------
653
+ # Interactive reboot
654
+ # ----------------------------------------------------------------
655
+
656
+ def check_and_reboot_sync!(agent, display, reader)
657
+ return unless RebootTool.signal_pending?(agent.definition.directory, Process.pid)
658
+
659
+ display.deactivate
660
+ reader.stop
661
+ reader.save_history_file(HISTORY_FILE)
662
+ puts ANSIColors.yellow("\n\u{1F504} [Reboot] Restarting process to reload changes...")
663
+ exec(
664
+ RbConfig.ruby,
665
+ File.expand_path($PROGRAM_NAME),
666
+ *@original_argv,
667
+ "--reboot-from",
668
+ Process.pid.to_s,
669
+ )
670
+ end
671
+
672
+ # Load CLI settings from config file
673
+ def load_settings
674
+ # Ensure config directory exists on startup
675
+ FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
676
+
677
+ return unless File.exist?(CONFIG_FILE)
678
+
679
+ data = JSON.parse(File.read(CONFIG_FILE))
680
+ @autocomplete_mode = data["autocomplete_mode"]&.to_sym || :manual
681
+ rescue StandardError => _e
682
+ @autocomplete_mode = :manual
683
+ end
684
+
685
+ # Save CLI settings to config file
686
+ def save_settings
687
+ FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
688
+ data = {
689
+ "autocomplete_mode" => @autocomplete_mode.to_s,
690
+ }
691
+ File.write(CONFIG_FILE, JSON.pretty_generate(data))
692
+ rescue StandardError => _e
693
+ # Silently ignore save errors
694
+ nil
695
+ end
696
+
697
+ # Get list of available skill names for autocomplete
698
+ #
699
+ # @param agent [SwarmSDK::V3::Agent] the agent
700
+ # @return [Array<String>] array of skill names
701
+ def available_skill_names(agent)
702
+ # If agent has loaded skills, use them
703
+ if agent.loaded_skills
704
+ return agent.loaded_skills.map(&:name)
705
+ end
706
+
707
+ # Otherwise, scan skill directories directly
708
+ return [] if agent.definition.skills.empty?
709
+
710
+ manifests = SwarmSDK::V3::Skills::Loader.scan(agent.definition.skills)
711
+ manifests.map(&:name)
712
+ rescue StandardError => _e
713
+ []
714
+ end
715
+
716
+ # Find a skill by name from the agent's loaded skills
717
+ #
718
+ # @param agent [SwarmSDK::V3::Agent] the agent
719
+ # @param name [String] the skill name to find
720
+ # @return [SwarmSDK::V3::Skills::Manifest, nil] the skill manifest or nil
721
+ def find_skill(agent, name)
722
+ # If agent has loaded skills, search them
723
+ if agent.loaded_skills
724
+ return agent.loaded_skills.find { |s| s.name.downcase == name.downcase }
725
+ end
726
+
727
+ # Otherwise, scan skill directories directly
728
+ return if agent.definition.skills.empty?
729
+
730
+ manifests = SwarmSDK::V3::Skills::Loader.scan(agent.definition.skills)
731
+ manifests.find { |s| s.name.downcase == name.downcase }
732
+ rescue StandardError => _e
733
+ nil
734
+ end
735
+ end
736
+ end
737
+ end