swarm_sdk 2.0.3 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9aba64a75c41f9957bb3e11afbe0dd5a5fc6422793fda5d3fb829ac0042cbe18
4
- data.tar.gz: 4f403fb7d107fa712a8b847775fea1140a03904cf066b8e848b914f4b3c573e3
3
+ metadata.gz: 2d1058470f6d37e95003807ae0ea2201aa587c8026417a5d8a793cbd59b80b46
4
+ data.tar.gz: 266b8559bf752f72a6db3a8b47fae0890d6aa34f3edf7d66a61e6a4cb9c28d6c
5
5
  SHA512:
6
- metadata.gz: 204b9d889968b2d582557b06169d02828abd4c365b00e2a6551cc7f4c44a2ec7ae452a55d1e25b59fdc8a0b10c486c365cce2b2332d02f0d54b502ec6667af75
7
- data.tar.gz: ae46da01755ef0c98b162c7c2be3c51ba8831dbbdd10301f937e0eed0260f9694b7514658d437e9d65d9cc58e06d7a877600ec29fd48eb773eec8514d74440b1
6
+ metadata.gz: cb6a9ccd6fa58cf55c062201f23391ebe10476894d5fc7f4838fbf90ade073d9e438257c0a007e20a636bba35764de8c434e381fdbc9114354e76c9d87252239
7
+ data.tar.gz: f281689946db6a04ab879f23fc922d7d06d3719a68f515e316b3497cc0ce0e075428623518e6f3ba9cee235f5b0389e2360406c49479a1b9e7c7a22ec533fcec
@@ -20,7 +20,8 @@ module SwarmSDK
20
20
  :TodoWrite,
21
21
  :ScratchpadWrite,
22
22
  :ScratchpadRead,
23
- :ScratchpadList,
23
+ :ScratchpadGlob,
24
+ :ScratchpadGrep,
24
25
  :Think,
25
26
  ].freeze
26
27
 
@@ -73,9 +74,15 @@ module SwarmSDK
73
74
  when :ScratchpadWrite
74
75
  Tools::ScratchpadWrite.create_for_scratchpad(@scratchpad)
75
76
  when :ScratchpadRead
76
- Tools::ScratchpadRead.create_for_scratchpad(@scratchpad)
77
- when :ScratchpadList
78
- Tools::ScratchpadList.create_for_scratchpad(@scratchpad)
77
+ Tools::ScratchpadRead.create_for_scratchpad(@scratchpad, agent_name)
78
+ when :ScratchpadEdit
79
+ Tools::ScratchpadEdit.create_for_scratchpad(@scratchpad, agent_name)
80
+ when :ScratchpadMultiEdit
81
+ Tools::ScratchpadMultiEdit.create_for_scratchpad(@scratchpad, agent_name)
82
+ when :ScratchpadGlob
83
+ Tools::ScratchpadGlob.create_for_scratchpad(@scratchpad)
84
+ when :ScratchpadGrep
85
+ Tools::ScratchpadGrep.create_for_scratchpad(@scratchpad)
79
86
  when :Think
80
87
  Tools::Think.new
81
88
  else
@@ -129,7 +129,8 @@ module SwarmSDK
129
129
  # @param name [String] Human-readable swarm name
130
130
  # @param global_concurrency [Integer] Max concurrent LLM calls across entire swarm
131
131
  # @param default_local_concurrency [Integer] Default max concurrent tool calls per agent
132
- def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY)
132
+ # @param scratchpad [Tools::Stores::Scratchpad, nil] Optional scratchpad instance (for testing)
133
+ def initialize(name:, global_concurrency: DEFAULT_GLOBAL_CONCURRENCY, default_local_concurrency: DEFAULT_LOCAL_CONCURRENCY, scratchpad: nil)
133
134
  @name = name
134
135
  @global_concurrency = global_concurrency
135
136
  @default_local_concurrency = default_local_concurrency
@@ -138,7 +139,11 @@ module SwarmSDK
138
139
  @global_semaphore = Async::Semaphore.new(@global_concurrency)
139
140
 
140
141
  # Shared scratchpad for all agents
141
- @scratchpad = Tools::Stores::Scratchpad.new
142
+ # Use provided scratchpad (for testing) or create persistent one
143
+ @scratchpad = scratchpad || begin
144
+ scratchpad_path = File.join(Dir.pwd, ".swarm", "scratchpad.json")
145
+ Tools::Stores::Scratchpad.new(persist_to: scratchpad_path)
146
+ end
142
147
 
143
148
  # Hook registry for named hooks and swarm defaults
144
149
  @hook_registry = Hooks::Registry.new
@@ -19,7 +19,8 @@ module SwarmSDK
19
19
  TodoWrite: :special, # Requires agent context for todo tracking
20
20
  ScratchpadWrite: :special, # Requires scratchpad instance
21
21
  ScratchpadRead: :special, # Requires scratchpad instance
22
- ScratchpadList: :special, # Requires scratchpad instance
22
+ ScratchpadGlob: :special, # Requires scratchpad instance
23
+ ScratchpadGrep: :special, # Requires scratchpad instance
23
24
  Think: SwarmSDK::Tools::Think,
24
25
  }.freeze
