swarm_sdk 2.0.4 → 2.0.5

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/agent/builder.rb +41 -0
  3. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +22 -5
  4. data/lib/swarm_sdk/agent/definition.rb +52 -6
  5. data/lib/swarm_sdk/configuration.rb +3 -1
  6. data/lib/swarm_sdk/prompts/memory.md.erb +480 -0
  7. data/lib/swarm_sdk/swarm/agent_initializer.rb +16 -3
  8. data/lib/swarm_sdk/swarm/builder.rb +9 -1
  9. data/lib/swarm_sdk/swarm/tool_configurator.rb +75 -32
  10. data/lib/swarm_sdk/swarm.rb +50 -11
  11. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  12. data/lib/swarm_sdk/tools/memory/memory_delete.rb +64 -0
  13. data/lib/swarm_sdk/tools/memory/memory_edit.rb +145 -0
  14. data/lib/swarm_sdk/tools/memory/memory_glob.rb +94 -0
  15. data/lib/swarm_sdk/tools/memory/memory_grep.rb +147 -0
  16. data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +228 -0
  17. data/lib/swarm_sdk/tools/memory/memory_read.rb +82 -0
  18. data/lib/swarm_sdk/tools/memory/memory_write.rb +90 -0
  19. data/lib/swarm_sdk/tools/registry.rb +11 -4
  20. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  21. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  22. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  23. data/lib/swarm_sdk/tools/stores/{scratchpad.rb → memory_storage.rb} +72 -88
  24. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  25. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  26. data/lib/swarm_sdk/tools/stores/{scratchpad_read_tracker.rb → storage_read_tracker.rb} +7 -7
  27. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  28. data/lib/swarm_sdk/version.rb +1 -1
  29. data/lib/swarm_sdk.rb +39 -0
  30. metadata +18 -9
  31. data/lib/swarm_sdk/tools/scratchpad_edit.rb +0 -143
  32. data/lib/swarm_sdk/tools/scratchpad_glob.rb +0 -92
  33. data/lib/swarm_sdk/tools/scratchpad_grep.rb +0 -145
  34. data/lib/swarm_sdk/tools/scratchpad_multi_edit.rb +0 -226
  35. data/lib/swarm_sdk/tools/scratchpad_read.rb +0 -80
  36. data/lib/swarm_sdk/tools/scratchpad_write.rb +0 -88
@@ -3,39 +3,33 @@
3
3
  module SwarmSDK
4
4
  module Tools
5
5
  module Stores
6
- # Scratchpad provides session-scoped, in-memory storage for agents
7
- # to store detailed outputs that would otherwise bloat tool responses.
6
+ # MemoryStorage provides persistent, per-agent storage
8
7
  #
9
8
  # Features:
10
- # - Session-scoped: Cleared when swarm execution completes
11
- # - Shared: Any agent can read/write any scratchpad address
9
+ # - Per-agent: Each agent has its own isolated storage
10
+ # - Persistent: ALWAYS saves to JSON file
12
11
  # - Path-based: Hierarchical organization using file-path-like addresses
13
- # - In-memory: No filesystem I/O, pure memory storage
14
12
  # - Metadata-rich: Stores content + title + timestamp + size
15
- class Scratchpad
16
- # Maximum size per scratchpad entry (1MB)
17
- MAX_ENTRY_SIZE = 1_000_000
18
-
19
- # Maximum total scratchpad size (100MB)
20
- MAX_TOTAL_SIZE = 100_000_000
21
-
22
- # Represents a single scratchpad entry with metadata
23
- Entry = Struct.new(:content, :title, :created_at, :size, keyword_init: true)
24
-
25
- # Initialize scratchpad with optional persistence
13
+ # - Thread-safe: Mutex-protected operations
14
+ class MemoryStorage < Storage
15
+ # Initialize memory storage with required persistence
26
16
  #
