claude_swarm 1.0.1 → 1.0.4
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/.claude/commands/release.md +1 -1
- data/.claude/hooks/lint-code-files.rb +65 -0
- data/.rubocop.yml +22 -2
- data/CHANGELOG.md +14 -1
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +69 -0
- data/README.md +27 -2
- data/Rakefile +71 -3
- data/analyze_coverage.rb +94 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
- data/docs/v2/README.md +308 -0
- data/docs/v2/guides/claude-code-agents.md +262 -0
- data/docs/v2/guides/complete-tutorial.md +3088 -0
- data/docs/v2/guides/getting-started.md +1456 -0
- data/docs/v2/guides/memory-adapters.md +998 -0
- data/docs/v2/guides/plugins.md +816 -0
- data/docs/v2/guides/quick-start-cli.md +1745 -0
- data/docs/v2/guides/rails-integration.md +1902 -0
- data/docs/v2/guides/swarm-memory.md +599 -0
- data/docs/v2/reference/cli.md +729 -0
- data/docs/v2/reference/ruby-dsl.md +2154 -0
- data/docs/v2/reference/yaml.md +1835 -0
- data/docs-team-swarm.yml +2222 -0
- data/examples/learning-assistant/assistant.md +7 -0
- data/examples/learning-assistant/example-memories/concept-example.md +90 -0
- data/examples/learning-assistant/example-memories/experience-example.md +66 -0
- data/examples/learning-assistant/example-memories/fact-example.md +76 -0
- data/examples/learning-assistant/example-memories/memory-index.md +78 -0
- data/examples/learning-assistant/example-memories/skill-example.md +168 -0
- data/examples/learning-assistant/learning_assistant.rb +34 -0
- data/examples/learning-assistant/learning_assistant.yml +20 -0
- data/examples/v2/dsl/01_basic.rb +44 -0
- data/examples/v2/dsl/02_core_parameters.rb +59 -0
- data/examples/v2/dsl/03_capabilities.rb +71 -0
- data/examples/v2/dsl/04_llm_parameters.rb +56 -0
- data/examples/v2/dsl/05_advanced_flags.rb +73 -0
- data/examples/v2/dsl/06_permissions.rb +80 -0
- data/examples/v2/dsl/07_mcp_server.rb +62 -0
- data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
- data/examples/v2/dsl/09_agent_hooks.rb +67 -0
- data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
- data/examples/v2/dsl/11_delegation.rb +60 -0
- data/examples/v2/dsl/12_complete_integration.rb +137 -0
- data/examples/v2/file_tools_swarm.yml +102 -0
- data/examples/v2/hooks/01_basic_hooks.rb +133 -0
- data/examples/v2/hooks/02_usage_tracking.rb +201 -0
- data/examples/v2/hooks/03_production_monitoring.rb +429 -0
- data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
- data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
- data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
- data/examples/v2/hooks/swarm_summary.sh +44 -0
- data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
- data/examples/v2/hooks/validate_bash.rb +59 -0
- data/examples/v2/multi_directory_permissions.yml +221 -0
- data/examples/v2/node_context_demo.rb +127 -0
- data/examples/v2/node_workflow.rb +173 -0
- data/examples/v2/path_resolution_demo.rb +216 -0
- data/examples/v2/simple-swarm-v2.rb +90 -0
- data/examples/v2/simple-swarm-v2.yml +62 -0
- data/examples/v2/swarm.yml +71 -0
- data/examples/v2/swarm_with_hooks.yml +61 -0
- data/examples/v2/swarm_with_hooks_simple.yml +25 -0
- data/examples/v2/think_tool_demo.rb +62 -0
- data/exe/swarm +6 -0
- data/lib/claude_swarm/claude_mcp_server.rb +0 -6
- data/lib/claude_swarm/cli.rb +10 -3
- data/lib/claude_swarm/commands/ps.rb +19 -20
- data/lib/claude_swarm/commands/show.rb +1 -1
- data/lib/claude_swarm/configuration.rb +10 -12
- data/lib/claude_swarm/mcp_generator.rb +10 -1
- data/lib/claude_swarm/orchestrator.rb +73 -49
- data/lib/claude_swarm/system_utils.rb +37 -11
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +1 -0
- data/lib/claude_swarm/yaml_loader.rb +22 -0
- data/lib/claude_swarm.rb +7 -2
- data/lib/swarm_cli/cli.rb +201 -0
- data/lib/swarm_cli/command_registry.rb +61 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +173 -0
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/interactive_repl.rb +918 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +151 -0
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +44 -0
- data/lib/swarm_memory/adapters/base.rb +141 -0
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
- data/lib/swarm_memory/chat_extension.rb +34 -0
- data/lib/swarm_memory/cli/commands.rb +306 -0
- data/lib/swarm_memory/core/entry.rb +37 -0
- data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
- data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
- data/lib/swarm_memory/core/path_normalizer.rb +75 -0
- data/lib/swarm_memory/core/semantic_index.rb +244 -0
- data/lib/swarm_memory/core/storage.rb +288 -0
- data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
- data/lib/swarm_memory/dsl/memory_config.rb +113 -0
- data/lib/swarm_memory/embeddings/embedder.rb +36 -0
- data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
- data/lib/swarm_memory/errors.rb +21 -0
- data/lib/swarm_memory/integration/cli_registration.rb +30 -0
- data/lib/swarm_memory/integration/configuration.rb +43 -0
- data/lib/swarm_memory/integration/registration.rb +31 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
- data/lib/swarm_memory/optimization/analyzer.rb +244 -0
- data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
- data/lib/swarm_memory/prompts/memory.md.erb +109 -0
- data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
- data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
- data/lib/swarm_memory/search/semantic_search.rb +112 -0
- data/lib/swarm_memory/search/text_search.rb +42 -0
- data/lib/swarm_memory/search/text_similarity.rb +80 -0
- data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
- data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
- data/lib/swarm_memory/tools/load_skill.rb +313 -0
- data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
- data/lib/swarm_memory/tools/memory_delete.rb +99 -0
- data/lib/swarm_memory/tools/memory_edit.rb +185 -0
- data/lib/swarm_memory/tools/memory_glob.rb +160 -0
- data/lib/swarm_memory/tools/memory_grep.rb +247 -0
- data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
- data/lib/swarm_memory/tools/memory_read.rb +123 -0
- data/lib/swarm_memory/tools/memory_write.rb +231 -0
- data/lib/swarm_memory/utils.rb +50 -0
- data/lib/swarm_memory/version.rb +5 -0
- data/lib/swarm_memory.rb +166 -0
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +461 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
- data/lib/swarm_sdk/agent/chat.rb +1159 -0
- data/lib/swarm_sdk/agent/context.rb +112 -0
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +556 -0
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
- data/lib/swarm_sdk/configuration.rb +296 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +197 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +147 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +51 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +75 -0
- data/lib/swarm_sdk/model_aliases.json +5 -0
- data/lib/swarm_sdk/models.json +1 -0
- data/lib/swarm_sdk/models.rb +120 -0
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
- data/lib/swarm_sdk/swarm/builder.rb +586 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
- data/lib/swarm_sdk/swarm.rb +982 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/delegate.rb +164 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +228 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +93 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/think.rb +95 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +157 -0
- data/llm.v2.txt +13407 -0
- data/rubocop/cop/security/no_reflection_methods.rb +47 -0
- data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
- data/swarm_cli.gemspec +57 -0
- data/swarm_memory.gemspec +28 -0
- data/swarm_sdk.gemspec +41 -0
- data/team.yml +1 -1
- data/team_full.yml +1875 -0
- data/{team_v2.yml → team_sdk.yml} +121 -52
- metadata +249 -6
- data/EXAMPLES.md +0 -164
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
require "tty-spinner"
|
|
5
|
+
require "tty-markdown"
|
|
6
|
+
require "tty-box"
|
|
7
|
+
require "pastel"
|
|
8
|
+
require "async"
|
|
9
|
+
require "async/condition"
|
|
10
|
+
|
|
11
|
+
module SwarmCLI
|
|
12
|
+
# InteractiveREPL provides a professional, interactive terminal interface
|
|
13
|
+
# for conversing with SwarmSDK agents.
|
|
14
|
+
#
|
|
15
|
+
# Features:
|
|
16
|
+
# - Multiline input with intuitive submission (Enter on empty line or Ctrl+D)
|
|
17
|
+
# - Beautiful Markdown rendering for agent responses
|
|
18
|
+
# - Progress indicators during processing
|
|
19
|
+
# - Command system (/help, /exit, /clear, etc.)
|
|
20
|
+
# - Conversation history with context preservation
|
|
21
|
+
# - Professional styling with Pastel and TTY tools
|
|
22
|
+
#
|
|
23
|
+
class InteractiveREPL
|
|
24
|
+
COMMANDS = {
|
|
25
|
+
"/help" => "Show available commands",
|
|
26
|
+
"/clear" => "Clear the lead agent's conversation context",
|
|
27
|
+
"/tools" => "List the lead agent's available tools",
|
|
28
|
+
"/history" => "Show conversation history",
|
|
29
|
+
"/defrag" => "Run memory defragmentation workflow (find and link related entries)",
|
|
30
|
+
"/exit" => "Exit the REPL (or press Ctrl+D)",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# History configuration
|
|
34
|
+
HISTORY_SIZE = 1000
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Get history file path (can be overridden with SWARM_HISTORY env var)
|
|
38
|
+
def history_file
|
|
39
|
+
ENV["SWARM_HISTORY"] || File.expand_path("~/.swarm/history")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(swarm:, options:, initial_message: nil)
|
|
44
|
+
@swarm = swarm
|
|
45
|
+
@options = options
|
|
46
|
+
@initial_message = initial_message
|
|
47
|
+
@conversation_history = []
|
|
48
|
+
@session_results = [] # Accumulate all results for session summary
|
|
49
|
+
@validation_warnings_shown = false
|
|
50
|
+
|
|
51
|
+
setup_ui_components
|
|
52
|
+
setup_persistent_history
|
|
53
|
+
|
|
54
|
+
# Create formatter for swarm execution output (interactive mode)
|
|
55
|
+
@formatter = Formatters::HumanFormatter.new(
|
|
56
|
+
output: $stdout,
|
|
57
|
+
quiet: options.quiet?,
|
|
58
|
+
truncate: options.truncate?,
|
|
59
|
+
verbose: options.verbose?,
|
|
60
|
+
mode: :interactive,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run
|
|
65
|
+
display_welcome
|
|
66
|
+
|
|
67
|
+
# Emit validation warnings before first prompt
|
|
68
|
+
emit_validation_warnings_before_prompt
|
|
69
|
+
|
|
70
|
+
# Send initial message if provided
|
|
71
|
+
if @initial_message && !@initial_message.empty?
|
|
72
|
+
handle_message(@initial_message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
main_loop
|
|
76
|
+
display_goodbye
|
|
77
|
+
display_session_summary
|
|
78
|
+
rescue Interrupt
|
|
79
|
+
puts "\n"
|
|
80
|
+
display_goodbye
|
|
81
|
+
display_session_summary
|
|
82
|
+
exit(130)
|
|
83
|
+
ensure
|
|
84
|
+
# Save history on exit
|
|
85
|
+
save_persistent_history
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Execute a message with Ctrl+C cancellation support
|
|
89
|
+
# Public for testing
|
|
90
|
+
#
|
|
91
|
+
# @param input [String] User input to execute
|
|
92
|
+
# @return [SwarmSDK::Result, nil] Result or nil if cancelled
|
|
93
|
+
def execute_with_cancellation(input, &log_callback)
|
|
94
|
+
cancelled = false
|
|
95
|
+
result = nil
|
|
96
|
+
|
|
97
|
+
# Execute in Async block to enable Ctrl+C cancellation
|
|
98
|
+
Async do |task|
|
|
99
|
+
# Use Async::Condition for trap-safe cancellation
|
|
100
|
+
# (Condition#signal uses Thread::Queue which is safe from trap context)
|
|
101
|
+
cancel_condition = Async::Condition.new
|
|
102
|
+
|
|
103
|
+
# Install trap ONLY during execution
|
|
104
|
+
# When Ctrl+C is pressed, signal the condition instead of calling task.stop
|
|
105
|
+
old_trap = trap("INT") do
|
|
106
|
+
cancel_condition.signal(:cancel)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
# Execute swarm in async task
|
|
111
|
+
llm_task = task.async do
|
|
112
|
+
@swarm.execute(input, &log_callback)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Monitor task - watches for cancellation signal
|
|
116
|
+
# Must be created AFTER llm_task so it can reference it
|
|
117
|
+
monitor_task = task.async do
|
|
118
|
+
if cancel_condition.wait == :cancel
|
|
119
|
+
cancelled = true
|
|
120
|
+
llm_task.stop
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
result = llm_task.wait
|
|
125
|
+
rescue Async::Stop
|
|
126
|
+
# Task was stopped by Ctrl+C
|
|
127
|
+
cancelled = true
|
|
128
|
+
ensure
|
|
129
|
+
# Clean up monitor task
|
|
130
|
+
monitor_task&.stop if monitor_task&.alive?
|
|
131
|
+
|
|
132
|
+
# CRITICAL: Restore old trap when done
|
|
133
|
+
# This ensures Ctrl+C at the prompt still exits the REPL
|
|
134
|
+
trap("INT", old_trap)
|
|
135
|
+
end
|
|
136
|
+
end.wait
|
|
137
|
+
|
|
138
|
+
cancelled ? nil : result
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Handle slash commands
|
|
142
|
+
# Public for testing
|
|
143
|
+
#
|
|
144
|
+
# @param input [String] Command input (e.g., "/help", "/clear")
|
|
145
|
+
def handle_command(input)
|
|
146
|
+
command = input.split.first.downcase
|
|
147
|
+
|
|
148
|
+
case command
|
|
149
|
+
when "/help"
|
|
150
|
+
display_help
|
|
151
|
+
when "/clear"
|
|
152
|
+
clear_context
|
|
153
|
+
when "/tools"
|
|
154
|
+
list_tools
|
|
155
|
+
when "/history"
|
|
156
|
+
display_history
|
|
157
|
+
when "/defrag"
|
|
158
|
+
defrag_memory
|
|
159
|
+
when "/exit"
|
|
160
|
+
# Break from main loop to trigger session summary
|
|
161
|
+
throw(:exit_repl)
|
|
162
|
+
else
|
|
163
|
+
puts render_error("Unknown command: #{command}")
|
|
164
|
+
puts @colors[:system].call("Type /help for available commands")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Save persistent history to file
|
|
169
|
+
# Public for testing
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
def save_persistent_history
|
|
173
|
+
history_file = self.class.history_file
|
|
174
|
+
return unless history_file
|
|
175
|
+
|
|
176
|
+
history = Reline::HISTORY.to_a
|
|
177
|
+
|
|
178
|
+
# Limit to configured size
|
|
179
|
+
if HISTORY_SIZE.positive? && history.size > HISTORY_SIZE
|
|
180
|
+
history = history.last(HISTORY_SIZE)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Write with secure permissions (owner read/write only)
|
|
184
|
+
File.open(history_file, "w", 0o600, encoding: Encoding::UTF_8) do |f|
|
|
185
|
+
# Handle multi-line entries by escaping newlines with backslash
|
|
186
|
+
history.each do |entry|
|
|
187
|
+
escaped = entry.scrub.split("\n").join("\\\n")
|
|
188
|
+
f.puts(escaped)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
192
|
+
# Can't write history - continue anyway
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def setup_ui_components
|
|
199
|
+
@pastel = Pastel.new(enabled: $stdout.tty?)
|
|
200
|
+
|
|
201
|
+
# Configure Reline for smooth, flicker-free input (like IRB)
|
|
202
|
+
Reline.output = $stdout
|
|
203
|
+
Reline.input = $stdin
|
|
204
|
+
|
|
205
|
+
# Configure tab completion UI colors (Ruby 3.1+)
|
|
206
|
+
configure_completion_ui
|
|
207
|
+
|
|
208
|
+
# Enable automatic completions (show as you type)
|
|
209
|
+
Reline.autocompletion = true
|
|
210
|
+
|
|
211
|
+
# Configure word break characters
|
|
212
|
+
Reline.completer_word_break_characters = " \t\n,;|&"
|
|
213
|
+
|
|
214
|
+
# Disable default autocomplete (uses start_with? filtering)
|
|
215
|
+
Reline.add_dialog_proc(:autocomplete, nil, nil)
|
|
216
|
+
|
|
217
|
+
# Add custom fuzzy completion dialog (bypasses Reline's filtering)
|
|
218
|
+
setup_fuzzy_completion
|
|
219
|
+
|
|
220
|
+
# Rebind Tab to invoke our custom dialog (not the default :complete method)
|
|
221
|
+
config = Reline.core.config
|
|
222
|
+
config.add_default_key_binding_by_keymap(:emacs, [9], :fuzzy_complete)
|
|
223
|
+
config.add_default_key_binding_by_keymap(:vi_insert, [9], :fuzzy_complete)
|
|
224
|
+
|
|
225
|
+
# Configure history size
|
|
226
|
+
Reline.core.config.history_size = HISTORY_SIZE
|
|
227
|
+
|
|
228
|
+
# Setup colors using detached styles for performance
|
|
229
|
+
@colors = {
|
|
230
|
+
prompt: @pastel.bright_cyan.bold.detach,
|
|
231
|
+
user_input: @pastel.white.detach,
|
|
232
|
+
agent_text: @pastel.bright_white.detach,
|
|
233
|
+
agent_label: @pastel.bright_blue.bold.detach,
|
|
234
|
+
success: @pastel.bright_green.detach,
|
|
235
|
+
success_icon: @pastel.bright_green.bold.detach,
|
|
236
|
+
error: @pastel.bright_red.detach,
|
|
237
|
+
error_icon: @pastel.bright_red.bold.detach,
|
|
238
|
+
warning: @pastel.bright_yellow.detach,
|
|
239
|
+
system: @pastel.dim.detach,
|
|
240
|
+
system_bracket: @pastel.bright_black.detach,
|
|
241
|
+
divider: @pastel.bright_black.detach,
|
|
242
|
+
header: @pastel.bright_cyan.bold.detach,
|
|
243
|
+
code: @pastel.bright_magenta.detach,
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def setup_persistent_history
|
|
248
|
+
history_file = self.class.history_file
|
|
249
|
+
|
|
250
|
+
# Ensure history directory exists
|
|
251
|
+
FileUtils.mkdir_p(File.dirname(history_file))
|
|
252
|
+
|
|
253
|
+
# Load history from file
|
|
254
|
+
return unless File.exist?(history_file)
|
|
255
|
+
|
|
256
|
+
File.open(history_file, "r:UTF-8") do |f|
|
|
257
|
+
f.each_line do |line|
|
|
258
|
+
line = line.chomp
|
|
259
|
+
|
|
260
|
+
# Handle multi-line entries (backslash continuation)
|
|
261
|
+
if Reline::HISTORY.last&.end_with?("\\")
|
|
262
|
+
Reline::HISTORY.last.delete_suffix!("\\")
|
|
263
|
+
Reline::HISTORY.last << "\n" << line
|
|
264
|
+
else
|
|
265
|
+
Reline::HISTORY << line unless line.empty?
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
270
|
+
# History file doesn't exist or can't be read - that's OK
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def display_welcome
|
|
275
|
+
divider = @colors[:divider].call("─" * 60)
|
|
276
|
+
|
|
277
|
+
puts ""
|
|
278
|
+
puts divider
|
|
279
|
+
puts @colors[:header].call("🚀 Swarm CLI Interactive REPL")
|
|
280
|
+
puts divider
|
|
281
|
+
puts ""
|
|
282
|
+
puts @colors[:agent_text].call("Swarm: #{@swarm.name}")
|
|
283
|
+
puts @colors[:system].call("Lead Agent: #{@swarm.lead_agent}")
|
|
284
|
+
puts ""
|
|
285
|
+
puts @colors[:system].call("Type your message and press Enter to submit")
|
|
286
|
+
puts @colors[:system].call("Press Option+Enter (or ESC then Enter) for multi-line input")
|
|
287
|
+
puts @colors[:system].call("Type #{@colors[:code].call("/help")} for commands or #{@colors[:code].call("/exit")} to quit")
|
|
288
|
+
puts ""
|
|
289
|
+
puts divider
|
|
290
|
+
puts ""
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def main_loop
|
|
294
|
+
catch(:exit_repl) do
|
|
295
|
+
loop do
|
|
296
|
+
input = read_user_input
|
|
297
|
+
|
|
298
|
+
break if input.nil? # Ctrl+D pressed
|
|
299
|
+
next if input.strip.empty?
|
|
300
|
+
|
|
301
|
+
if input.start_with?("/")
|
|
302
|
+
handle_command(input.strip)
|
|
303
|
+
else
|
|
304
|
+
handle_message(input)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
puts "" # Spacing between interactions
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def read_user_input
|
|
313
|
+
# Display stats separately (they scroll up naturally)
|
|
314
|
+
display_prompt_stats
|
|
315
|
+
|
|
316
|
+
# Build the prompt indicator with colors
|
|
317
|
+
prompt_indicator = build_prompt_indicator
|
|
318
|
+
|
|
319
|
+
# Use Reline.readmultiline for multi-line input support
|
|
320
|
+
# - Option+ENTER (or ESC+ENTER): Adds a newline, continues editing
|
|
321
|
+
# - Regular ENTER: Always submits immediately
|
|
322
|
+
# Second parameter true = add to history for arrow up/down
|
|
323
|
+
# Block always returns true = ENTER always submits
|
|
324
|
+
input = Reline.readmultiline(prompt_indicator, true) { |_lines| true }
|
|
325
|
+
|
|
326
|
+
return if input.nil? # Ctrl+D returns nil
|
|
327
|
+
|
|
328
|
+
# Strip whitespace from the complete input
|
|
329
|
+
input.strip
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def display_prompt_stats
|
|
333
|
+
# Only show stats if we have conversation history
|
|
334
|
+
stats = build_prompt_stats
|
|
335
|
+
puts stats if stats && !stats.empty?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def build_prompt_indicator
|
|
339
|
+
# Reline supports ANSI colors without flickering!
|
|
340
|
+
# Use your beautiful colored prompt
|
|
341
|
+
@pastel.bright_cyan("You") +
|
|
342
|
+
@pastel.bright_black(" ❯ ")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def build_prompt_stats
|
|
346
|
+
return "" if @conversation_history.empty?
|
|
347
|
+
|
|
348
|
+
parts = []
|
|
349
|
+
|
|
350
|
+
# Agent name
|
|
351
|
+
parts << @colors[:agent_label].call(@swarm.lead_agent.to_s)
|
|
352
|
+
|
|
353
|
+
# Message count (user messages only)
|
|
354
|
+
msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
|
|
355
|
+
parts << "#{msg_count} #{msg_count == 1 ? "msg" : "msgs"}"
|
|
356
|
+
|
|
357
|
+
# Get last result stats if available
|
|
358
|
+
if @last_result
|
|
359
|
+
# Token count
|
|
360
|
+
tokens = @last_result.total_tokens
|
|
361
|
+
if tokens > 0
|
|
362
|
+
formatted_tokens = format_number(tokens)
|
|
363
|
+
parts << "#{formatted_tokens} tokens"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Cost
|
|
367
|
+
cost = @last_result.total_cost
|
|
368
|
+
if cost > 0
|
|
369
|
+
formatted_cost = format_cost_value(cost)
|
|
370
|
+
parts << formatted_cost
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Context percentage (from last log entry with usage info)
|
|
374
|
+
if @last_context_percentage
|
|
375
|
+
color_method = context_percentage_color(@last_context_percentage)
|
|
376
|
+
colored_pct = @pastel.public_send(color_method, @last_context_percentage)
|
|
377
|
+
parts << "#{colored_pct} context"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
"[#{parts.join(" • ")}]"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def format_number(num)
|
|
385
|
+
if num >= 1_000_000
|
|
386
|
+
"#{(num / 1_000_000.0).round(1)}M"
|
|
387
|
+
elsif num >= 1_000
|
|
388
|
+
"#{(num / 1_000.0).round(1)}K"
|
|
389
|
+
else
|
|
390
|
+
num.to_s
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def format_cost_value(cost)
|
|
395
|
+
if cost < 0.01
|
|
396
|
+
"$#{format("%.4f", cost)}"
|
|
397
|
+
elsif cost < 1.0
|
|
398
|
+
"$#{format("%.3f", cost)}"
|
|
399
|
+
else
|
|
400
|
+
"$#{format("%.2f", cost)}"
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def context_percentage_color(percentage_string)
|
|
405
|
+
percentage = percentage_string.to_s.gsub("%", "").to_f
|
|
406
|
+
|
|
407
|
+
if percentage < 50
|
|
408
|
+
:green
|
|
409
|
+
elsif percentage < 80
|
|
410
|
+
:yellow
|
|
411
|
+
else
|
|
412
|
+
:red
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def handle_message(input)
|
|
417
|
+
# Add to history
|
|
418
|
+
@conversation_history << { role: "user", content: input }
|
|
419
|
+
|
|
420
|
+
puts ""
|
|
421
|
+
|
|
422
|
+
# Execute with cancellation support
|
|
423
|
+
result = execute_with_cancellation(input) do |log_entry|
|
|
424
|
+
# Skip model warnings - already emitted before first prompt
|
|
425
|
+
next if log_entry[:type] == "model_lookup_warning"
|
|
426
|
+
|
|
427
|
+
@formatter.on_log(log_entry)
|
|
428
|
+
|
|
429
|
+
# Track context percentage from usage info
|
|
430
|
+
if log_entry[:usage] && log_entry[:usage][:tokens_used_percentage]
|
|
431
|
+
@last_context_percentage = log_entry[:usage][:tokens_used_percentage]
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Handle cancellation (result is nil when cancelled)
|
|
436
|
+
if result.nil?
|
|
437
|
+
# Stop all active spinners
|
|
438
|
+
@formatter.spinner_manager.stop_all
|
|
439
|
+
|
|
440
|
+
puts ""
|
|
441
|
+
puts @colors[:warning].call("✗ Request cancelled by user")
|
|
442
|
+
puts ""
|
|
443
|
+
return
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Check for errors
|
|
447
|
+
if result.failure?
|
|
448
|
+
@formatter.on_error(error: result.error, duration: result.duration)
|
|
449
|
+
return
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Display success through formatter (minimal in interactive mode)
|
|
453
|
+
@formatter.on_success(result: result)
|
|
454
|
+
|
|
455
|
+
# Store result for prompt stats and session summary
|
|
456
|
+
@last_result = result
|
|
457
|
+
@session_results << result
|
|
458
|
+
|
|
459
|
+
# Add response to history
|
|
460
|
+
@conversation_history << { role: "agent", content: result.content }
|
|
461
|
+
rescue StandardError => e
|
|
462
|
+
@formatter.on_error(error: e)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def emit_validation_warnings_before_prompt
|
|
466
|
+
# Setup temporary logging to capture and display warnings
|
|
467
|
+
SwarmSDK::LogCollector.on_log do |log_entry|
|
|
468
|
+
@formatter.on_log(log_entry) if log_entry[:type] == "model_lookup_warning"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
SwarmSDK::LogStream.emitter = SwarmSDK::LogCollector
|
|
472
|
+
|
|
473
|
+
# Emit validation warnings as log events
|
|
474
|
+
@swarm.emit_validation_warnings
|
|
475
|
+
|
|
476
|
+
# Clean up
|
|
477
|
+
SwarmSDK::LogCollector.reset!
|
|
478
|
+
SwarmSDK::LogStream.reset!
|
|
479
|
+
|
|
480
|
+
# Add spacing if warnings were shown
|
|
481
|
+
puts "" if @swarm.validate.any?
|
|
482
|
+
rescue StandardError
|
|
483
|
+
# Ignore errors during validation emission
|
|
484
|
+
begin
|
|
485
|
+
SwarmSDK::LogCollector.reset!
|
|
486
|
+
rescue
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
begin
|
|
490
|
+
SwarmSDK::LogStream.reset!
|
|
491
|
+
rescue
|
|
492
|
+
nil
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def display_help
|
|
497
|
+
help_box = TTY::Box.frame(
|
|
498
|
+
@colors[:header].call("Available Commands:"),
|
|
499
|
+
"",
|
|
500
|
+
*COMMANDS.map do |cmd, desc|
|
|
501
|
+
cmd_styled = @colors[:code].call(cmd.ljust(15))
|
|
502
|
+
desc_styled = @colors[:system].call(desc)
|
|
503
|
+
" #{cmd_styled} #{desc_styled}"
|
|
504
|
+
end,
|
|
505
|
+
"",
|
|
506
|
+
@colors[:system].call("Input Tips:"),
|
|
507
|
+
@colors[:system].call(" • Press Enter to submit your message"),
|
|
508
|
+
@colors[:system].call(" • Press Option+Enter (or ESC then Enter) for multi-line input"),
|
|
509
|
+
@colors[:system].call(" • Press Ctrl+C to cancel an ongoing request"),
|
|
510
|
+
@colors[:system].call(" • Press Ctrl+D to exit"),
|
|
511
|
+
@colors[:system].call(" • Use arrow keys for history and editing"),
|
|
512
|
+
@colors[:system].call(" • Type / for commands or @ for file paths"),
|
|
513
|
+
@colors[:system].call(" • Use Shift-Tab to navigate autocomplete menu"),
|
|
514
|
+
border: :light,
|
|
515
|
+
padding: [1, 2],
|
|
516
|
+
align: :left,
|
|
517
|
+
title: { top_left: " HELP " },
|
|
518
|
+
style: {
|
|
519
|
+
border: { fg: :bright_yellow },
|
|
520
|
+
},
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
puts help_box
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def clear_context
|
|
527
|
+
# Get the lead agent
|
|
528
|
+
lead = @swarm.agent(@swarm.lead_agent)
|
|
529
|
+
|
|
530
|
+
# Clear the agent's conversation history
|
|
531
|
+
lead.reset_messages!
|
|
532
|
+
|
|
533
|
+
# Clear REPL conversation history
|
|
534
|
+
@conversation_history.clear
|
|
535
|
+
|
|
536
|
+
# Display confirmation
|
|
537
|
+
puts ""
|
|
538
|
+
puts @colors[:success].call("✓ Conversation context cleared for #{@swarm.lead_agent}")
|
|
539
|
+
puts @colors[:system].call(" Starting fresh - previous messages removed from context")
|
|
540
|
+
puts ""
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def list_tools
|
|
544
|
+
# Get the lead agent
|
|
545
|
+
lead = @swarm.agent(@swarm.lead_agent)
|
|
546
|
+
|
|
547
|
+
# Get tools hash (tool_name => tool_instance)
|
|
548
|
+
tools_hash = lead.tools
|
|
549
|
+
|
|
550
|
+
puts ""
|
|
551
|
+
puts @colors[:header].call("Available Tools for #{@swarm.lead_agent}:")
|
|
552
|
+
puts @colors[:divider].call("─" * 60)
|
|
553
|
+
puts ""
|
|
554
|
+
|
|
555
|
+
if tools_hash.empty?
|
|
556
|
+
puts @colors[:system].call("No tools available")
|
|
557
|
+
return
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Group tools by category
|
|
561
|
+
memory_tools = []
|
|
562
|
+
standard_tools = []
|
|
563
|
+
delegation_tools = []
|
|
564
|
+
mcp_tools = []
|
|
565
|
+
other_tools = []
|
|
566
|
+
|
|
567
|
+
tools_hash.each_value do |tool|
|
|
568
|
+
tool_name = tool.name
|
|
569
|
+
case tool_name
|
|
570
|
+
when /^Memory/, "LoadSkill"
|
|
571
|
+
memory_tools << tool_name
|
|
572
|
+
when /^DelegateTaskTo/
|
|
573
|
+
delegation_tools << tool_name
|
|
574
|
+
when /^mcp__/
|
|
575
|
+
mcp_tools << tool_name
|
|
576
|
+
when "Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob",
|
|
577
|
+
"TodoWrite", "Think", "Clock", "WebFetch",
|
|
578
|
+
"ScratchpadWrite", "ScratchpadRead", "ScratchpadList"
|
|
579
|
+
standard_tools << tool_name
|
|
580
|
+
else
|
|
581
|
+
other_tools << tool_name
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Display tools by category
|
|
586
|
+
if standard_tools.any?
|
|
587
|
+
puts @colors[:agent_label].call("Standard Tools:")
|
|
588
|
+
standard_tools.sort.each do |name|
|
|
589
|
+
puts @colors[:system].call(" • #{name}")
|
|
590
|
+
end
|
|
591
|
+
puts ""
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
if memory_tools.any?
|
|
595
|
+
puts @colors[:agent_label].call("Memory Tools:")
|
|
596
|
+
memory_tools.sort.each do |name|
|
|
597
|
+
puts @colors[:system].call(" • #{name}")
|
|
598
|
+
end
|
|
599
|
+
puts ""
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
if delegation_tools.any?
|
|
603
|
+
puts @colors[:agent_label].call("Delegation Tools:")
|
|
604
|
+
delegation_tools.sort.each do |name|
|
|
605
|
+
puts @colors[:system].call(" • #{name}")
|
|
606
|
+
end
|
|
607
|
+
puts ""
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
if mcp_tools.any?
|
|
611
|
+
puts @colors[:agent_label].call("MCP Tools:")
|
|
612
|
+
mcp_tools.sort.each do |name|
|
|
613
|
+
puts @colors[:system].call(" • #{name}")
|
|
614
|
+
end
|
|
615
|
+
puts ""
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
if other_tools.any?
|
|
619
|
+
puts @colors[:agent_label].call("Other Tools:")
|
|
620
|
+
other_tools.sort.each do |name|
|
|
621
|
+
puts @colors[:system].call(" • #{name}")
|
|
622
|
+
end
|
|
623
|
+
puts ""
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
puts @colors[:divider].call("─" * 60)
|
|
627
|
+
puts @colors[:system].call("Total: #{tools_hash.size} tools")
|
|
628
|
+
puts ""
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def display_history
|
|
632
|
+
if @conversation_history.empty?
|
|
633
|
+
puts @colors[:system].call("No conversation history yet")
|
|
634
|
+
return
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
puts @colors[:header].call("Conversation History:")
|
|
638
|
+
puts @colors[:divider].call("─" * 60)
|
|
639
|
+
puts ""
|
|
640
|
+
|
|
641
|
+
@conversation_history.each_with_index do |entry, index|
|
|
642
|
+
role_label = if entry[:role] == "user"
|
|
643
|
+
@colors[:prompt].call("User")
|
|
644
|
+
else
|
|
645
|
+
@colors[:agent_label].call("Agent")
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
puts "#{index + 1}. #{role_label}:"
|
|
649
|
+
|
|
650
|
+
# Truncate long messages in history view
|
|
651
|
+
content = entry[:content]
|
|
652
|
+
if content.length > 200
|
|
653
|
+
content = content[0...200] + "..."
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
puts @colors[:system].call(" #{content.gsub("\n", "\n ")}")
|
|
657
|
+
puts ""
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
puts @colors[:divider].call("─" * 60)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def defrag_memory
|
|
664
|
+
puts ""
|
|
665
|
+
puts @colors[:header].call("🔧 Memory Defragmentation Workflow")
|
|
666
|
+
puts @colors[:divider].call("─" * 60)
|
|
667
|
+
puts ""
|
|
668
|
+
|
|
669
|
+
# Inject prompt to run find_related then link_related
|
|
670
|
+
prompt = <<~PROMPT.strip
|
|
671
|
+
Run memory defragmentation workflow:
|
|
672
|
+
|
|
673
|
+
1. First, run MemoryDefrag(action: "find_related") to discover related entries
|
|
674
|
+
2. Review the results carefully
|
|
675
|
+
3. Then run MemoryDefrag(action: "link_related", dry_run: false) to create bidirectional links
|
|
676
|
+
|
|
677
|
+
Report what you found and what links were created.
|
|
678
|
+
PROMPT
|
|
679
|
+
|
|
680
|
+
handle_message(prompt)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def display_goodbye
|
|
684
|
+
puts ""
|
|
685
|
+
goodbye_text = @colors[:success].call("👋 Goodbye! Thanks for using Swarm CLI")
|
|
686
|
+
puts goodbye_text
|
|
687
|
+
puts ""
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def display_session_summary
|
|
691
|
+
return if @session_results.empty?
|
|
692
|
+
|
|
693
|
+
# Calculate session totals
|
|
694
|
+
total_tokens = @session_results.sum(&:total_tokens)
|
|
695
|
+
total_cost = @session_results.sum(&:total_cost)
|
|
696
|
+
total_llm_requests = @session_results.sum(&:llm_requests)
|
|
697
|
+
total_tool_calls = @session_results.sum(&:tool_calls_count)
|
|
698
|
+
all_agents = @session_results.flat_map(&:agents_involved).uniq
|
|
699
|
+
|
|
700
|
+
# Get session duration (time from first to last message)
|
|
701
|
+
session_duration = if @session_results.size > 1
|
|
702
|
+
@session_results.map(&:duration).sum
|
|
703
|
+
else
|
|
704
|
+
@session_results.first&.duration || 0
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Render session summary
|
|
708
|
+
divider = @colors[:divider].call("─" * 60)
|
|
709
|
+
puts divider
|
|
710
|
+
puts @colors[:header].call("📊 Session Summary")
|
|
711
|
+
puts divider
|
|
712
|
+
puts ""
|
|
713
|
+
|
|
714
|
+
# Message count
|
|
715
|
+
msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
|
|
716
|
+
puts " #{@colors[:agent_label].call("Messages sent:")} #{msg_count}"
|
|
717
|
+
|
|
718
|
+
# Agents used
|
|
719
|
+
if all_agents.any?
|
|
720
|
+
agents_list = all_agents.map { |agent| @colors[:agent_label].call(agent.to_s) }.join(", ")
|
|
721
|
+
puts " #{@colors[:agent_label].call("Agents used:")} #{agents_list}"
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# LLM requests
|
|
725
|
+
puts " #{@colors[:system].call("LLM Requests:")} #{total_llm_requests}"
|
|
726
|
+
|
|
727
|
+
# Tool calls
|
|
728
|
+
puts " #{@colors[:system].call("Tool Calls:")} #{total_tool_calls}"
|
|
729
|
+
|
|
730
|
+
# Tokens
|
|
731
|
+
formatted_tokens = SwarmCLI::UI::Formatters::Number.format(total_tokens)
|
|
732
|
+
puts " #{@colors[:system].call("Total Tokens:")} #{formatted_tokens}"
|
|
733
|
+
|
|
734
|
+
# Cost (colored)
|
|
735
|
+
formatted_cost = SwarmCLI::UI::Formatters::Cost.format(total_cost, pastel: @pastel)
|
|
736
|
+
puts " #{@colors[:system].call("Total Cost:")} #{formatted_cost}"
|
|
737
|
+
|
|
738
|
+
# Duration
|
|
739
|
+
formatted_duration = SwarmCLI::UI::Formatters::Time.duration(session_duration)
|
|
740
|
+
puts " #{@colors[:system].call("Session Duration:")} #{formatted_duration}"
|
|
741
|
+
|
|
742
|
+
puts ""
|
|
743
|
+
puts divider
|
|
744
|
+
puts ""
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def render_error(message)
|
|
748
|
+
icon = @colors[:error_icon].call("✗")
|
|
749
|
+
text = @colors[:error].call(message)
|
|
750
|
+
"#{icon} #{text}"
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def render_system_message(text)
|
|
754
|
+
bracket_open = @colors[:system_bracket].call("[")
|
|
755
|
+
bracket_close = @colors[:system_bracket].call("]")
|
|
756
|
+
content = @colors[:system].call(text)
|
|
757
|
+
"#{bracket_open}#{content}#{bracket_close}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def configure_completion_ui
|
|
761
|
+
# Only configure if Reline::Face is available (Ruby 3.1+)
|
|
762
|
+
return unless defined?(Reline::Face)
|
|
763
|
+
|
|
764
|
+
Reline::Face.config(:completion_dialog) do |conf|
|
|
765
|
+
conf.define(:default, foreground: :white, background: :blue)
|
|
766
|
+
conf.define(:enhanced, foreground: :black, background: :cyan) # Selected item
|
|
767
|
+
conf.define(:scrollbar, foreground: :cyan, background: :blue)
|
|
768
|
+
end
|
|
769
|
+
rescue StandardError
|
|
770
|
+
# Ignore errors if Face configuration fails
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def setup_fuzzy_completion
|
|
774
|
+
# Capture COMMANDS for use in lambda
|
|
775
|
+
commands = COMMANDS
|
|
776
|
+
|
|
777
|
+
# Capture file completion logic for use in lambda (since lambda runs in different context)
|
|
778
|
+
file_completions = lambda do |target|
|
|
779
|
+
has_at_prefix = target.start_with?("@")
|
|
780
|
+
query = has_at_prefix ? target[1..] : target
|
|
781
|
+
|
|
782
|
+
next Dir.glob("*").sort.first(20) if query.empty?
|
|
783
|
+
|
|
784
|
+
# Find files matching query anywhere in path
|
|
785
|
+
pattern = "**/*#{query}*"
|
|
786
|
+
found = Dir.glob(pattern, File::FNM_CASEFOLD).reject do |path|
|
|
787
|
+
path.split("/").any? { |part| part.start_with?(".") }
|
|
788
|
+
end.sort.first(20)
|
|
789
|
+
|
|
790
|
+
# Add @ prefix if needed
|
|
791
|
+
has_at_prefix ? found.map { |p| "@#{p}" } : found
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Custom dialog proc for fuzzy file/command completion
|
|
795
|
+
fuzzy_proc = lambda do
|
|
796
|
+
# State: [pre, target, post, matches, pointer, navigating]
|
|
797
|
+
|
|
798
|
+
# Check if this is a navigation key press
|
|
799
|
+
is_nav_key = key&.match?(dialog.name)
|
|
800
|
+
|
|
801
|
+
# If we were in navigation mode and user typed a regular key (not Tab), exit nav mode
|
|
802
|
+
if !context.empty? && context.size >= 6 && context[5] && !is_nav_key
|
|
803
|
+
context[5] = false # Exit navigation mode
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Early check: if user typed and current target has spaces, close dialog
|
|
807
|
+
unless is_nav_key || context.empty?
|
|
808
|
+
_, target_check, = retrieve_completion_block
|
|
809
|
+
if target_check.include?(" ")
|
|
810
|
+
context.clear
|
|
811
|
+
return
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Detect if we should recalculate matches
|
|
816
|
+
should_recalculate = if context.empty?
|
|
817
|
+
true # First time - initialize
|
|
818
|
+
elsif is_nav_key
|
|
819
|
+
false # Navigation key - don't recalculate, just cycle
|
|
820
|
+
elsif context.size >= 6 && context[5]
|
|
821
|
+
false # We're in navigation mode - keep matches stable
|
|
822
|
+
else
|
|
823
|
+
true # User typed something - recalculate
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Recalculate matches if user typed
|
|
827
|
+
if should_recalculate
|
|
828
|
+
preposing, target, postposing = retrieve_completion_block
|
|
829
|
+
|
|
830
|
+
# Don't show completions if the target itself has spaces
|
|
831
|
+
# (allows "@lib/swarm" in middle of sentence like "check @lib/swarm file")
|
|
832
|
+
return if target.include?(" ")
|
|
833
|
+
|
|
834
|
+
matches = if target.start_with?("/")
|
|
835
|
+
# Command completions
|
|
836
|
+
query = target[1..] || ""
|
|
837
|
+
commands.keys.map(&:to_s).select do |cmd|
|
|
838
|
+
query.empty? || cmd.downcase.include?(query.downcase)
|
|
839
|
+
end.sort
|
|
840
|
+
elsif target.start_with?("@") || target.include?("/")
|
|
841
|
+
# File path completions - use captured lambda
|
|
842
|
+
file_completions.call(target)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
return if matches.nil? || matches.empty?
|
|
846
|
+
|
|
847
|
+
# Store fresh values - not in navigation mode yet
|
|
848
|
+
context.clear
|
|
849
|
+
context.push(preposing, target, postposing, matches, 0, false)
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Use stored values
|
|
853
|
+
stored_pre, _, stored_post, matches, pointer, _ = context
|
|
854
|
+
|
|
855
|
+
# Handle navigation keys
|
|
856
|
+
if is_nav_key
|
|
857
|
+
# Check if Enter was pressed - close dialog without submitting
|
|
858
|
+
# Must check key.char (not method_symbol, which is :fuzzy_complete when trapped)
|
|
859
|
+
if key.char == "\r" || key.char == "\n"
|
|
860
|
+
# Enter pressed - accept completion and close dialog
|
|
861
|
+
# Clear context so dialog doesn't reappear
|
|
862
|
+
context.clear
|
|
863
|
+
return
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# Update pointer (cycle through matches)
|
|
867
|
+
# Tab is now bound to :fuzzy_complete, Shift-Tab to :completion_journey_up
|
|
868
|
+
pointer = if key.method_symbol == :completion_journey_up
|
|
869
|
+
# Shift-Tab - cycle backward
|
|
870
|
+
(pointer - 1) % matches.size
|
|
871
|
+
else
|
|
872
|
+
# Tab (:fuzzy_complete) - cycle forward
|
|
873
|
+
(pointer + 1) % matches.size
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Update line buffer with selected completion
|
|
877
|
+
selected = matches[pointer]
|
|
878
|
+
|
|
879
|
+
# Get current line editor state
|
|
880
|
+
le = @line_editor
|
|
881
|
+
|
|
882
|
+
new_line = stored_pre + selected + stored_post
|
|
883
|
+
new_cursor = stored_pre.length + selected.bytesize
|
|
884
|
+
|
|
885
|
+
# Update buffer using public APIs
|
|
886
|
+
le.set_current_line(new_line)
|
|
887
|
+
le.byte_pointer = new_cursor
|
|
888
|
+
|
|
889
|
+
# Update state - mark as navigating so we don't recalculate
|
|
890
|
+
context[4] = pointer
|
|
891
|
+
context[5] = true # Now in navigation mode
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# Set visual highlight
|
|
895
|
+
dialog.pointer = pointer
|
|
896
|
+
|
|
897
|
+
# Trap Shift-Tab and Enter (Tab is already bound to our dialog)
|
|
898
|
+
dialog.trap_key = [[27, 91, 90], [13]]
|
|
899
|
+
|
|
900
|
+
# Position dropdown
|
|
901
|
+
x = [cursor_pos.x, 0].max
|
|
902
|
+
y = 0
|
|
903
|
+
|
|
904
|
+
# Return dialog
|
|
905
|
+
Reline::DialogRenderInfo.new(
|
|
906
|
+
pos: Reline::CursorPos.new(x, y),
|
|
907
|
+
contents: matches,
|
|
908
|
+
scrollbar: true,
|
|
909
|
+
height: [15, matches.size].min,
|
|
910
|
+
face: :completion_dialog,
|
|
911
|
+
)
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Register the custom fuzzy dialog
|
|
915
|
+
Reline.add_dialog_proc(:fuzzy_complete, fuzzy_proc, [])
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
end
|