claude_swarm 1.0.4 → 1.0.6

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/Rakefile +4 -4
  4. data/docs/v2/CHANGELOG.swarm_cli.md +19 -0
  5. data/docs/v2/CHANGELOG.swarm_memory.md +19 -0
  6. data/docs/v2/CHANGELOG.swarm_sdk.md +92 -0
  7. data/docs/v2/README.md +56 -22
  8. data/docs/v2/guides/MEMORY_DEFRAG_GUIDE.md +811 -0
  9. data/docs/v2/guides/complete-tutorial.md +115 -3
  10. data/docs/v2/guides/getting-started.md +6 -6
  11. data/docs/v2/guides/rails-integration.md +6 -6
  12. data/docs/v2/reference/architecture-flow.md +407 -0
  13. data/docs/v2/reference/event_payload_structures.md +471 -0
  14. data/docs/v2/reference/execution-flow.md +600 -0
  15. data/docs/v2/reference/ruby-dsl.md +138 -5
  16. data/docs/v2/reference/swarm_memory_technical_details.md +2090 -0
  17. data/examples/v2/swarm_with_hooks.yml +1 -1
  18. data/lib/claude_swarm/cli.rb +9 -11
  19. data/lib/claude_swarm/commands/ps.rb +1 -2
  20. data/lib/claude_swarm/configuration.rb +2 -3
  21. data/lib/claude_swarm/mcp_generator.rb +4 -10
  22. data/lib/claude_swarm/orchestrator.rb +43 -44
  23. data/lib/claude_swarm/system_utils.rb +4 -4
  24. data/lib/claude_swarm/version.rb +1 -1
  25. data/lib/claude_swarm.rb +4 -9
  26. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  27. data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
  28. data/lib/swarm_cli/config_loader.rb +14 -13
  29. data/lib/swarm_cli/version.rb +1 -1
  30. data/lib/swarm_cli.rb +2 -0
  31. data/lib/swarm_memory/adapters/base.rb +4 -4
  32. data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
  33. data/lib/swarm_memory/core/storage.rb +66 -6
  34. data/lib/swarm_memory/integration/sdk_plugin.rb +14 -0
  35. data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
  36. data/lib/swarm_memory/tools/memory_edit.rb +1 -0
  37. data/lib/swarm_memory/tools/memory_glob.rb +24 -1
  38. data/lib/swarm_memory/tools/memory_write.rb +2 -2
  39. data/lib/swarm_memory/version.rb +1 -1
  40. data/lib/swarm_memory.rb +2 -0
  41. data/lib/swarm_sdk/agent/chat.rb +1 -1
  42. data/lib/swarm_sdk/agent/definition.rb +18 -21
  43. data/lib/swarm_sdk/configuration.rb +34 -10
  44. data/lib/swarm_sdk/mcp.rb +16 -0
  45. data/lib/swarm_sdk/node/agent_config.rb +7 -2
  46. data/lib/swarm_sdk/node/builder.rb +130 -35
  47. data/lib/swarm_sdk/node_context.rb +75 -0
  48. data/lib/swarm_sdk/node_orchestrator.rb +219 -12
  49. data/lib/swarm_sdk/plugin.rb +73 -1
  50. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  51. data/lib/swarm_sdk/result.rb +32 -6
  52. data/lib/swarm_sdk/swarm/builder.rb +1 -0
  53. data/lib/swarm_sdk/swarm.rb +32 -50
  54. data/lib/swarm_sdk/tools/delegate.rb +2 -2
  55. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  56. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  57. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  58. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  59. data/lib/swarm_sdk/tools/think.rb +4 -1
  60. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  61. data/lib/swarm_sdk/version.rb +1 -1
  62. data/lib/swarm_sdk.rb +332 -27
  63. data/swarm_sdk.gemspec +1 -1
  64. metadata +9 -3
@@ -10,7 +10,7 @@
10
10
  # Run: swarm run lib/swarm_sdk/examples/swarm_with_hooks.yml -p "your prompt"
11
11
  #
12
12
  # Or programmatically:
13
- # swarm = SwarmSDK::Swarm.load("lib/swarm_sdk/examples/swarm_with_hooks.yml")
13
+ # swarm = SwarmSDK.load_file("lib/swarm_sdk/examples/swarm_with_hooks.yml")
14
14
  # result = swarm.execute("your prompt")