27
- # @param persist_to [String, nil] Path to JSON file for persistence (nil = no persistence)
28
- def initialize(persist_to: nil)
17
+ # @param persist_to [String] Path to JSON file for persistence (REQUIRED)
18
+ # @raise [ArgumentError] If persist_to is not provided
19
+ def initialize(persist_to:)
20
+ super() # Initialize parent Storage class
21
+ raise ArgumentError, "persist_to is required for MemoryStorage" if persist_to.nil? || persist_to.to_s.strip.empty?
22
+
29
23
  @entries = {}
30
24
  @total_size = 0
31
25
  @persist_to = persist_to
32
26
  @mutex = Mutex.new
33
27
 
34
- # Load existing data if persistence is enabled
35
- load_from_file if @persist_to && File.exist?(@persist_to)
28
+ # Load existing data if file exists
29
+ load_from_file if File.exist?(@persist_to)
36
30
  end
37
31
 
38
- # Write content to scratchpad
32
+ # Write content to memory storage
39
33
  #
40
34
  # @param file_path [String] Path to store content
41
35
  # @param content [String] Content to store
@@ -63,7 +57,7 @@ module SwarmSDK
63
57
 
64
58
  # Check total size limit
65
59
  if new_total_size > MAX_TOTAL_SIZE
66
- raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
60
+ raise ArgumentError, "Memory storage full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
67
61
  "Current: #{format_bytes(@total_size)}, " \
68
62
  "Would be: #{format_bytes(new_total_size)}. " \
69
63
  "Clear old entries or use smaller content."
@@ -73,7 +67,7 @@ module SwarmSDK
73
67
  entry = Entry.new(
74
68
  content: content,
75
69
  title: title,
76
- created_at: Time.now,
70
+ updated_at: Time.now,
77
71
  size: content_size,
78
72
  )
79
73
 
@@ -81,14 +75,14 @@ module SwarmSDK
81
75
  @entries[file_path] = entry
82
76
  @total_size = new_total_size
83
77
 
84
- # Persist to file if enabled
85
- save_to_file if @persist_to
78
+ # Always persist to file
79
+ save_to_file
86
80
 
87
81
  entry
88
82
  end
89
83
  end
90
84
 
91
- # Read content from scratchpad
85
+ # Read content from memory storage
92
86
  #
93
87
  # @param file_path [String] Path to read from
94
88
  # @raise [ArgumentError] If path not found
@@ -97,15 +91,38 @@ module SwarmSDK
97
91
  raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
98
92
 
99
93
  entry = @entries[file_path]
100
- raise ArgumentError, "scratchpad://#{file_path} not found" unless entry
94
+ raise ArgumentError, "memory://#{file_path} not found" unless entry
101
95
 
102
96
  entry.content
103
97
  end
104
98
 
105
- # List scratchpad entries, optionally filtered by prefix
99
+ # Delete a specific entry
100
+ #
101
+ # @param file_path [String] Path to delete
102
+ # @raise [ArgumentError] If path not found
103
+ # @return [void]
104
+ def delete(file_path:)
105
+ @mutex.synchronize do
106
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
107
+
108
+ entry = @entries[file_path]
109
+ raise ArgumentError, "memory://#{file_path} not found" unless entry
110
+
111
+ # Update total size
112
+ @total_size -= entry.size
113
+
114
+ # Remove entry
115
+ @entries.delete(file_path)
116
+
117
+ # Always persist to file
118
+ save_to_file
119
+ end
120
+ end
121
+
122
+ # List memory storage entries, optionally filtered by prefix
106
123
  #
107
124
  # @param prefix [String, nil] Filter by path prefix
108
- # @return [Array<Hash>] Array of entry metadata (path, title, size, created_at)
125
+ # @return [Array<Hash>] Array of entry metadata (path, title, size, updated_at)
109
126
  def list(prefix: nil)
110
127
  entries = @entries
111
128
 
@@ -114,21 +131,21 @@ module SwarmSDK
114
131
  entries = entries.select { |path, _| path.start_with?(prefix) }
115
132
  end
116
133
 
117
- # Return metadata
134
+ # Return metadata sorted by path
118
135
  entries.map do |path, entry|