25
26
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Tool for editing scratchpad entries with exact string replacement
6
+ #
7
+ # Performs exact string replacements in scratchpad content.
8
+ # All agents in the swarm share the same scratchpad.
9
+ class ScratchpadEdit < RubyLLM::Tool
10
+ define_method(:name) { "ScratchpadEdit" }
11
+
12
+ description <<~DESC
13
+ Performs exact string replacements in scratchpad entries.
14
+ Works like the Edit tool but operates on scratchpad content instead of files.
15
+ You must use ScratchpadRead on the entry before editing it.
16
+ When editing text from ScratchpadRead output, ensure you preserve the exact indentation as it appears AFTER the line number prefix.
17
+ The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual content to match.
18
+ Never include any part of the line number prefix in the old_string or new_string.
19
+ The edit will FAIL if old_string is not unique in the entry. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
20
+ Use replace_all for replacing and renaming strings across the entry.
21
+ DESC
22
+
23
+ param :file_path,
24
+ desc: "Path to the scratchpad entry (e.g., 'analysis/report', 'parallel/batch1/task_0')",
25
+ required: true
26
+
27
+ param :old_string,
28
+ desc: "The exact text to replace (must match exactly including whitespace)",
29
+ required: true
30
+
31
+ param :new_string,
32
+ desc: "The text to replace it with (must be different from old_string)",
33
+ required: true
34
+
35
+ param :replace_all,
36
+ desc: "Replace all occurrences of old_string (default false)",
37
+ required: false
38
+
39
+ class << self
40
+ # Create a ScratchpadEdit tool for a specific scratchpad instance
41
+ #
42
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
43
+ # @param agent_name [Symbol, String] Agent identifier for tracking reads
44
+ # @return [ScratchpadEdit] Tool instance
45
+ def create_for_scratchpad(scratchpad, agent_name)
46
+ new(scratchpad, agent_name)
47
+ end
48
+ end
49
+
50
+ # Initialize with scratchpad instance and agent name
51
+ #
52
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
53
+ # @param agent_name [Symbol, String] Agent identifier
54
+ def initialize(scratchpad, agent_name)
55
+ super() # Call RubyLLM::Tool's initialize
56
+ @scratchpad = scratchpad
57
+ @agent_name = agent_name.to_sym
58
+ end
59
+
60
+ # Execute the tool
61
+ #
62
+ # @param file_path [String] Path to scratchpad entry
63
+ # @param old_string [String] Text to replace
64
+ # @param new_string [String] Replacement text
65
+ # @param replace_all [Boolean] Replace all occurrences
66
+ # @return [String] Success message or error
67
+ def execute(file_path:, old_string:, new_string:, replace_all: false)
68
+ # Validate inputs
69
+ return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
70
+ return validation_error("old_string is required") if old_string.nil? || old_string.empty?
71
+ return validation_error("new_string is required") if new_string.nil?
72
+
73
+ # old_string and new_string must be different
74
+ if old_string == new_string
75
+ return validation_error("old_string and new_string must be different. They are currently identical.")
76
+ end
77
+
78
+ # Read current content (this will raise ArgumentError if entry doesn't exist)
79
+ content = scratchpad.read(file_path: file_path)
80
+
81
+ # Enforce read-before-edit
82
+ unless Stores::ScratchpadReadTracker.entry_read?(@agent_name, file_path)
83
+ return validation_error(
84
+ "Cannot edit scratchpad entry without reading it first. " \
85
+ "You must use ScratchpadRead on 'scratchpad://#{file_path}' before editing it. " \
86
+ "This ensures you have the current content to match against.",
87
+ )
88
+ end
89
+
90
+ # Check if old_string exists in content
91
+ unless content.include?(old_string)
92
+ return validation_error(<<~ERROR.chomp)
93
+ old_string not found in scratchpad entry. Make sure it matches exactly, including all whitespace and indentation.
94
+ Do not include line number prefixes from ScratchpadRead tool output.
95
+ ERROR
96
+ end
97
+
98
+ # Count occurrences
99
+ occurrences = content.scan(old_string).count
100
+
101
+ # If not replace_all and multiple occurrences, error
102
+ if !replace_all && occurrences > 1
103
+ return validation_error(<<~ERROR.chomp)
104
+ Found #{occurrences} occurrences of old_string.
105
+ Either provide more surrounding context to make the match unique, or use replace_all: true to replace all occurrences.
106
+ ERROR
107
+ end
108
+
109
+ # Perform replacement
110
+ new_content = if replace_all
111
+ content.gsub(old_string, new_string)
112
+ else
113
+ content.sub(old_string, new_string)
114
+ end
115
+
116
+ # Get existing entry metadata
117
+ entries = scratchpad.list
118
+ existing_entry = entries.find { |e| e[:path] == file_path }
119
+
120
+ # Write updated content back (preserving the title)
121
+ scratchpad.write(
122
+ file_path: file_path,
123
+ content: new_content,
124
+ title: existing_entry[:title],
125
+ )
126
+
127
+ # Build success message
128
+ replaced_count = replace_all ? occurrences : 1
129
+ "Successfully replaced #{replaced_count} occurrence(s) in scratchpad://#{file_path}"
130
+ rescue ArgumentError => e
131
+ validation_error(e.message)
132
+ end
133
+
134
+ private
135
+
136
+ attr_reader :scratchpad
137
+
138
+ def validation_error(message)
139
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
140
+ end
141
+ end
142
+ end
143
+ end
@@ -2,28 +2,34 @@
2
2
 
3
3
  module SwarmSDK