15
15
 
16
16
  version: 2
@@ -43,9 +43,8 @@ module ClaudeSwarm
43
43
  type: :string,
44
44
  desc: "Root directory for resolving relative paths (defaults to current directory)"
45
45
  def start(config_file = nil)
46
- # Set root directory early so it's available to all components
47
- root_dir = options[:root_dir] || Dir.pwd
48
- ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
46
+ # Determine root directory for this session
47
+ root_dir = File.expand_path(options[:root_dir] || Dir.pwd)
49
48
 
50
49
  # Resolve config path relative to root directory
51
50
  config_path = config_file || "claude-swarm.yml"
@@ -71,7 +70,7 @@ module ClaudeSwarm
71
70
  end
72
71
 
73
72
  begin
74
- config = Configuration.new(config_path, base_dir: ClaudeSwarm.root_dir, options: options)
73
+ config = Configuration.new(config_path, base_dir: root_dir, options: options)
75
74
  generator = McpGenerator.new(config, vibe: options[:vibe])
76
75
  orchestrator = Orchestrator.new(
77
76
  config,
@@ -547,24 +546,23 @@ module ClaudeSwarm
547
546
  exit(1)
548
547
  end
549
548
 
550
- # Change to the original root directory if it exists
549
+ # Load the original root directory from session
551
550
  root_dir_file = File.join(session_path, "root_directory")
552
- if File.exist?(root_dir_file)
551
+ root_dir = if File.exist?(root_dir_file)
553
552
  original_dir = File.read(root_dir_file).strip
554
553
  if Dir.exist?(original_dir)
555
- Dir.chdir(original_dir)
556
- ENV["CLAUDE_SWARM_ROOT_DIR"] = original_dir
557
- say("Changed to original directory: #{original_dir}", :green) unless options[:prompt]
554
+ say("Using original directory: #{original_dir}", :green) unless options[:prompt]
555
+ original_dir
558
556
  else
559
557
  error("Original directory no longer exists: #{original_dir}")
560
558
  exit(1)
561
559
  end
562
560
  else
563
561
  # If no root_directory file, use current directory
564
- ENV["CLAUDE_SWARM_ROOT_DIR"] = Dir.pwd
562
+ Dir.pwd
565
563
  end
566
564
 
567
- config = Configuration.new(config_file, base_dir: ClaudeSwarm.root_dir)
565
+ config = Configuration.new(config_file, base_dir: root_dir)
568
566
 
569
567
  # Load session metadata if it exists to check for worktree info
570
568
  session_metadata_file = File.join(session_path, "session_metadata.json")
@@ -96,9 +96,8 @@ module ClaudeSwarm
96
96
  main_instance = config.dig("swarm", "main")
97
97
 
98
98
  # Get base directory from session metadata or root_directory file
99
- base_dir = ClaudeSwarm.root_dir
100
99
  root_dir_file = File.join(session_dir, "root_directory")
101
- base_dir = File.read(root_dir_file).strip if File.exist?(root_dir_file)
100
+ base_dir = File.exist?(root_dir_file) ? File.read(root_dir_file).strip : Dir.pwd
102
101
 
103
102
  # Get all directories - handle both string and array formats
104
103
  dir_config = config.dig("swarm", "instances", main_instance, "directory")
@@ -13,13 +13,12 @@ module ClaudeSwarm
13
13
  ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
14
14
  O_SERIES_MODEL_PATTERN = /^(o\d+(\s+(Preview|preview))?(-pro|-mini|-deep-research|-mini-deep-research)?|gpt-5(-mini|-nano)?)$/
15
15
 
16
- attr_reader :config, :config_path, :swarm, :swarm_name, :main_instance, :instances, :root_directory
16
+ attr_reader :config, :config_path, :swarm, :swarm_name, :main_instance, :instances, :base_dir
17
17
 
18
18
  def initialize(config_path, base_dir: nil, options: {})
19
19
  @config_path = Pathname.new(config_path).expand_path
20
20
  @config_dir = @config_path.dirname
21
- @base_dir = base_dir || @config_dir
22
- @root_directory = @base_dir
21
+ @base_dir = base_dir || @config_dir.to_s
23
22
  @options = options
24
23
  load_and_validate
25
24
  end
@@ -183,21 +183,17 @@ module ClaudeSwarm
183
183
  args.push("--claude-session-id", claude_session_id) if claude_session_id
184
184
  end
185
185
 
186
- # Capture environment variables needed for Ruby and Bundler to work properly
187
- # This includes both BUNDLE_* variables and Ruby-specific variables
186
+ # Capture environment variables needed for running claude-swarm
187
+ # We intentionally exclude Bundler variables to ensure we use the system-installed gem
188
188
  required_env = {}
189
189
 
190
- # Bundle-specific variables
191
- ENV.each do |k, v|
192
- required_env[k] = v if k.start_with?("BUNDLE_")
193
- end
194
-
195
- # Claude Swarm-specific variables
190
+ # Claude Swarm-specific variables (always needed)
196
191
  ENV.each do |k, v|
197
192
  required_env[k] = v if k.start_with?("CLAUDE_SWARM_")
198
193
  end
199
194
 
200
195
  # Ruby-specific variables that MCP servers need
196
+ # Exclude RUBYOPT and RUBYLIB to avoid Bundler interference
201
197
  [
202
198
  "RUBY_ROOT",
203
199
  "RUBY_ENGINE",
@@ -205,8 +201,6 @@ module ClaudeSwarm
205
201
  "GEM_ROOT",
206
202
  "GEM_HOME",
207
203
  "GEM_PATH",
208
- "RUBYOPT",
209
- "RUBYLIB",
210
204
  "PATH",
211
205
  ].each do |key|
212
206
  required_env[key] = ENV[key] if ENV[key]
@@ -38,7 +38,7 @@ module ClaudeSwarm
38
38
  @session_log_path = File.join(@session_path, "session.log")
39
39
  else
40
40
  # Generate new session path
41
- session_params = { working_dir: ClaudeSwarm.root_dir }
41
+ session_params = { working_dir: @config.base_dir }
42
42
  session_params[:session_id] = @provided_session_id if @provided_session_id
43
43
  @session_path = SessionPath.generate(**session_params)
44
44
  SessionPath.ensure_directory(@session_path)
@@ -49,7 +49,6 @@ module ClaudeSwarm
49
49
 
50
50
  end
51
51
  ENV["CLAUDE_SWARM_SESSION_PATH"] = @session_path
52
- ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
53
52
 
54
53
  # Initialize components that depend on session path
55
54
  @process_tracker = ProcessTracker.new(@session_path)
@@ -235,13 +234,11 @@ module ClaudeSwarm
235
234
  before_commands_dir = parent_dir
236
235
  end
237
236
 
238
- Dir.chdir(before_commands_dir) do
239
- success = execute_before_commands?(before_commands)
240
- unless success
241
- non_interactive_output { print("❌ Before commands failed. Aborting swarm launch.") }
242
- cleanup_all
243
- exit(1)
244
- end
237
+ success = execute_before_commands?(before_commands, chdir: before_commands_dir)
238
+ unless success
239
+ non_interactive_output { print("❌ Before commands failed. Aborting swarm launch.") }
240
+ cleanup_all
241
+ exit(1)
245
242
  end
246
243
 
247
244
  non_interactive_output do
@@ -262,19 +259,18 @@ module ClaudeSwarm
262
259
  end
263
260
 
264
261
  # Execute the main instance - this will cascade to other instances via MCP
265
- Dir.chdir(main_instance[:directory]) do
266
- # Execute main Claude instance with unbundled environment to avoid bundler conflicts
267
- # This ensures the main instance runs in a clean environment without inheriting
268
- # Claude Swarm's BUNDLE_* environment variables
269
- Bundler.with_unbundled_env do
270
- if @non_interactive_prompt
271
- stream_to_session_log(*command)
272
- else
273
- system_with_pid!(*command) do |pid|
274
- @process_tracker.track_pid(pid, "claude_#{@config.main_instance}")
275
- non_interactive_output do
276
- puts "✓ Claude instance started with PID: #{pid}"
277
- end
262
+ # Execute main Claude instance with unbundled environment to avoid bundler conflicts
263
+ # This ensures the main instance runs in a clean environment without inheriting
264
+ # Claude Swarm's BUNDLE_* environment variables
265
+ main_dir = main_instance[:directory]
266
+ Bundler.with_unbundled_env do
267
+ if @non_interactive_prompt
268
+ stream_to_session_log(*command, chdir: main_dir)
269
+ else
270
+ system_with_pid!(*command, chdir: main_dir) do |pid|
271
+ @process_tracker.track_pid(pid, "claude_#{@config.main_instance}")
272
+ non_interactive_output do
273
+ puts "✓ Claude instance started with PID: #{pid}"
278
274
  end
279
275
  end
280
276
  end
@@ -306,12 +302,12 @@ module ClaudeSwarm
306
302
  puts
307
303
  end
308
304
 
309
- def execute_before_commands?(commands)
310
- execute_commands(commands, phase: "before", fail_fast: true)
305
+ def execute_before_commands?(commands, chdir:)
306
+ execute_commands(commands, phase: "before", fail_fast: true, chdir: chdir)
311
307
  end
312
308
 
313
- def execute_after_commands?(commands)
314
- execute_commands(commands, phase: "after", fail_fast: false)
309
+ def execute_after_commands?(commands, chdir:)
310
+ execute_commands(commands, phase: "after", fail_fast: false, chdir: chdir)
315
311
  end
316
312
 
317
313
  def execute_after_commands_once
@@ -336,16 +332,14 @@ module ClaudeSwarm
336
332
  after_commands_dir = parent_dir
337
333
  end
338
334
 
339
- Dir.chdir(after_commands_dir) do
340
- non_interactive_output do
341
- print("⚙️ Executing after commands...")
342
- end
335
+ non_interactive_output do
336
+ print("⚙️ Executing after commands...")
337
+ end
343
338
 
344
- success = execute_after_commands?(after_commands)
345
- unless success
346
- non_interactive_output do
347
- puts "⚠️ Some after commands failed"
348
- end
339
+ success = execute_after_commands?(after_commands, chdir: after_commands_dir)
340
+ unless success
341
+ non_interactive_output do
342
+ puts "⚠️ Some after commands failed"
349
343
  end
350
344
  end
351
345
  end
@@ -357,7 +351,7 @@ module ClaudeSwarm
357
351
 
358
352
  # Save the root directory
359
353
  root_dir_file = File.join(session_path, "root_directory")
360
- File.write(root_dir_file, ClaudeSwarm.root_dir)
354
+ File.write(root_dir_file, @config.base_dir)
361
355
 
362
356
  # Save session metadata
363
357
  metadata_file = File.join(session_path, "session_metadata.json")
@@ -366,7 +360,7 @@ module ClaudeSwarm
366
360
 
367
361
  def build_session_metadata
368
362
  {
369
- "root_directory" => ClaudeSwarm.root_dir,
363
+ "root_directory" => @config.base_dir,
370
364
  "timestamp" => Time.now.utc.iso8601,
371
365
  "start_time" => @start_time.utc.iso8601,
372
366
  "swarm_name" => @config.swarm_name,
@@ -642,12 +636,12 @@ module ClaudeSwarm
642
636
  end
643
637
  end
644
638
 
645
- def stream_to_session_log(*command)
639
+ def stream_to_session_log(*command, chdir:)
646
640
  # Setup logger for session logging
647
641
  logger = Logger.new(@session_log_path, level: :info, progname: @config.main_instance)
648
642
 
649
643
  # Use Open3.popen2e to capture stdout and stderr merged for formatting
650
- Open3.popen2e(*command) do |stdin, stdout_and_stderr, wait_thr|
644
+ Open3.popen2e(*command, chdir: chdir) do |stdin, stdout_and_stderr, wait_thr|
651
645
  stdin.close
652
646
 
653
647
  # Read and process the merged output
@@ -819,7 +813,10 @@ module ClaudeSwarm
819
813
  @logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
820
814
  end
821
815
 
822
- def execute_commands(commands, phase:, fail_fast:)
816
+ def execute_commands(commands, phase:, fail_fast:, chdir:)
817
+ raise ArgumentError, "chdir parameter is required" if chdir.nil?
818
+ raise ArgumentError, "chdir must be a valid directory: #{chdir}" unless File.directory?(chdir)
819
+
823
820
  all_succeeded = true
824
821
 
825
822
  # Setup logger for session logging if we have a session path
@@ -839,13 +836,15 @@ module ClaudeSwarm
839
836
  end
840
837
  end
841
838
 
842
- output = %x(#{command} 2>&1)
843
- success = $CHILD_STATUS.success?
839
+ # Use Open3.capture2e with chdir option to execute the command
840
+ # This allows setting the working directory without changing the process directory
841
+ output, status = Open3.capture2e(command, chdir: chdir)
842
+ success = status.success?
844
843
  output_separator = "-" * 80
845
844
 
846
845
  logger.info { "Command output:" }
847
846
  logger.info { output }
848
- logger.info { "Exit status: #{$CHILD_STATUS.exitstatus}" }
847
+ logger.info { "Exit status: #{status.exitstatus}" }
849
848
  logger.info { output_separator }
850
849
 
851
850
  # Show output if in debug mode or if command failed
@@ -854,7 +853,7 @@ module ClaudeSwarm
854
853
  output_prefix = phase == "after" ? "After command" : "Command"
855
854
  puts "#{output_prefix} #{index + 1} output:"
856
855
  puts output
857
- print("Exit status: #{$CHILD_STATUS.exitstatus}")
856
+ print("Exit status: #{status.exitstatus}")
858
857
  end
859
858
  end
860
859
 
@@ -2,14 +2,14 @@
2
2
 
3
3
  module ClaudeSwarm
4
4
  module SystemUtils
5
- def system!(*args)
6
- system(*args)
5
+ def system!(*args, **options)
6
+ system(*args, **options)
7
7
  handle_command_failure(last_status, args)
8
8
  end
9
9
 
10
- def system_with_pid!(*args)
10
+ def system_with_pid!(*args, **options)
11
11
  # Spawn the process - by default, inherits the parent's I/O
12
- pid = Process.spawn(*args)
12
+ pid = Process.spawn(*args, **options)
13
13
 
14
14
  # Yield the PID to the block if given
15
15
  yield(pid) if block_given?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "1.0.4"
4
+ VERSION = "1.0.6"
5
5
  end
data/lib/claude_swarm.rb CHANGED
@@ -30,22 +30,20 @@ require_relative "claude_swarm/version"
30
30
  # Zeitwerk setup
31
31
  require "zeitwerk"
32
32
  loader = Zeitwerk::Loader.new
33
- loader.tag = "claude_swarm"
34
-
33
+ loader.tag = File.basename(__FILE__, ".rb")
35
34
  loader.ignore("#{__dir__}/claude_swarm/templates")
35
+ loader.push_dir("#{__dir__}/claude_swarm", namespace: ClaudeSwarm)
36
+ loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
36
37
  loader.inflector.inflect(
37
38
  "cli" => "CLI",
38
39
  "openai" => "OpenAI",
39
40
  )
41
+ loader.setup
40
42
 
41
43
  module ClaudeSwarm
42
44
  class Error < StandardError; end
43
45
 
44
46
  class << self
45
- def root_dir
46
- ENV.fetch("CLAUDE_SWARM_ROOT_DIR") { Dir.pwd }
47
- end
48
-
49
47
  def home_dir
50
48
  ENV.fetch("CLAUDE_SWARM_HOME") { File.expand_path("~/.claude-swarm") }
51
49
  end
@@ -67,6 +65,3 @@ module ClaudeSwarm
67
65
  end
68
66
  end
69
67
  end
70
-
71
- loader.push_dir("#{__dir__}/claude_swarm", namespace: ClaudeSwarm)
72
- loader.setup
@@ -29,7 +29,7 @@ module SwarmCLI
29
29
 
30
30
  # Validate the swarm configuration
31
31
  begin
32
- SwarmSDK::Swarm.load(config_path)
32
+ SwarmSDK.load_file(config_path)
33
33
  rescue SwarmSDK::ConfigurationError => e
34
34
  $stderr.puts "Error: Invalid swarm configuration: #{e.message}"
35
35
  exit(1)
@@ -92,7 +92,7 @@ module SwarmCLI
92
92
 
93
93
  define_method(:call) do |task:, description: nil, thinking_budget: nil|
94
94
  # Load swarm for each execution (ensures fresh state)
95
- swarm = SwarmSDK::Swarm.load(self.class.config_path)
95
+ swarm = SwarmSDK.load_file(self.class.config_path)
96
96
 
97
97
  # Build prompt with thinking budget if provided
98
98
  prompt = task
@@ -14,9 +14,9 @@ module SwarmCLI
14
14
 
15
15
  def initialize(options)
16
16
  @options = options
17
- # Create scratchpad with persistence for MCP server
18
- scratchpad_path = File.join(Dir.pwd, ".swarm", "scratchpad.json")
19
- @scratchpad = SwarmSDK::Scratchpad.new(persist_to: scratchpad_path)
17
+ # Create volatile scratchpad for MCP server
18
+ # Note: Scratchpad is always volatile - data is not persisted between sessions
19
+ @scratchpad = SwarmSDK::Tools::Stores::ScratchpadStorage.new
20
20
  end
21
21
 
22
22
  def execute
@@ -4,8 +4,8 @@ module SwarmCLI
4
4
  # ConfigLoader handles loading swarm configurations from both YAML and Ruby DSL files.
5
5
  #
6
6
  # Supports:
7
- # - YAML files (.yml, .yaml) - loaded via SwarmSDK::Swarm.load
8
- # - Ruby DSL files (.rb) - executed and expected to return a SwarmSDK::Swarm instance
7
+ # - YAML files (.yml, .yaml) - loaded via SwarmSDK.load_file
8
+ # - Ruby DSL files (.rb) - executed and expected to return a SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance
9
9
  #
10
10
  # @example Load YAML config
11
11
  # swarm = ConfigLoader.load("config.yml")
@@ -18,11 +18,11 @@ module SwarmCLI
18
18
  # Load a swarm configuration from file (YAML or Ruby DSL)
19
19
  #
20
20
  # Detects file type by extension:
21
- # - .yml, .yaml -> Load as YAML using SwarmSDK::Swarm.load
22
- # - .rb -> Execute as Ruby DSL and expect SwarmSDK::Swarm instance
21
+ # - .yml, .yaml -> Load as YAML using SwarmSDK.load_file
22
+ # - .rb -> Execute as Ruby DSL and expect SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance
23
23
  #
24
24
  # @param path [String, Pathname] Path to configuration file
25
- # @return [SwarmSDK::Swarm] Configured swarm instance
25
+ # @return [SwarmSDK::Swarm, SwarmSDK::NodeOrchestrator] Configured swarm or orchestrator instance
26
26
  # @raise [SwarmCLI::ConfigurationError] If file not found or invalid format
27
27
  def load(path)
28
28
  path = Pathname.new(path).expand_path
@@ -50,7 +50,7 @@ module SwarmCLI
50
50
  # @param path [Pathname] Path to YAML file
51
51
  # @return [SwarmSDK::Swarm] Configured swarm instance
52
52
  def load_yaml(path)
53
- SwarmSDK::Swarm.load(path.to_s)
53
+ SwarmSDK.load_file(path.to_s)
54
54
  rescue SwarmSDK::ConfigurationError => e
55
55
  # Re-raise with CLI context
56
56
  raise ConfigurationError, "Configuration error in #{path}: #{e.message}"
@@ -59,12 +59,12 @@ module SwarmCLI
59
59
  # Load Ruby DSL configuration file
60
60
  #
61
61
  # Executes the Ruby file in a clean binding and expects it to return
62
- # a SwarmSDK::Swarm instance. The file should use SwarmSDK.build or
63
- # create a Swarm instance directly.
62
+ # a SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance. The file should
63
+ # use SwarmSDK.build or create a Swarm/NodeOrchestrator instance directly.
64
64
  #
65
65
  # @param path [Pathname] Path to Ruby DSL file
66
- # @return [SwarmSDK::Swarm] Configured swarm instance
67
- # @raise [ConfigurationError] If file doesn't return a Swarm instance
66
+ # @return [SwarmSDK::Swarm, SwarmSDK::NodeOrchestrator] Configured swarm or orchestrator instance
67
+ # @raise [ConfigurationError] If file doesn't return a valid instance
68
68
  def load_ruby_dsl(path)
69
69
  # Read the file content
70
70
  content = path.read
@@ -73,10 +73,11 @@ module SwarmCLI
73
73
  # This allows the DSL file to use SwarmSDK.build directly
74
74
  result = eval(content, binding, path.to_s, 1) # rubocop:disable Security/Eval
75
75
 
76
- # Validate result is a Swarm instance
77
- unless result.is_a?(SwarmSDK::Swarm)
76
+ # Validate result is a Swarm or NodeOrchestrator instance
77
+ # Both have the same execute(prompt) interface
78
+ unless result.is_a?(SwarmSDK::Swarm) || result.is_a?(SwarmSDK::NodeOrchestrator)
78
79
  raise ConfigurationError,
79
- "Ruby DSL file must return a SwarmSDK::Swarm instance. " \
80
+ "Ruby DSL file must return a SwarmSDK::Swarm or SwarmSDK::NodeOrchestrator instance. " \
80
81
  "Got: #{result.class}. " \
81
82
  "Use: SwarmSDK.build { ... } or Swarm.new(...)"
82
83
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmCLI
4
- VERSION = "2.1.0"
4
+ VERSION = "2.1.2"
5
5
  end
data/lib/swarm_cli.rb CHANGED
@@ -22,7 +22,9 @@ require_relative "swarm_cli/version"
22
22
 
23
23
  require "zeitwerk"
24
24
  loader = Zeitwerk::Loader.new
25
+ loader.tag = File.basename(__FILE__, ".rb")
25
26
  loader.push_dir("#{__dir__}/swarm_cli", namespace: SwarmCLI)
27
+ loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
26
28
  loader.inflector.inflect(
27
29
  "cli" => "CLI",
28
30
  "ui" => "UI",
@@ -7,11 +7,11 @@ module SwarmMemory
7
7
  # Subclasses must implement all public methods to provide
8
8
  # different storage backends (filesystem, Redis, SQLite, etc.)
9
9
  class Base
10
- # Maximum size per entry (1MB)
11
- MAX_ENTRY_SIZE = 1_000_000
10
+ # Maximum size per entry (3MB)
11
+ MAX_ENTRY_SIZE = 3_000_000
12
12
 
13
- # Maximum total storage size (100MB)
14
- MAX_TOTAL_SIZE = 100_000_000
13
+ # Maximum total storage size (100GB)
14
+ MAX_TOTAL_SIZE = 100_000_000_000
15
15
 
16
16
  # Write content to storage
17
17
  #
@@ -171,12 +171,6 @@ module SwarmMemory
171
171
 
172
172
  content = File.read(md_file)
173
173
 
174
- # Check if it's a stub (redirect)
175
- if stub_content?(content)
176
- target_path = extract_redirect_target(content)
177
- return read(file_path: target_path) if target_path
178
- end
179
-
180
174
  # Increment hit counter
181
175
  increment_hits(file_path)
182
176
 
@@ -205,12 +199,6 @@ module SwarmMemory
205
199
 
206
200
  content = File.read(md_file)
207
201
 
208
- # Follow stub redirect if applicable
209
- if stub_content?(content)
210
- target_path = extract_redirect_target(content)
211
- return read_entry(file_path: target_path) if target_path
212
- end
213
-
214
202
  # Read metadata
215
203
  yaml_data = File.exist?(yaml_file) ? YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol]) : {}
216
204
 
@@ -95,22 +95,82 @@ module SwarmMemory
95
95
  )
96
96
  end
97
97
 
98
- # Read content from storage
98
+ # Read content from storage, automatically following stub redirects
99
99
  #
100
100
  # @param file_path [String] Path to read from
101
101
  # @return [String] Content at the path
102
102
  def read(file_path:)
103
- normalized_path = PathNormalizer.normalize(file_path)
104
- @adapter.read(file_path: normalized_path)
103
+ entry = read_entry(file_path: file_path)
104
+ entry.content
105
105
  end
106
106
 
107
- # Read full entry with metadata
107
+ # Read full entry with metadata, automatically following stub redirects
108
+ #
109
+ # Stub redirects are created by MemoryDefrag when merging/moving entries.
110
+ # This method transparently follows redirect chains up to 5 levels deep.
108
111
  #
109
112
  # @param file_path [String] Path to read from
113
+ # @param visited [Array<String>] Internal: tracks visited paths to detect circular redirects
110
114
  # @return [Entry] Full entry object
111
- def read_entry(file_path:)
115
+ # @raise [ArgumentError] If path not found, circular redirect detected, or too many redirects
116
+ def read_entry(file_path:, visited: [])
112
117
  normalized_path = PathNormalizer.normalize(file_path)
113
- @adapter.read_entry(file_path: normalized_path)
118
+
119
+ # Detect circular redirects immediately
120
+ if visited.include?(normalized_path)
121
+ cycle = visited + [normalized_path]
122
+ raise ArgumentError,
123
+ "Circular redirect detected in memory storage: #{cycle.join(" → ")}\n\n" \
124
+ "This indicates corrupted stub files. Please run MemoryDefrag to repair:\n " \
125
+ "MemoryDefrag(action: \"analyze\")"
126
+ end
127
+
128
+ # Check depth limit (prevent infinite chains)
129
+ if visited.size >= 5
130
+ chain = visited + [normalized_path]
131
+ raise ArgumentError,
132
+ "Memory redirect chain too deep (>5 redirects): #{chain.join(" → ")}\n\n" \
133
+ "This indicates fragmented memory storage. Please run maintenance:\n " \
134
+ "MemoryDefrag(action: \"full\", dry_run: true) # Preview first\n " \
135
+ "MemoryDefrag(action: \"full\", dry_run: false) # Execute"
136
+ end
137
+
138
+ # Read entry from adapter
139
+ begin
140
+ entry = @adapter.read_entry(file_path: normalized_path)
141
+ rescue ArgumentError
142
+ # If this is a redirect target that doesn't exist, provide helpful error
143
+ if visited.empty?
144
+ # Not a redirect, just re-raise original error
145
+ raise
146
+ else
147
+ original_path = visited.first
148
+ raise ArgumentError,
149
+ "memory://#{original_path} was redirected to memory://#{normalized_path}, but the target was not found.\n\n" \
150
+ "The original entry may have been merged or moved incorrectly. " \
151
+ "Run MemoryDefrag to identify and fix broken redirects:\n " \
152
+ "MemoryDefrag(action: \"analyze\")"
153
+ end
154
+ end
155
+
156
+ # Check if this is a stub redirect
157
+ if entry.metadata && entry.metadata["stub"] == true
158
+ redirect_target = entry.metadata["redirect_to"]
159
+
160
+ # Validate redirect target exists
161
+ if redirect_target.nil? || redirect_target.strip.empty?
162
+ raise ArgumentError,
163
+ "memory://#{normalized_path} is a stub with invalid redirect metadata.\n\n" \
164
+ "This should never happen (stubs are created by MemoryDefrag). " \
165
+ "The stub file may be corrupted. Please report this as a bug."
166
+ end
167
+
168
+ # Follow redirect recursively, tracking visited paths
169
+ return read_entry(file_path: redirect_target, visited: visited + [normalized_path])
170
+ end
171
+
172
+ # Not a stub, return the entry
173
+ entry
114
174
  end
115
175
 
116
176
  # Delete an entry
@@ -207,6 +207,19 @@ module SwarmMemory
207
207
  agent_definition.memory_enabled?
208
208
  end
209
209
 
210
+ # Contribute to agent serialization
211
+ #
212
+ # Preserves memory configuration when agents are cloned (e.g., in NodeOrchestrator).
213
+ # This allows memory configuration to persist across node transitions.
214
+ #
215
+ # @param agent_definition [Agent::Definition] Agent definition
216
+ # @return [Hash] Memory config to include in to_h
217
+ def serialize_config(agent_definition:)
218
+ return {} unless agent_definition.memory
219
+
220
+ { memory: agent_definition.memory }
221
+ end
222
+
210
223
  # Lifecycle: Agent initialized
211
224
  #
212
225
  # Filters tools by mode (removing non-mode tools), registers LoadSkill,
@@ -287,6 +300,7 @@ module SwarmMemory
287
300
  def on_user_message(agent_name:, prompt:, is_first_message:)
288
301
  storage = @storages[agent_name]
289
302
  return [] unless storage&.semantic_index
303
+ return [] if prompt.empty?
290
304
 
291
305
  # Adaptive threshold based on query length
292
306
  # Short queries use lower threshold as they have less semantic richness
@@ -747,7 +747,11 @@ module SwarmMemory
747
747
  # @param to [String] Target path
748
748
  # @param reason [String] Reason (merged, moved)
749
749
  # @return [void]
750
+ # @raise [ArgumentError] If target path or reason is nil/empty
750
751
  def create_stub(from:, to:, reason:)
752
+ raise ArgumentError, "Cannot create stub without target path" if to.nil? || to.strip.empty?
753
+ raise ArgumentError, "Cannot create stub without reason" if reason.nil? || reason.strip.empty?
754
+
751
755
  stub_content = "# #{reason} → #{to}\n\nThis entry was #{reason} into #{to}."
752
756
 
753
757
  @adapter.write(