119
136
  {
120
137
  path: path,
121
138
  title: entry.title,
122
139
  size: entry.size,
123
- created_at: entry.created_at,
140
+ updated_at: entry.updated_at,
124
141
  }
125
142
  end.sort_by { |e| e[:path] }
126
143
  end
127
144
 
128
- # Search entries by glob pattern (like filesystem glob)
145
+ # Search entries by glob pattern
129
146
  #
130
147
  # @param pattern [String] Glob pattern (e.g., "**/*.txt", "parallel/*/task_*")
131
- # @return [Array<Hash>] Array of matching entry metadata (path, title, size, created_at)
148
+ # @return [Array<Hash>] Array of matching entry metadata, sorted by most recent first
132
149
  def glob(pattern:)
133
150
  raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
134
151
 
@@ -138,18 +155,18 @@ module SwarmSDK
138
155
  # Filter entries by pattern
139
156
  matching_entries = @entries.select { |path, _| regex.match?(path) }
140
157
 
141
- # Return metadata sorted by path
158
+ # Return metadata sorted by most recent first
142
159
  matching_entries.map do |path, entry|
143
160
  {
144
161
  path: path,
145
162
  title: entry.title,
146
163
  size: entry.size,
147
- created_at: entry.created_at,
164
+ updated_at: entry.updated_at,
148
165
  }
149
- end.sort_by { |e| e[:path] }
166
+ end.sort_by { |e| -e[:updated_at].to_f }
150
167
  end
151
168
 
152
- # Search entry content by pattern (like grep)
169
+ # Search entry content by pattern
153
170
  #
154
171
  # @param pattern [String] Regular expression pattern to search for
155
172
  # @param case_insensitive [Boolean] Whether to perform case-insensitive search
@@ -170,24 +187,24 @@ module SwarmSDK
170
187
  .sort
171
188
  matching_paths
172
189
  when "content"
173
- # Return paths with matching lines
190
+ # Return paths with matching lines, sorted by most recent first
174
191
  results = []
175
192
  @entries.each do |path, entry|
176
193
  matching_lines = []
177
194
  entry.content.each_line.with_index(1) do |line, line_num|
178
195
  matching_lines << { line_number: line_num, content: line.chomp } if regex.match?(line)
179
196
  end
180
- results << { path: path, matches: matching_lines } unless matching_lines.empty?
197
+ results << { path: path, matches: matching_lines, updated_at: entry.updated_at } unless matching_lines.empty?
181
198
  end
182
- results.sort_by { |r| r[:path] }
199
+ results.sort_by { |r| -r[:updated_at].to_f }.map { |r| r.except(:updated_at) }
183
200
  when "count"
184
- # Return paths with match counts
201
+ # Return paths with match counts, sorted by most recent first
185
202
  results = []
186
203
  @entries.each do |path, entry|
187
204
  count = entry.content.scan(regex).size
188
- results << { path: path, count: count } if count > 0
205
+ results << { path: path, count: count, updated_at: entry.updated_at } if count > 0
189
206
  end
190
- results.sort_by { |r| r[:path] }
207
+ results.sort_by { |r| -r[:updated_at].to_f }.map { |r| r.except(:updated_at) }
191
208
  else
192
209
  raise ArgumentError, "Invalid output_mode: #{output_mode}. Must be 'files_with_matches', 'content', or 'count'"
193
210
  end
@@ -197,8 +214,11 @@ module SwarmSDK
197
214
  #
198
215
  # @return [void]
199
216
  def clear
200
- @entries.clear
201
- @total_size = 0
217
+ @mutex.synchronize do
218
+ @entries.clear
219
+ @total_size = 0
220
+ save_to_file
221
+ end
202
222
  end
203
223
 
204
224
  # Get current total size
@@ -215,46 +235,10 @@ module SwarmSDK
215
235
 
216
236
  private