4
4
  module Tools
5
- # Tool for listing scratchpad entries with metadata
5
+ # Tool for searching scratchpad entries by glob pattern
6
6
  #
7
- # Lists available scratchpad entries with titles and sizes.
8
- # Supports filtering by path prefix.
9
- class ScratchpadList < RubyLLM::Tool
10
- define_method(:name) { "ScratchpadList" }
7
+ # Finds scratchpad entries matching a glob pattern (like filesystem glob).
8
+ # All agents in the swarm share the same scratchpad.
9
+ class ScratchpadGlob < RubyLLM::Tool
10
+ define_method(:name) { "ScratchpadGlob" }
11
11
 
12
12
  description <<~DESC
13
- List available scratchpad entries with titles and metadata.
14
- Use this to discover what content is available in scratchpad memory.
15
- Optionally filter by path prefix.
13
+ Search scratchpad entries by glob pattern.
14
+ Works like filesystem glob - use * for wildcards, ** for recursive matching.
15
+ Use this to discover entries matching specific patterns.
16
+
17
+ Examples:
18
+ - "parallel/*" - all entries directly under parallel/
19
+ - "parallel/**" - all entries under parallel/ (recursive)
20
+ - "*/report" - all entries named "report" in any top-level directory
21
+ - "analysis/*/result_*" - entries like "analysis/foo/result_1"
16
22
  DESC
17
23
 
18
- param :prefix,
19
- desc: "Filter by path prefix (e.g., 'parallel/', 'analysis/'). Leave empty to list all entries.",
20
- required: false
24
+ param :pattern,
25
+ desc: "Glob pattern to match (e.g., '**/*.txt', 'parallel/*/task_*')",
26
+ required: true
21
27
 
22
28
  class << self
23
- # Create a ScratchpadList tool for a specific scratchpad instance
29
+ # Create a ScratchpadGlob tool for a specific scratchpad instance
24
30
  #
25
31
  # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
26
- # @return [ScratchpadList] Tool instance
32
+ # @return [ScratchpadGlob] Tool instance
27
33
  def create_for_scratchpad(scratchpad)
28
34
  new(scratchpad)
29
35
  end
@@ -39,19 +45,17 @@ module SwarmSDK
39
45
 
40
46
  # Execute the tool
41
47
  #
42
- # @param prefix [String, nil] Optional path prefix filter
43
- # @return [String] Formatted list of entries
44
- def execute(prefix: nil)
45
- entries = scratchpad.list(prefix: prefix)
48
+ # @param pattern [String] Glob pattern to match
49
+ # @return [String] Formatted list of matching entries
50
+ def execute(pattern:)
51
+ entries = scratchpad.glob(pattern: pattern)
46
52
 
47
53
  if entries.empty?
48
- return "Scratchpad is empty" if prefix.nil? || prefix.empty?
49
-
50
- return "No entries found with prefix '#{prefix}'"
54
+ return "No entries found matching pattern '#{pattern}'"
51
55
  end
52
56
 
53
57
  result = []
54
- result << "Scratchpad contents (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
58
+ result << "Scratchpad entries matching '#{pattern}' (#{entries.size} #{entries.size == 1 ? "entry" : "entries"}):"
55
59
 
56
60
  entries.each do |entry|
