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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/agent/builder.rb +41 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +22 -5
- data/lib/swarm_sdk/agent/definition.rb +52 -6
- data/lib/swarm_sdk/configuration.rb +3 -1
- data/lib/swarm_sdk/prompts/memory.md.erb +480 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +16 -3
- data/lib/swarm_sdk/swarm/builder.rb +9 -1
- data/lib/swarm_sdk/swarm/tool_configurator.rb +75 -32
- data/lib/swarm_sdk/swarm.rb +50 -11
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/memory/memory_delete.rb +64 -0
- data/lib/swarm_sdk/tools/memory/memory_edit.rb +145 -0
- data/lib/swarm_sdk/tools/memory/memory_glob.rb +94 -0
- data/lib/swarm_sdk/tools/memory/memory_grep.rb +147 -0
- data/lib/swarm_sdk/tools/memory/memory_multi_edit.rb +228 -0
- data/lib/swarm_sdk/tools/memory/memory_read.rb +82 -0
- data/lib/swarm_sdk/tools/memory/memory_write.rb +90 -0
- data/lib/swarm_sdk/tools/registry.rb +11 -4
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/{scratchpad.rb → memory_storage.rb} +72 -88
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/{scratchpad_read_tracker.rb → storage_read_tracker.rb} +7 -7
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/version.rb +1 -1
- data/lib/swarm_sdk.rb +39 -0
- metadata +18 -9
- data/lib/swarm_sdk/tools/scratchpad_edit.rb +0 -143
- data/lib/swarm_sdk/tools/scratchpad_glob.rb +0 -92
- data/lib/swarm_sdk/tools/scratchpad_grep.rb +0 -145
- data/lib/swarm_sdk/tools/scratchpad_multi_edit.rb +0 -226
- data/lib/swarm_sdk/tools/scratchpad_read.rb +0 -80
- 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
|
-
#
|
7
|
-
# to store detailed outputs that would otherwise bloat tool responses.
|
6
|
+
# MemoryStorage provides persistent, per-agent storage
|
8
7
|
#
|
9
8
|
# Features:
|
10
|
-
# -
|
11
|
-
# -
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
28
|
-
|
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
|
35
|
-
load_from_file if
|
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
|
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, "
|
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
|
-
|
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
|
-
#
|
85
|
-
save_to_file
|
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
|
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, "
|
94
|
+
raise ArgumentError, "memory://#{file_path} not found" unless entry
|
101
95
|
|
102
96
|
entry.content
|
103
97
|
end
|
104
98
|
|
105
|
-
#
|
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,
|
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
|
-
|
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
|
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
|
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
|
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
|
-
|
164
|
+
updated_at: entry.updated_at,
|
148
165
|
}
|
149
|
-
end.sort_by { |e| e[:
|
166
|
+
end.sort_by { |e| -e[:updated_at].to_f }
|
150
167
|
end
|
151
168
|
|
152
|
-
# Search entry content by pattern
|
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[:
|
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[:
|
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
|
-
@
|
201
|
-
|
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
|
-
#
|
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
|
-
|
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
|
266
|
+
# Load memory storage data from JSON file
|
283
267
|
#
|
284
268
|
# @return [void]
|
285
269
|
def load_from_file
|
286
|
-
return unless
|
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
|
-
|
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
|
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
|
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
|