217
237
 
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
-
238
- # Format bytes to human-readable size
239
- #
240
- # @param bytes [Integer] Number of bytes
241
- # @return [String] Formatted size (e.g., "1.5MB", "500.0KB")
242
- def format_bytes(bytes)
243
- if bytes >= 1_000_000
244
- "#{(bytes.to_f / 1_000_000).round(1)}MB"
245
- elsif bytes >= 1_000
246
- "#{(bytes.to_f / 1_000).round(1)}KB"
247
- else
248
- "#{bytes}B"
249
- end
250
- end
251
-
252
- # Save scratchpad data to JSON file
238
+ # Save memory storage data to JSON file
253
239
  #
254
240
  # @return [void]
255
241
  def save_to_file
256
- return unless @persist_to
257
-
258
242
  # Convert entries to serializable format
259
243
  data = {
260
244
  version: 1,
@@ -263,7 +247,7 @@ module SwarmSDK
263
247
  {
264
248
  content: entry.content,
265
249
  title: entry.title,
266
- created_at: entry.created_at.iso8601,
250
+ updated_at: entry.updated_at.iso8601,
267
251
  size: entry.size,
268
252
  }
269
253
  end,
@@ -279,11 +263,11 @@ module SwarmSDK
279
263
  File.rename(temp_file, @persist_to)
280
264
  end
281
265
 
282
- # Load scratchpad data from JSON file
266
+ # Load memory storage data from JSON file
283
267
  #
284
268
  # @return [void]
285
269
  def load_from_file
286
- return unless @persist_to && File.exist?(@persist_to)
270
+ return unless File.exist?(@persist_to)
287
271
 
288
272
  data = JSON.parse(File.read(@persist_to))
289
273
 
@@ -292,7 +276,7 @@ module SwarmSDK
292
276
  Entry.new(
293
277
  content: entry_data["content"],
294
278
  title: entry_data["title"],
295
- created_at: Time.parse(entry_data["created_at"]),
279
+ updated_at: Time.parse(entry_data["updated_at"]),
296
280
  size: entry_data["size"],
297
281
  )
298
282
  end
@@ -301,12 +285,12 @@ module SwarmSDK
301
285
  @total_size = data["total_size"]
302
286
  rescue JSON::ParserError => e
303
287
  # 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.")
288
+ warn("Warning: Failed to load memory storage from #{@persist_to}: #{e.message}. Starting with empty storage.")
305
289
  @entries = {}
306
290
  @total_size = 0
307
291
  rescue StandardError => e
308
292
  # 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.")
293
+ warn("Warning: Failed to load memory storage from #{@persist_to}: #{e.message}. Starting with empty storage.")
310
294
  @entries = {}
311
295
  @total_size = 0
312
296
  end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Stores
6
+ # ScratchpadStorage provides volatile, shared storage
7
+ #
8
+ # Features:
9
+ # - Shared: All agents share the same scratchpad
10
+ # - Volatile: NEVER persists - all data lost when process ends
11
+ # - Path-based: Hierarchical organization using file-path-like addresses
12
+ # - Metadata-rich: Stores content + title + timestamp + size
13
+ # - Thread-safe: Mutex-protected operations
14
+ #
15
+ # Use for temporary, cross-agent communication within a single session.
16
+ class ScratchpadStorage < Storage
17
+ # Initialize scratchpad storage (always volatile)
18
+ def initialize
19
+ super() # Initialize parent Storage class
20
+ @entries = {}
21
+ @total_size = 0
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ # Write content to scratchpad
26
+ #
27
+ # @param file_path [String] Path to store content
28
+ # @param content [String] Content to store
29
+ # @param title [String] Brief title describing the content
30
+ # @raise [ArgumentError] If size limits are exceeded
31
+ # @return [Entry] The created entry
32
+ def write(file_path:, content:, title:)
33
+ @mutex.synchronize do
34
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
35
+ raise ArgumentError, "content is required" if content.nil?
36
+ raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
37
+
38
+ content_size = content.bytesize
39
+
40
+ # Check entry size limit
41
+ if content_size > MAX_ENTRY_SIZE
42
+ raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
43
+ "Current: #{format_bytes(content_size)}"
44
+ end
45
+
46
+ # Calculate new total size
47
+ existing_entry = @entries[file_path]
48
+ existing_size = existing_entry ? existing_entry.size : 0
49
+ new_total_size = @total_size - existing_size + content_size
50
+
51
+ # Check total size limit
52
+ if new_total_size > MAX_TOTAL_SIZE
53
+ raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
54
+ "Current: #{format_bytes(@total_size)}, " \
55
+ "Would be: #{format_bytes(new_total_size)}. " \
56
+ "Clear old entries or use smaller content."
57
+ end
58
+
59
+ # Create entry
60
+ entry = Entry.new(
61
+ content: content,
62
+ title: title,
63
+ updated_at: Time.now,
64
+ size: content_size,
65
+ )
66
+
67
+ # Update storage
68
+ @entries[file_path] = entry
69
+ @total_size = new_total_size
70
+
71
+ entry
72
+ end
73
+ end
74
+
75
+ # Read content from scratchpad
76
+ #
77
+ # @param file_path [String] Path to read from
78
+ # @raise [ArgumentError] If path not found
79
+ # @return [String] Content at the path
80
+ def read(file_path:)
81
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
82
+
83
+ entry = @entries[file_path]
84
+ raise ArgumentError, "scratchpad://#{file_path} not found" unless entry
85
+
86
+ entry.content
87
+ end
88
+
89
+ # Delete a specific entry
90
+ #
91
+ # @param file_path [String] Path to delete
92
+ # @raise [ArgumentError] If path not found
93
+ # @return [void]
94
+ def delete(file_path:)
95
+ @mutex.synchronize do
96
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
97
+
98
+ entry = @entries[file_path]
99
+ raise ArgumentError, "scratchpad://#{file_path} not found" unless entry
100
+
101
+ # Update total size
102
+ @total_size -= entry.size
103
+
104
+ # Remove entry
105
+ @entries.delete(file_path)
106
+ end
107
+ end
108
+
109
+ # List scratchpad entries, optionally filtered by prefix
110
+ #
111
+ # @param prefix [String, nil] Filter by path prefix
112
+ # @return [Array<Hash>] Array of entry metadata (path, title, size, updated_at)
113
+ def list(prefix: nil)
114
+ entries = @entries
115
+
116
+ # Filter by prefix if provided
117
+ if prefix && !prefix.empty?
118
+ entries = entries.select { |path, _| path.start_with?(prefix) }
119
+ end
120
+
121
+ # Return metadata sorted by path
122
+ entries.map do |path, entry|
123
+ {
124
+ path: path,
125
+ title: entry.title,
126
+ size: entry.size,
127
+ updated_at: entry.updated_at,
128
+ }
129
+ end.sort_by { |e| e[:path] }
130
+ end
131
+
132
+ # Search entries by glob pattern
133
+ #
134
+ # @param pattern [String] Glob pattern (e.g., "**/*.txt", "parallel/*/task_*")
135
+ # @return [Array<Hash>] Array of matching entry metadata, sorted by most recent first
136
+ def glob(pattern:)
137
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
138
+
139
+ # Convert glob pattern to regex
140
+ regex = glob_to_regex(pattern)
141
+
142
+ # Filter entries by pattern
143
+ matching_entries = @entries.select { |path, _| regex.match?(path) }
144
+
145
+ # Return metadata sorted by most recent first
146
+ matching_entries.map do |path, entry|
147
+ {
148
+ path: path,
149
+ title: entry.title,
150
+ size: entry.size,
151
+ updated_at: entry.updated_at,
152
+ }
153
+ end.sort_by { |e| -e[:updated_at].to_f }
154
+ end
155
+
156
+ # Search entry content by pattern
157
+ #
158
+ # @param pattern [String] Regular expression pattern to search for
159
+ # @param case_insensitive [Boolean] Whether to perform case-insensitive search
160
+ # @param output_mode [String] Output mode: "files_with_matches" (default), "content", or "count"
161
+ # @return [Array<Hash>, String] Results based on output_mode
162
+ def grep(pattern:, case_insensitive: false, output_mode: "files_with_matches")
163
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
164
+
165
+ # Create regex from pattern
166
+ flags = case_insensitive ? Regexp::IGNORECASE : 0
167
+ regex = Regexp.new(pattern, flags)
168
+
169
+ case output_mode
170
+ when "files_with_matches"
171
+ # Return just the paths that match
172
+ matching_paths = @entries.select { |_path, entry| regex.match?(entry.content) }
173
+ .map { |path, _| path }
174
+ .sort
175
+ matching_paths
176
+ when "content"
177
+ # Return paths with matching lines, sorted by most recent first
178
+ results = []
179
+ @entries.each do |path, entry|
180
+ matching_lines = []
181
+ entry.content.each_line.with_index(1) do |line, line_num|
182
+ matching_lines << { line_number: line_num, content: line.chomp } if regex.match?(line)
183
+ end
184
+ results << { path: path, matches: matching_lines, updated_at: entry.updated_at } unless matching_lines.empty?
185
+ end
186
+ results.sort_by { |r| -r[:updated_at].to_f }.map { |r| r.except(:updated_at) }
187
+ when "count"
188
+ # Return paths with match counts, sorted by most recent first
189
+ results = []
190
+ @entries.each do |path, entry|
191
+ count = entry.content.scan(regex).size
192
+ results << { path: path, count: count, updated_at: entry.updated_at } if count > 0
193
+ end
194
+ results.sort_by { |r| -r[:updated_at].to_f }.map { |r| r.except(:updated_at) }
195
+ else
196
+ raise ArgumentError, "Invalid output_mode: #{output_mode}. Must be 'files_with_matches', 'content', or 'count'"
197
+ end
198
+ end
199
+
200
+ # Clear all entries
201
+ #
202
+ # @return [void]
203
+ def clear
204
+ @mutex.synchronize do
205
+ @entries.clear
206
+ @total_size = 0
207
+ end
208
+ end
209
+
210
+ # Get current total size
211
+ #
212
+ # @return [Integer] Total size in bytes
213
+ attr_reader :total_size
214
+
215
+ # Get number of entries
216
+ #
217
+ # @return [Integer] Number of entries
218
+ def size
219
+ @entries.size
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Tools
5
+ module Stores
6
+ # Abstract base class for hierarchical key-value storage with metadata
7
+ #
8
+ # Provides session-scoped storage for agents with path-based organization.
9
+ # Subclasses implement persistence behavior (volatile vs persistent).
10
+ #
11
+ # Features:
12
+ # - Path-based: Hierarchical organization using file-path-like addresses
13
+ # - Metadata-rich: Stores content + title + timestamp + size
14
+ # - Search capabilities: Glob patterns and grep-style content search
15
+ # - Thread-safe: Mutex-protected operations
16
+ class Storage
17
+ # Maximum size per entry (1MB)
18
+ MAX_ENTRY_SIZE = 1_000_000
19
+
20
+ # Maximum total storage size (100MB)
21
+ MAX_TOTAL_SIZE = 100_000_000
22
+
23
+ # Represents a single storage entry with metadata
24
+ Entry = Struct.new(:content, :title, :updated_at, :size, keyword_init: true)
25
+
26
+ # Initialize storage
27
+ #
28
+ # Subclasses should call super() in their initialize method.
29
+ # This base implementation does nothing - it exists only to satisfy RuboCop.
30
+ def initialize
31
+ # Base class initialization - subclasses implement their own logic
32
+ end
33
+
34
+ # Write content to storage
35
+ #
36
+ # @param file_path [String] Path to store content
37
+ # @param content [String] Content to store
38
+ # @param title [String] Brief title describing the content
39
+ # @raise [ArgumentError] If size limits are exceeded
40
+ # @return [Entry] The created entry
41
+ def write(file_path:, content:, title:)
42
+ raise NotImplementedError, "Subclass must implement #write"
43
+ end
44
+
45
+ # Read content from storage
46
+ #
47
+ # @param file_path [String] Path to read from
48
+ # @raise [ArgumentError] If path not found
49
+ # @return [String] Content at the path
50
+ def read(file_path:)
51
+ raise NotImplementedError, "Subclass must implement #read"
52
+ end
53
+
54
+ # Delete a specific entry
55
+ #
56
+ # @param file_path [String] Path to delete
57
+ # @raise [ArgumentError] If path not found
58
+ # @return [void]
59
+ def delete(file_path:)
60
+ raise NotImplementedError, "Subclass must implement #delete"
61
+ end
62
+
63
+ # List entries, optionally filtered by prefix
64
+ #
65
+ # @param prefix [String, nil] Filter by path prefix
66
+ # @return [Array<Hash>] Array of entry metadata (path, title, size, updated_at)
67
+ def list(prefix: nil)
68
+ raise NotImplementedError, "Subclass must implement #list"
69
+ end
70
+
71
+ # Search entries by glob pattern
72
+ #
73
+ # @param pattern [String] Glob pattern (e.g., "**/*.txt", "parallel/*/task_*")
74
+ # @return [Array<Hash>] Array of matching entry metadata, sorted by most recent first
75
+ def glob(pattern:)
76
+ raise NotImplementedError, "Subclass must implement #glob"
77
+ end
78
+
79
+ # Search entry content by pattern
80
+ #
81
+ # @param pattern [String] Regular expression pattern to search for
82
+ # @param case_insensitive [Boolean] Whether to perform case-insensitive search
83
+ # @param output_mode [String] Output mode: "files_with_matches" (default), "content", or "count"
84
+ # @return [Array<Hash>, String] Results based on output_mode
85
+ def grep(pattern:, case_insensitive: false, output_mode: "files_with_matches")
86
+ raise NotImplementedError, "Subclass must implement #grep"
87
+ end
88
+
89
+ # Clear all entries
90
+ #
91
+ # @return [void]
92
+ def clear
93
+ raise NotImplementedError, "Subclass must implement #clear"
94
+ end
95
+
96
+ # Get current total size
97
+ #
98
+ # @return [Integer] Total size in bytes
99
+ def total_size
100
+ raise NotImplementedError, "Subclass must implement #total_size"
101
+ end
102
+
103
+ # Get number of entries
104
+ #
105
+ # @return [Integer] Number of entries
106
+ def size
107
+ raise NotImplementedError, "Subclass must implement #size"
108
+ end
109
+
110
+ protected
111
+
112
+ # Format bytes to human-readable size
113
+ #
114
+ # @param bytes [Integer] Number of bytes
115
+ # @return [String] Formatted size (e.g., "1.5MB", "500.0KB")
116
+ def format_bytes(bytes)
117
+ if bytes >= 1_000_000
118
+ "#{(bytes.to_f / 1_000_000).round(1)}MB"
119
+ elsif bytes >= 1_000
120
+ "#{(bytes.to_f / 1_000).round(1)}KB"
121
+ else
122
+ "#{bytes}B"
123
+ end
124
+ end
125
+
126
+ # Convert glob pattern to regex
127
+ #
128
+ # @param pattern [String] Glob pattern
129
+ # @return [Regexp] Regular expression
130
+ def glob_to_regex(pattern)
131
+ # Escape special regex characters except glob wildcards
132
+ escaped = Regexp.escape(pattern)
133
+
134
+ # Convert glob wildcards to regex
135
+ # ** matches any number of directories (including zero)
136
+ escaped = escaped.gsub('\*\*', ".*")
137
+ # * matches anything except directory separator
138
+ escaped = escaped.gsub('\*', "[^/]*")
139
+ # ? matches single character except directory separator
140
+ escaped = escaped.gsub('\?', "[^/]")
141
+
142
+ # Anchor to start and end
143
+ Regexp.new("\\A#{escaped}\\z")
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end