57
61
  result << " scratchpad://#{entry[:path]} - \"#{entry[:title]}\" (#{format_bytes(entry[:size])})"
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Tool for searching scratchpad content by pattern
6
+ #
7
+ # Searches content stored in scratchpad entries using regex patterns.
8
+ # All agents in the swarm share the same scratchpad.
9
+ class ScratchpadGrep < RubyLLM::Tool
10
+ define_method(:name) { "ScratchpadGrep" }
11
+
12
+ description <<~DESC
13
+ Search scratchpad content by pattern (like grep).
14
+ Use regex patterns to search content within scratchpad entries.
15
+ Returns matching entries and optionally line numbers and content.
16
+
17
+ Output modes:
18
+ - files_with_matches: Only list paths containing matches (default)
19
+ - content: Show matching lines with line numbers
20
+ - count: Show number of matches per file
21
+ DESC
22
+
23
+ param :pattern,
24
+ desc: "Regular expression pattern to search for",
25
+ required: true
26
+
27
+ param :case_insensitive,
28
+ desc: "Perform case-insensitive search (default: false)",
29
+ required: false
30
+
31
+ param :output_mode,
32
+ desc: "Output mode: 'files_with_matches' (default), 'content', or 'count'",
33
+ required: false
34
+
35
+ class << self
36
+ # Create a ScratchpadGrep tool for a specific scratchpad instance
37
+ #
38
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
39
+ # @return [ScratchpadGrep] Tool instance
40
+ def create_for_scratchpad(scratchpad)
41
+ new(scratchpad)
42
+ end
43
+ end
44
+
45
+ # Initialize with scratchpad instance
46
+ #
47
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
48
+ def initialize(scratchpad)
49
+ super() # Call RubyLLM::Tool's initialize
50
+ @scratchpad = scratchpad
51
+ end
52
+
53
+ # Execute the tool
54
+ #
55
+ # @param pattern [String] Regex pattern to search for
56
+ # @param case_insensitive [Boolean] Whether to perform case-insensitive search
57
+ # @param output_mode [String] Output mode
58
+ # @return [String] Formatted search results
59
+ def execute(pattern:, case_insensitive: false, output_mode: "files_with_matches")
60
+ results = scratchpad.grep(
61
+ pattern: pattern,
62
+ case_insensitive: case_insensitive,
63
+ output_mode: output_mode,
64
+ )
65
+
66
+ format_results(results, pattern, output_mode)
67
+ rescue ArgumentError => e
68
+ validation_error(e.message)
69
+ rescue RegexpError => e
70
+ validation_error("Invalid regex pattern: #{e.message}")
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :scratchpad
76
+
77
+ def validation_error(message)
78
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
79
+ end
80
+
81
+ def format_results(results, pattern, output_mode)
82
+ case output_mode
83
+ when "files_with_matches"
84
+ format_files_with_matches(results, pattern)
85
+ when "content"
86
+ format_content(results, pattern)
87
+ when "count"
88
+ format_count(results, pattern)
89
+ else
90
+ validation_error("Invalid output_mode: #{output_mode}")
91
+ end
92
+ end
93
+
94
+ def format_files_with_matches(paths, pattern)
95
+ if paths.empty?
96
+ return "No matches found for pattern '#{pattern}'"
97
+ end
98
+
99
+ result = []
100
+ result << "Scratchpad entries matching '#{pattern}' (#{paths.size} #{paths.size == 1 ? "entry" : "entries"}):"
101
+ paths.each do |path|
102
+ result << " scratchpad://#{path}"
103
+ end
104
+ result.join("\n")
105
+ end
106
+
107
+ def format_content(results, pattern)
108
+ if results.empty?
109
+ return "No matches found for pattern '#{pattern}'"
110
+ end
111
+
112
+ total_matches = results.sum { |r| r[:matches].size }
113
+ output = []
114
+ output << "Scratchpad entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} #{total_matches == 1 ? "match" : "matches"}):"
115
+ output << ""
116
+
117
+ results.each do |result|
118
+ output << "scratchpad://#{result[:path]}:"
119
+ result[:matches].each do |match|
120
+ output << " #{match[:line_number]}: #{match[:content]}"
121
+ end
122
+ output << ""
123
+ end
124
+
125
+ output.join("\n").rstrip
126
+ end
127
+
128
+ def format_count(results, pattern)
129
+ if results.empty?
130
+ return "No matches found for pattern '#{pattern}'"
131
+ end
132
+
133
+ total_matches = results.sum { |r| r[:count] }
134
+ output = []
135
+ output << "Scratchpad entries matching '#{pattern}' (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} total #{total_matches == 1 ? "match" : "matches"}):"
136
+
137
+ results.each do |result|
138
+ output << " scratchpad://#{result[:path]}: #{result[:count]} #{result[:count] == 1 ? "match" : "matches"}"
139
+ end
140
+
141
+ output.join("\n")
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ # Tool for performing multiple edits to a scratchpad entry
6
+ #
7
+ # Applies multiple edit operations sequentially to a single scratchpad entry.
8
+ # Each edit sees the result of all previous edits, allowing for
9
+ # coordinated multi-step transformations.
10
+ # All agents in the swarm share the same scratchpad.
11
+ class ScratchpadMultiEdit < RubyLLM::Tool
12
+ define_method(:name) { "ScratchpadMultiEdit" }
13
+
14
+ description <<~DESC
15
+ Performs multiple exact string replacements in a single scratchpad entry.
16
+ Edits are applied sequentially, so later edits see the results of earlier ones.
17
+ You must use ScratchpadRead on the entry before editing it.
18
+ When editing text from ScratchpadRead output, ensure you preserve the exact indentation as it appears AFTER the line number prefix.
19
+ The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual content to match.
20
+ Never include any part of the line number prefix in the old_string or new_string.
21
+ Each edit will FAIL if old_string is not unique in the entry. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
22
+ Use replace_all for replacing and renaming strings across the entry.
23
+ DESC
24
+
25
+ param :file_path,
26
+ desc: "Path to the scratchpad entry (e.g., 'analysis/report', 'parallel/batch1/task_0')",
27
+ required: true
28
+
29
+ param :edits_json,
30
+ type: "string",
31
+ desc: <<~DESC.chomp,
32
+ JSON array of edit operations. Each edit must have:
33
+ old_string (exact text to replace),
34
+ new_string (replacement text),
35
+ and optionally replace_all (boolean, default false).
36
+ Example: [{"old_string":"foo","new_string":"bar","replace_all":false}]
37
+ DESC
38
+ required: true
39
+
40
+ class << self
41
+ # Create a ScratchpadMultiEdit tool for a specific scratchpad instance
42
+ #
43
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
44
+ # @param agent_name [Symbol, String] Agent identifier for tracking reads
45
+ # @return [ScratchpadMultiEdit] Tool instance
46
+ def create_for_scratchpad(scratchpad, agent_name)
47
+ new(scratchpad, agent_name)
48
+ end
49
+ end
50
+
51
+ # Initialize with scratchpad instance and agent name
52
+ #
53
+ # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
54
+ # @param agent_name [Symbol, String] Agent identifier
55
+ def initialize(scratchpad, agent_name)
56
+ super() # Call RubyLLM::Tool's initialize
57
+ @scratchpad = scratchpad
58
+ @agent_name = agent_name.to_sym
59
+ end
60
+
61
+ # Execute the tool
62
+ #
63
+ # @param file_path [String] Path to scratchpad entry
64
+ # @param edits_json [String] JSON array of edit operations
65
+ # @return [String] Success message or error
66
+ def execute(file_path:, edits_json:)
67
+ # Validate inputs
68
+ return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
69
+
70
+ # Parse JSON
71
+ edits = begin
72
+ JSON.parse(edits_json)
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+
77
+ return validation_error("Invalid JSON format. Please provide a valid JSON array of edit operations.") if edits.nil?
78
+
79
+ return validation_error("edits must be an array") unless edits.is_a?(Array)
80
+ return validation_error("edits array cannot be empty") if edits.empty?
81
+
82
+ # Read current content (this will raise ArgumentError if entry doesn't exist)
83
+ content = scratchpad.read(file_path: file_path)
84
+
85
+ # Enforce read-before-edit
86
+ unless Stores::ScratchpadReadTracker.entry_read?(@agent_name, file_path)
87
+ return validation_error(
88
+ "Cannot edit scratchpad entry without reading it first. " \
89
+ "You must use ScratchpadRead on 'scratchpad://#{file_path}' before editing it. " \
90
+ "This ensures you have the current content to match against.",
91
+ )
92
+ end
93
+
94
+ # Validate edit operations
95
+ validated_edits = []
96
+ edits.each_with_index do |edit, index|
97
+ unless edit.is_a?(Hash)
98
+ return validation_error("Edit at index #{index} must be a hash/object with old_string and new_string")
99
+ end
100
+
101
+ # Convert string keys to symbols for consistency
102
+ edit = edit.transform_keys(&:to_sym)
103
+
104
+ unless edit[:old_string]
105
+ return validation_error("Edit at index #{index} missing required field 'old_string'")
106
+ end
107
+
108
+ unless edit[:new_string]
109
+ return validation_error("Edit at index #{index} missing required field 'new_string'")
110
+ end
111
+
112
+ # old_string and new_string must be different
113
+ if edit[:old_string] == edit[:new_string]
114
+ return validation_error("Edit at index #{index}: old_string and new_string must be different")
115
+ end
116
+
117
+ validated_edits << {
118
+ old_string: edit[:old_string].to_s,
119
+ new_string: edit[:new_string].to_s,
120
+ replace_all: edit[:replace_all] == true,
121
+ index: index,
122
+ }
123
+ end
124
+
125
+ # Apply edits sequentially
126
+ results = []
127
+ current_content = content
128
+
129
+ validated_edits.each do |edit|
130
+ # Check if old_string exists in current content
131
+ unless current_content.include?(edit[:old_string])
132
+ return error_with_results(
133
+ <<~ERROR.chomp,
134
+ Edit #{edit[:index]}: old_string not found in scratchpad entry.
135
+ Make sure it matches exactly, including all whitespace and indentation.
136
+ Do not include line number prefixes from ScratchpadRead tool output.
137
+ Note: This edit follows #{edit[:index]} previous edit(s) which may have changed the content.
138
+ ERROR
139
+ results,
140
+ )
141
+ end
142
+
143
+ # Count occurrences
144
+ occurrences = current_content.scan(edit[:old_string]).count
145
+
146
+ # If not replace_all and multiple occurrences, error
147
+ if !edit[:replace_all] && occurrences > 1
148
+ return error_with_results(
149
+ <<~ERROR.chomp,
150
+ Edit #{edit[:index]}: Found #{occurrences} occurrences of old_string.
151
+ Either provide more surrounding context to make the match unique, or set replace_all: true to replace all occurrences.
152
+ ERROR
153
+ results,
154
+ )
155
+ end
156
+
157
+ # Perform replacement
158
+ new_content = if edit[:replace_all]
159
+ current_content.gsub(edit[:old_string], edit[:new_string])
160
+ else
161
+ current_content.sub(edit[:old_string], edit[:new_string])
162
+ end
163
+
164
+ # Record result
165
+ replaced_count = edit[:replace_all] ? occurrences : 1
166
+ results << {
167
+ index: edit[:index],
168
+ status: "success",
169
+ occurrences: replaced_count,
170
+ message: "Replaced #{replaced_count} occurrence(s)",
171
+ }
172
+
173
+ # Update content for next edit
174
+ current_content = new_content
175
+ end
176
+
177
+ # Get existing entry metadata
178
+ entries = scratchpad.list
179
+ existing_entry = entries.find { |e| e[:path] == file_path }
180
+
181
+ # Write updated content back (preserving the title)
182
+ scratchpad.write(
183
+ file_path: file_path,
184
+ content: current_content,
185
+ title: existing_entry[:title],
186
+ )
187
+
188
+ # Build success message
189
+ total_replacements = results.sum { |r| r[:occurrences] }
190
+ message = "Successfully applied #{validated_edits.size} edit(s) to scratchpad://#{file_path}\n"
191
+ message += "Total replacements: #{total_replacements}\n\n"
192
+ message += "Details:\n"
193
+ results.each do |result|
194
+ message += " Edit #{result[:index]}: #{result[:message]}\n"
195
+ end
196
+
197
+ message
198
+ rescue ArgumentError => e
199
+ validation_error(e.message)
200
+ end
201
+
202
+ private
203
+
204
+ attr_reader :scratchpad
205
+
206
+ def validation_error(message)
207
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
208
+ end
209
+
210
+ def error_with_results(message, results)
211
+ output = "<tool_use_error>InputValidationError: #{message}\n\n"
212
+
213
+ if results.any?
214
+ output += "Previous successful edits before error:\n"
215
+ results.each do |result|
216
+ output += " Edit #{result[:index]}: #{result[:message]}\n"
217
+ end
218
+ output += "\n"
219
+ end
220
+
221
+ output += "Note: The scratchpad entry has NOT been modified. All or nothing approach - if any edit fails, no changes are saved.</tool_use_error>"
222
+ output
223
+ end
224
+ end
225
+ end
226
+ end
@@ -23,26 +23,33 @@ module SwarmSDK
23
23
  # Create a ScratchpadRead tool for a specific scratchpad instance
24
24
  #
25
25
  # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
26
+ # @param agent_name [Symbol, String] Agent identifier for tracking reads
26
27
  # @return [ScratchpadRead] Tool instance
27
- def create_for_scratchpad(scratchpad)
28
- new(scratchpad)
28
+ def create_for_scratchpad(scratchpad, agent_name)
29
+ new(scratchpad, agent_name)
29
30
  end
30
31
  end
31
32
 
32
- # Initialize with scratchpad instance
33
+ # Initialize with scratchpad instance and agent name
33
34
  #
34
35
  # @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
35
- def initialize(scratchpad)
36
+ # @param agent_name [Symbol, String] Agent identifier
37
+ def initialize(scratchpad, agent_name)
36
38
  super() # Call RubyLLM::Tool's initialize
37
39
  @scratchpad = scratchpad
40
+ @agent_name = agent_name.to_sym
38
41
  end
39
42
 
40
43
  # Execute the tool
41
44
  #
42
45
  # @param file_path [String] Path to read from
43
- # @return [String] Content at the path or error message
46
+ # @return [String] Content at the path with line numbers, or error message
44
47
  def execute(file_path:)
45
- scratchpad.read(file_path: file_path)
48
+ # Register this read in the tracker
49
+ Stores::ScratchpadReadTracker.register_read(@agent_name, file_path)
50
+
51
+ content = scratchpad.read(file_path: file_path)
52
+ format_with_line_numbers(content)
46
53
  rescue ArgumentError => e
47
54
  validation_error(e.message)
48
55
  end
@@ -54,6 +61,20 @@ module SwarmSDK
54
61
  def validation_error(message)
55
62
  "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
56
63
  end
64
+
65
+ # Format content with line numbers (same format as Read tool)
66
+ #
67
+ # @param content [String] Content to format
68
+ # @return [String] Content with line numbers
69
+ def format_with_line_numbers(content)
70
+ lines = content.lines
71
+ output_lines = lines.each_with_index.map do |line, idx|
72
+ line_number = idx + 1
73
+ display_line = line.chomp
74
+ "#{line_number.to_s.rjust(6)}→#{display_line}"
75
+ end
76
+ output_lines.join("\n")
77
+ end
57
78
  end
58
79
  end
59
80
  end
@@ -22,9 +22,17 @@ module SwarmSDK
22
22
  # Represents a single scratchpad entry with metadata
23
23
  Entry = Struct.new(:content, :title, :created_at, :size, keyword_init: true)
24
24
 
25
- def initialize
25
+ # Initialize scratchpad with optional persistence
26
+ #
27
+ # @param persist_to [String, nil] Path to JSON file for persistence (nil = no persistence)
28
+ def initialize(persist_to: nil)
26
29
  @entries = {}
27
30
  @total_size = 0
31
+ @persist_to = persist_to
32
+ @mutex = Mutex.new
33
+
34
+ # Load existing data if persistence is enabled
35
+ load_from_file if @persist_to && File.exist?(@persist_to)
28
36
  end
29
37
 
30
38
  # Write content to scratchpad
@@ -35,44 +43,49 @@ module SwarmSDK
35
43
  # @raise [ArgumentError] If size limits are exceeded
36
44
  # @return [Entry] The created entry
37
45
  def write(file_path:, content:, title:)
38
- raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
39
- raise ArgumentError, "content is required" if content.nil?
40
- raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
46
+ @mutex.synchronize do
47
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
48
+ raise ArgumentError, "content is required" if content.nil?
49
+ raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
41
50
 
42
- content_size = content.bytesize
51
+ content_size = content.bytesize
43
52
 
44
- # Check entry size limit
45
- if content_size > MAX_ENTRY_SIZE
46
- raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
47
- "Current: #{format_bytes(content_size)}"
48
- end
53
+ # Check entry size limit
54
+ if content_size > MAX_ENTRY_SIZE
55
+ raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
56
+ "Current: #{format_bytes(content_size)}"
57
+ end
49
58
 
50
- # Calculate new total size
51
- existing_entry = @entries[file_path]
52
- existing_size = existing_entry ? existing_entry.size : 0
53
- new_total_size = @total_size - existing_size + content_size
54
-
55
- # Check total size limit
56
- if new_total_size > MAX_TOTAL_SIZE
57
- raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
58
- "Current: #{format_bytes(@total_size)}, " \
59
- "Would be: #{format_bytes(new_total_size)}. " \
60
- "Clear old entries or use smaller content."
61
- end
59
+ # Calculate new total size
60
+ existing_entry = @entries[file_path]
61
+ existing_size = existing_entry ? existing_entry.size : 0
62
+ new_total_size = @total_size - existing_size + content_size
63
+
64
+ # Check total size limit
65
+ if new_total_size > MAX_TOTAL_SIZE
66
+ raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
67
+ "Current: #{format_bytes(@total_size)}, " \
68
+ "Would be: #{format_bytes(new_total_size)}. " \
69
+ "Clear old entries or use smaller content."
70
+ end
71
+
72
+ # Create entry
73
+ entry = Entry.new(
74
+ content: content,
75
+ title: title,
76
+ created_at: Time.now,
77
+ size: content_size,
78
+ )
62
79
 
63
- # Create entry
64
- entry = Entry.new(
65
- content: content,
66
- title: title,
67
- created_at: Time.now,
68
- size: content_size,
69
- )
80
+ # Update storage
81
+ @entries[file_path] = entry
82
+ @total_size = new_total_size
70
83
 
71
- # Update storage
72
- @entries[file_path] = entry
73
- @total_size = new_total_size
84
+ # Persist to file if enabled
85
+ save_to_file if @persist_to
74
86
 
75
- entry
87
+ entry
88
+ end
76
89
  end
77
90
 
78
91
  # Read content from scratchpad
@@ -112,6 +125,74 @@ module SwarmSDK
112
125
  end.sort_by { |e| e[:path] }
113
126
  end
114
127
 
128
+ # Search entries by glob pattern (like filesystem glob)
129
+ #
130
+ # @param pattern [String] Glob pattern (e.g., "**/*.txt", "parallel/*/task_*")
131
+ # @return [Array<Hash>] Array of matching entry metadata (path, title, size, created_at)
132
+ def glob(pattern:)
133
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
134
+
135
+ # Convert glob pattern to regex
136
+ regex = glob_to_regex(pattern)
137
+
138
+ # Filter entries by pattern
139
+ matching_entries = @entries.select { |path, _| regex.match?(path) }
140
+
141
+ # Return metadata sorted by path
142
+ matching_entries.map do |path, entry|
143
+ {
144
+ path: path,
145
+ title: entry.title,
146
+ size: entry.size,
147
+ created_at: entry.created_at,
148
+ }
149
+ end.sort_by { |e| e[:path] }
150
+ end
151
+
152
+ # Search entry content by pattern (like grep)
153
+ #
154
+ # @param pattern [String] Regular expression pattern to search for
155
+ # @param case_insensitive [Boolean] Whether to perform case-insensitive search
156
+ # @param output_mode [String] Output mode: "files_with_matches" (default), "content", or "count"
157
+ # @return [Array<Hash>, String] Results based on output_mode
158
+ def grep(pattern:, case_insensitive: false, output_mode: "files_with_matches")
159
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
160
+
161
+ # Create regex from pattern
162
+ flags = case_insensitive ? Regexp::IGNORECASE : 0
163
+ regex = Regexp.new(pattern, flags)
164
+
165
+ case output_mode
166
+ when "files_with_matches"
167
+ # Return just the paths that match
168
+ matching_paths = @entries.select { |_path, entry| regex.match?(entry.content) }
169
+ .map { |path, _| path }
170
+ .sort
171
+ matching_paths
172
+ when "content"
173
+ # Return paths with matching lines
174
+ results = []
175
+ @entries.each do |path, entry|
176
+ matching_lines = []
177
+ entry.content.each_line.with_index(1) do |line, line_num|
178
+ matching_lines << { line_number: line_num, content: line.chomp } if regex.match?(line)
179
+ end
180
+ results << { path: path, matches: matching_lines } unless matching_lines.empty?
181
+ end
182
+ results.sort_by { |r| r[:path] }
183
+ when "count"
184
+ # Return paths with match counts
185
+ results = []
186
+ @entries.each do |path, entry|
187
+ count = entry.content.scan(regex).size
188
+ results << { path: path, count: count } if count > 0
189
+ end
190
+ results.sort_by { |r| r[:path] }
191
+ else
192
+ raise ArgumentError, "Invalid output_mode: #{output_mode}. Must be 'files_with_matches', 'content', or 'count'"
193
+ end
194
+ end
195
+
115
196
  # Clear all entries
116
197
  #
117
198
  # @return [void]
@@ -134,6 +215,26 @@ module SwarmSDK
134
215
 
135
216
  private
136
217
 
218
+ # Convert glob pattern to regex
219
+ #
220
+ # @param pattern [String] Glob pattern
221
+ # @return [Regexp] Regular expression
222
+ def glob_to_regex(pattern)
223
+ # Escape special regex characters except glob wildcards
224
+ escaped = Regexp.escape(pattern)
225
+
226
+ # Convert glob wildcards to regex
227
+ # ** matches any number of directories (including zero)
228
+ escaped = escaped.gsub('\*\*', ".*")
229
+ # * matches anything except directory separator
230
+ escaped = escaped.gsub('\*', "[^/]*")
231
+ # ? matches single character except directory separator
232
+ escaped = escaped.gsub('\?', "[^/]")
233
+
234
+ # Anchor to start and end
235
+ Regexp.new("\\A#{escaped}\\z")
236
+ end
237
+
137
238
  # Format bytes to human-readable size
138
239
  #
139
240
  # @param bytes [Integer] Number of bytes
@@ -147,6 +248,68 @@ module SwarmSDK
147
248
  "#{bytes}B"
148
249
  end
149
250
  end
251
+
252
+ # Save scratchpad data to JSON file
253
+ #
254
+ # @return [void]
255
+ def save_to_file
256
+ return unless @persist_to
257
+
258
+ # Convert entries to serializable format
259
+ data = {
260
+ version: 1,
261
+ total_size: @total_size,
262
+ entries: @entries.transform_values do |entry|
263
+ {
264
+ content: entry.content,
265
+ title: entry.title,
266
+ created_at: entry.created_at.iso8601,
267
+ size: entry.size,
268
+ }
269
+ end,
270
+ }
271
+
272
+ # Ensure directory exists
273
+ dir = File.dirname(@persist_to)
274
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
275
+
276
+ # Write to file atomically (write to temp file, then rename)
277
+ temp_file = "#{@persist_to}.tmp"
278
+ File.write(temp_file, JSON.pretty_generate(data))
279
+ File.rename(temp_file, @persist_to)
280
+ end
281
+
282
+ # Load scratchpad data from JSON file
283
+ #
284
+ # @return [void]
285
+ def load_from_file
286
+ return unless @persist_to && File.exist?(@persist_to)
287
+
288
+ data = JSON.parse(File.read(@persist_to))
289
+
290
+ # Restore entries
291
+ @entries = data["entries"].transform_values do |entry_data|
292
+ Entry.new(
293
+ content: entry_data["content"],
294
+ title: entry_data["title"],
295
+ created_at: Time.parse(entry_data["created_at"]),
296
+ size: entry_data["size"],
297
+ )
298
+ end
299
+
300
+ # Restore total size
301
+ @total_size = data["total_size"]
302
+ rescue JSON::ParserError => e
303
+ # If file is corrupted, log warning and start fresh
304
+ warn("Warning: Failed to load scratchpad from #{@persist_to}: #{e.message}. Starting with empty scratchpad.")
305
+ @entries = {}
306
+ @total_size = 0
307
+ rescue StandardError => e
308
+ # If any other error occurs, log warning and start fresh
309
+ warn("Warning: Failed to load scratchpad from #{@persist_to}: #{e.message}. Starting with empty scratchpad.")
310
+ @entries = {}
311
+ @total_size = 0
312
+ end
150
313
  end
151
314
  end
152
315
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Stores
6
+ # ScratchpadReadTracker manages read-entry tracking for all agents
7
+ #
8
+ # This module maintains a global registry of which scratchpad entries each agent
9
+ # has read during their conversation. This enables enforcement of the
10
+ # "read-before-edit" rule that ensures agents have context before modifying entries.
11
+ #
12
+ # Each agent maintains an independent set of read entries, keyed by agent identifier.
13
+ module ScratchpadReadTracker
14
+ @read_entries = {}
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # Register that an agent has read a scratchpad entry
19
+ #
20
+ # @param agent_id [Symbol] The agent identifier
21
+ # @param entry_path [String] The scratchpad entry path
22
+ def register_read(agent_id, entry_path)
23
+ @mutex.synchronize do
24
+ @read_entries[agent_id] ||= Set.new
25
+ @read_entries[agent_id] << entry_path
26
+ end
27
+ end
28
+
29
+ # Check if an agent has read a scratchpad entry
30
+ #
31
+ # @param agent_id [Symbol] The agent identifier
32
+ # @param entry_path [String] The scratchpad entry path
33
+ # @return [Boolean] true if the agent has read this entry
34
+ def entry_read?(agent_id, entry_path)
35
+ @mutex.synchronize do
36
+ return false unless @read_entries[agent_id]
37
+
38
+ @read_entries[agent_id].include?(entry_path)
39
+ end
40
+ end
41
+
42
+ # Clear read history for an agent (useful for testing)
43
+ #
44
+ # @param agent_id [Symbol] The agent identifier
45
+ def clear(agent_id)
46
+ @mutex.synchronize do
47
+ @read_entries.delete(agent_id)
48
+ end
49
+ end
50
+
51
+ # Clear all read history (useful for testing)
52
+ def clear_all
53
+ @mutex.synchronize do
54
+ @read_entries.clear
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmSDK
4
- VERSION = "2.0.3"
4
+ VERSION = "2.0.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swarm_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.3
4
+ version: 2.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -142,11 +142,15 @@ files:
142
142
  - lib/swarm_sdk/tools/path_resolver.rb
143
143
  - lib/swarm_sdk/tools/read.rb
144
144
  - lib/swarm_sdk/tools/registry.rb
145
- - lib/swarm_sdk/tools/scratchpad_list.rb
145
+ - lib/swarm_sdk/tools/scratchpad_edit.rb
146
+ - lib/swarm_sdk/tools/scratchpad_glob.rb
147
+ - lib/swarm_sdk/tools/scratchpad_grep.rb
148
+ - lib/swarm_sdk/tools/scratchpad_multi_edit.rb
146
149
  - lib/swarm_sdk/tools/scratchpad_read.rb
147
150
  - lib/swarm_sdk/tools/scratchpad_write.rb
148
151
  - lib/swarm_sdk/tools/stores/read_tracker.rb
149
152
  - lib/swarm_sdk/tools/stores/scratchpad.rb
153
+ - lib/swarm_sdk/tools/stores/scratchpad_read_tracker.rb
150
154
  - lib/swarm_sdk/tools/stores/todo_manager.rb
151
155
  - lib/swarm_sdk/tools/think.rb
152
156
  - lib/swarm_sdk/tools/todo_write.rb