swarm_memory 2.2.5 → 2.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: adb03d1c769f7c76bff25ce6150989948b0b217d809b4dc40f9cd3561cdd1c02
4
- data.tar.gz: 14c0276ee61c49b2d0e6d70938846516067a716b55effb8b0927e178c24f35c8
3
+ metadata.gz: aa8c626431db042fc20905dc9aff6871b38ade85e1bbed5dd9a6770b2567ebbd
4
+ data.tar.gz: 947ebabdc79659228284e8d9af16b17763bcfc5ae471093fcc9f5678083abc91
5
5
  SHA512:
6
- metadata.gz: 37d88e126bd894b358044d40b6d06ba039766483b3a1a5d0722b3aca883ddbb00ed43c92a2b86bf8f479dda8888b946271f011b62b2f6ef87e5aa6a34585e325
7
- data.tar.gz: eb4fc576318665ec0ca0e497cb2dc687204f92f36deeff9407158ff43c6683ca70d1dca8798c10d7e14ad7b41835a6ab6127aee94805699cac2b8a758ee9b26e
6
+ metadata.gz: 7ff95190f7efb9328d1e5e20c5693cdf485806e74c56b9ace9bd01a1ad7bf486bda6c209b14a0034c35bab910d8be4c63c8c7dafe612a5b624674ad169cd19fd
7
+ data.tar.gz: 472e6ada1c0952e5feaf243bdd66339da09ff8bd4e043e4321bffbc6fd88e1973bef544e66a96eb74c50984e687b40c43b03a69bcee6032d49cb62d655a63be7
@@ -101,6 +101,34 @@ module SwarmMemory
101
101
  raise NotImplementedError, "Subclass must implement #size"
102
102
  end
103
103
 
104
+ # Semantic search by embedding vector
105
+ #
106
+ # Searches all entries with embeddings and returns those similar to the query.
107
+ # Results are sorted by cosine similarity in descending order.
108
+ #
109
+ # @param embedding [Array<Float>] Query embedding vector
110
+ # @param top_k [Integer] Number of results to return
111
+ # @param threshold [Float] Minimum similarity score (0.0-1.0)
112
+ # @return [Array<Hash>] Results with similarity scores
113
+ #
114
+ # @example
115
+ # results = adapter.semantic_search(
116
+ # embedding: query_embedding,
117
+ # top_k: 5,
118
+ # threshold: 0.65
119
+ # )
120
+ #
121
+ # Each result hash must contain:
122
+ # - :path (String) - logical file path
123
+ # - :similarity (Float) - cosine similarity score 0.0-1.0
124
+ # - :title (String) - entry title
125
+ # - :size (Integer) - content size in bytes
126
+ # - :updated_at (Time) - last update timestamp
127
+ # - :metadata (Hash) - nested hash with type, tags, domain, etc.
128
+ def semantic_search(embedding:, top_k: 10, threshold: 0.0)
129
+ raise NotImplementedError, "Subclass must implement #semantic_search"
130
+ end
131
+
104
132
  protected
105
133
 
106
134
  # Format bytes to human-readable size
@@ -9,21 +9,21 @@ module SwarmMemory
9
9
  module BuilderExtension
10
10
  # Configure persistent memory for this agent
11
11
  #
12
- # @example Interactive mode (default) - Learn and retrieve
12
+ # @example Read-write mode (default) - Learn and retrieve
13
13
  # memory do
14
14
  # directory ".swarm/agent-memory"
15
15
  # end
16
16
  #
17
- # @example Retrieval mode - Read-only Q&A
17
+ # @example Read-only mode - Q&A without learning
18
18
  # memory do
19
19
  # directory "team-knowledge/"
20
- # mode :retrieval
20
+ # mode :read_only
21
21
  # end
22
22
  #
23
- # @example Researcher mode - Knowledge extraction
23
+ # @example Full access mode - Knowledge management with Delete and Defrag
24
24
  # memory do
25
25
  # directory "team-knowledge/"
26
- # mode :researcher
26
+ # mode :full_access
27
27
  # end
28
28
  def memory(&block)
29
29
  @memory_config = SwarmMemory::DSL::MemoryConfig.new
@@ -28,7 +28,7 @@ module SwarmMemory
28
28
  def initialize
29
29
  @adapter_type = :filesystem # Default adapter
30
30
  @adapter_options = {} # Options passed to adapter constructor
31
- @mode = :assistant # Default mode
31
+ @mode = :read_write # Default mode
32
32
  end
33
33
 
34
34
  # DSL method to set/get adapter type
@@ -72,9 +72,9 @@ module SwarmMemory
72
72
  # DSL method to set/get mode
73
73
  #
74
74
  # Modes:
75
- # - :assistant (default) - Read + Write, balanced for learning and retrieval
76
- # - :retrieval - Read-only, optimized for Q&A
77
- # - :researcher - All tools, optimized for knowledge extraction
75
+ # - :read_write (default) - Read + Write + Edit, balanced for learning and retrieval
76
+ # - :read_only - Read-only, optimized for Q&A
77
+ # - :full_access - All tools including Delete and Defrag, optimized for knowledge management
78
78
  #
79
79
  # @param value [Symbol, nil] Memory mode
80
80
  # @return [Symbol] Current mode
@@ -20,7 +20,7 @@ module SwarmMemory
20
20
  # Needed for semantic skill discovery in on_user_message
21
21
  @storages = {}
22
22
  # Track memory mode for each agent: { agent_name => mode }
23
- # Modes: :assistant (default), :retrieval, :researcher
23
+ # Modes: :read_write (default), :read_only, :full_access
24
24
  @modes = {}
25
25
  # Track threshold configuration for each agent: { agent_name => config }
26
26
  # Enables per-adapter threshold tuning with ENV fallback
@@ -50,6 +50,7 @@ module SwarmMemory
50
50
  :MemoryRead,
51
51
  :MemoryGlob,
52
52
  :MemoryGrep,
53
+ :MemorySearch,
53
54
  :MemoryWrite,
54
55
  :MemoryEdit,
55
56
  :MemoryDelete,
@@ -63,26 +64,27 @@ module SwarmMemory
63
64
  # @return [Array<Symbol>] Tool names for this mode
64
65
  def tools_for_mode(mode)
65
66
  case mode
66
- when :retrieval
67
+ when :read_only
67
68
  # Read-only tools for Q&A agents
68
- [:MemoryRead, :MemoryGlob, :MemoryGrep]
69
- when :assistant
70
- # Read + Write + Edit for learning assistants (need edit for corrections)
71
- [:MemoryRead, :MemoryGlob, :MemoryGrep, :MemoryWrite, :MemoryEdit]
72
- when :researcher
73
- # All tools for knowledge extraction
69
+ [:MemoryRead, :MemoryGlob, :MemoryGrep, :MemorySearch]
70
+ when :read_write
71
+ # Read + Write + Edit for learning agents (need edit for corrections)
72
+ [:MemoryRead, :MemoryGlob, :MemoryGrep, :MemorySearch, :MemoryWrite, :MemoryEdit]
73
+ when :full_access
74
+ # All tools for knowledge extraction and management
74
75
  [
75
76
  :MemoryRead,
76
77
  :MemoryGlob,
77
78
  :MemoryGrep,
79
+ :MemorySearch,
78
80
  :MemoryWrite,
79
81
  :MemoryEdit,
80
82
  :MemoryDelete,
81
83
  :MemoryDefrag,
82
84
  ]
83
85
  else
84
- # Default to assistant
85
- [:MemoryRead, :MemoryGlob, :MemoryGrep, :MemoryWrite, :MemoryEdit]
86
+ # Default to read_write
87
+ [:MemoryRead, :MemoryGlob, :MemoryGrep, :MemorySearch, :MemoryWrite, :MemoryEdit]
86
88
  end
87
89
  end
88
90
 
@@ -180,16 +182,16 @@ module SwarmMemory
180
182
  elsif memory_config.respond_to?(:mode)
181
183
  memory_config.mode # Other object with mode method
182
184
  elsif memory_config.is_a?(Hash)
183
- (memory_config[:mode] || memory_config["mode"] || :assistant).to_sym
185
+ (memory_config[:mode] || memory_config["mode"] || :read_write).to_sym
184
186
  else
185
- :assistant # Default mode
187
+ :read_write # Default mode
186
188
  end
187
189
 
188
190
  # Select prompt template based on mode
189
191
  prompt_filename = case mode
190
- when :retrieval then "memory_retrieval.md.erb"
191
- when :researcher then "memory_researcher.md.erb"
192
- else "memory_assistant.md.erb" # Default
192
+ when :read_only then "memory_read_only.md.erb"
193
+ when :full_access then "memory_full_access.md.erb"
194
+ else "memory_read_write.md.erb" # Default
193
195
  end
194
196
 
195
197
  memory_prompt_path = File.expand_path("../prompts/#{prompt_filename}", __dir__)
@@ -209,8 +211,8 @@ module SwarmMemory
209
211
  def immutable_tools_for_mode(mode)
210
212
  base_tools = tools_for_mode(mode)
211
213
 
212
- # LoadSkill only for assistant and researcher modes (not retrieval)
213
- if mode == :retrieval
214
+ # LoadSkill only for read_write and full_access modes (not read_only)
215
+ if mode == :read_only
214
216
  base_tools
215
217
  else
216
218
  base_tools + [:LoadSkill]
@@ -390,8 +392,8 @@ module SwarmMemory
390
392
  agent.remove_tool(tool_name)
391
393
  end
392
394
 
393
- # Create and register LoadSkill tool (NOT for retrieval mode - read-only)
394
- unless mode == :retrieval
395
+ # Create and register LoadSkill tool (NOT for read_only mode)
396
+ unless mode == :read_only
395
397
  load_skill_tool = SwarmMemory.create_tool(
396
398
  :LoadSkill,
397
399
  storage: storage,
@@ -24,6 +24,7 @@ You have persistent memory that learns from conversations and helps you answer q
24
24
 
25
25
  **CRITICAL - These are your ONLY memory tools:**
26
26
  - `MemoryRead` - Read a specific memory
27
+ - `MemorySearch` - Search memory by semantic similarity
27
28
  - `MemoryGrep` - Search memory by keyword pattern
28
29
  - `MemoryGlob` - Browse memory by path pattern
29
30
  - `MemoryWrite` - Create new memory
@@ -33,7 +34,6 @@ You have persistent memory that learns from conversations and helps you answer q
33
34
  - `LoadSkill` - Load a skill and swap tools
34
35
 
35
36
  **DO NOT use:**
36
- - ❌ "MemorySearch" (doesn't exist - use MemoryGrep)
37
37
  - ❌ Any other memory tool names
38
38
 
39
39
  ## CRITICAL: Every Memory MUST Have a Type
@@ -24,6 +24,7 @@ You have persistent memory that learns from conversations and helps you answer q
24
24
 
25
25
  **CRITICAL - These are your ONLY memory tools:**
26
26
  - `MemoryRead` - Read a specific memory
27
+ - `MemorySearch` - Search memory by semantic similarity
27
28
  - `MemoryGrep` - Search memory by keyword pattern
28
29
  - `MemoryGlob` - Browse memory by path pattern
29
30
  - `MemoryWrite` - Create new memory
@@ -31,7 +32,6 @@ You have persistent memory that learns from conversations and helps you answer q
31
32
  - `LoadSkill` - Load a skill and swap tools
32
33
 
33
34
  **DO NOT use:**
34
- - ❌ "MemorySearch" (doesn't exist - use MemoryGrep)
35
35
  - ❌ Any other memory tool names
36
36
 
37
37
  ## CRITICAL: Every Memory MUST Have a Type
@@ -43,14 +43,6 @@ module SwarmMemory
43
43
  MemoryGlob(pattern: "fact/*")
44
44
  Result: fact/api.md (only direct children, not nested)
45
45
 
46
- # Find ALL facts recursively
47
- MemoryGlob(pattern: "fact/**")
48
- Result: fact/api.md, fact/people/john.md, fact/people/jane.md, ...
49
-
50
- # Find all skills recursively
51
- MemoryGlob(pattern: "skill/**")
52
- Result: skill/debugging/api-errors.md, skill/meta/deep-learning.md, ...
53
-
54
46
  # Find all concepts in a domain
55
47
  MemoryGlob(pattern: "concept/ruby/**")
56
48
  Result: concept/ruby/classes.md, concept/ruby/modules.md, ...
@@ -66,10 +58,6 @@ module SwarmMemory
66
58
  # Find debugging skills recursively
67
59
  MemoryGlob(pattern: "skill/debugging/**")
68
60
  Result: skill/debugging/api-errors.md, skill/debugging/performance.md, ...
69
-
70
- # Find all entries (all categories)
71
- MemoryGlob(pattern: "**/*")
72
- Result: All .md entries across all 4 categories
73
61
  ```
74
62
 
75
63
  **Understanding * vs **:**
@@ -92,15 +80,16 @@ module SwarmMemory
92
80
  **Tips:**
93
81
  - Start with broad patterns and narrow down
94
82
  - Use `**` for recursive searching entire hierarchies
83
+ - NEVER use `**` to search all categories at once, or all memories for a category. Always use specific patterns.
95
84
  - Combine with MemoryGrep if you need content-based search
96
85
  - Check entry sizes to identify large entries
97
86
  DESC
98
87
 
99
88
  param :pattern,
100
- desc: "Glob pattern - target concept/, fact/, skill/, or experience/ only (e.g., 'skill/**', 'concept/ruby/*', 'fact/people/*.md')",
89
+ desc: "Glob pattern - target concept/, fact/, skill/, or experience/ only (e.g., 'skill/debugging/*ruby*', 'concept/programming-languages/ruby*', 'fact/people/*john*')",
101
90
  required: true
102
91
 
103
- MAX_RESULTS = 500 # Limit results to prevent overwhelming output
92
+ MAX_RESULTS = 50 # Limit results to prevent overwhelming output
104
93
 
105
94
  # Initialize with storage instance
106
95
  #
@@ -127,6 +127,8 @@ module SwarmMemory
127
127
  desc: "Output mode: 'files_with_matches' (default), 'content', or 'count'",
128
128
  required: false
129
129
 
130
+ MAX_RESULTS = 50 # Limit results to prevent overwhelming output
131
+
130
132
  # Initialize with storage instance
131
133
  #
132
134
  # @param storage [Core::Storage] Storage instance
@@ -196,12 +198,39 @@ module SwarmMemory
196
198
  return "No matches found for pattern #{search_desc}"
197
199
  end
198
200
 
201
+ # Limit results and track if truncated
202
+ original_count = paths.size
203
+ truncated = original_count > MAX_RESULTS
204
+ paths = paths.take(MAX_RESULTS) if truncated
205
+
199
206
  result = []
200
- result << "Memory entries matching #{search_desc} (#{paths.size} #{paths.size == 1 ? "entry" : "entries"}):"
207
+ result << if truncated
208
+ "Memory entries matching #{search_desc} (showing #{paths.size} of #{original_count} entries):"
209
+ else
210
+ "Memory entries matching #{search_desc} (#{paths.size} #{paths.size == 1 ? "entry" : "entries"}):"
211
+ end
212
+
201
213
  paths.each do |path|
202
214
  result << "- #{format_memory_path_with_title(path)}"
203
215
  end
204
- result.join("\n")
216
+
217
+ output = result.join("\n")
218
+
219
+ # Add system reminder if truncated
220
+ if truncated
221
+ output += <<~REMINDER
222
+
223
+ <system-reminder>
224
+ Results limited to first #{MAX_RESULTS} matches (sorted by most recently modified).
225
+ Your search returned #{original_count} total matches. Consider:
226
+ - Adding a path filter to narrow scope (e.g., path: "fact/api-design/")
227
+ - Using a more specific regex pattern
228
+ - Searching within a specific memory category (concept/, fact/, skill/, experience/)
229
+ </system-reminder>
230
+ REMINDER
231
+ end
232
+
233
+ output
205
234
  end
206
235
 
207
236
  def format_content(results, pattern, path_filter)
@@ -211,9 +240,21 @@ module SwarmMemory
211
240
  return "No matches found for pattern #{search_desc}"
212
241
  end
213
242
 
243
+ # Limit results and track if truncated
244
+ original_count = results.size
245
+ original_total_matches = results.sum { |r| r[:matches].size }
246
+ truncated = original_count > MAX_RESULTS
247
+ results = results.take(MAX_RESULTS) if truncated
248
+
214
249
  total_matches = results.sum { |r| r[:matches].size }
215
250
  output = []
216
- output << "Memory entries matching #{search_desc} (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} #{total_matches == 1 ? "match" : "matches"}):"
251
+
252
+ output << if truncated
253
+ "Memory entries matching #{search_desc} (showing #{results.size} of #{original_count} entries, #{total_matches} of #{original_total_matches} matches):"
254
+ else
255
+ "Memory entries matching #{search_desc} (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} #{total_matches == 1 ? "match" : "matches"}):"
256
+ end
257
+
217
258
  output << ""
218
259
 
219
260
  results.each do |result|
@@ -224,7 +265,23 @@ module SwarmMemory
224
265
  output << ""
225
266
  end
226
267
 
227
- output.join("\n").rstrip
268
+ result_text = output.join("\n").rstrip
269
+
270
+ # Add system reminder if truncated
271
+ if truncated
272
+ result_text += <<~REMINDER
273
+
274
+ <system-reminder>
275
+ Results limited to first #{MAX_RESULTS} entries.
276
+ Your search returned #{original_count} total entries with #{original_total_matches} matches. Consider:
277
+ - Adding a path filter to narrow scope (e.g., path: "fact/api-design/")
278
+ - Using a more specific regex pattern
279
+ - Searching within a specific memory category or subdomain
280
+ </system-reminder>
281
+ REMINDER
282
+ end
283
+
284
+ result_text
228
285
  end
229
286
 
230
287
  def format_count(results, pattern, path_filter)
@@ -234,15 +291,42 @@ module SwarmMemory
234
291
  return "No matches found for pattern #{search_desc}"
235
292
  end
236
293
 
294
+ # Limit results and track if truncated
295
+ original_count = results.size
296
+ original_total_matches = results.sum { |r| r[:count] }
297
+ truncated = original_count > MAX_RESULTS
298
+ results = results.take(MAX_RESULTS) if truncated
299
+
237
300
  total_matches = results.sum { |r| r[:count] }
238
301
  output = []
239
- output << "Memory entries matching #{search_desc} (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} total #{total_matches == 1 ? "match" : "matches"}):"
302
+
303
+ output << if truncated
304
+ "Memory entries matching #{search_desc} (showing #{results.size} of #{original_count} entries, #{total_matches} of #{original_total_matches} total matches):"
305
+ else
306
+ "Memory entries matching #{search_desc} (#{results.size} #{results.size == 1 ? "entry" : "entries"}, #{total_matches} total #{total_matches == 1 ? "match" : "matches"}):"
307
+ end
240
308
 
241
309
  results.each do |result|
242
310
  output << " memory://#{result[:path]}: #{result[:count]} #{result[:count] == 1 ? "match" : "matches"}"
243
311
  end
244
312
 
245
- output.join("\n")
313
+ result_text = output.join("\n")
314
+
315
+ # Add system reminder if truncated
316
+ if truncated
317
+ result_text += <<~REMINDER
318
+
319
+ <system-reminder>
320
+ Results limited to first #{MAX_RESULTS} entries.
321
+ Your search returned #{original_count} total entries with #{original_total_matches} matches. Consider:
322
+ - Adding a path filter to narrow scope (e.g., path: "fact/api-design/")
323
+ - Using a more specific regex pattern
324
+ - Searching within a specific memory category or subdomain
325
+ </system-reminder>
326
+ REMINDER
327
+ end
328
+
329
+ result_text
246
330
  end
247
331
  end
248
332
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmMemory
4
+ module Tools
5
+ # Tool for semantic search across memory entries
6
+ #
7
+ # Searches content stored in memory using AI embeddings to find
8
+ # conceptually related entries even when exact keywords don't match.
9
+ # Each agent has its own isolated memory storage.
10
+ class MemorySearch < RubyLLM::Tool
11
+ description <<~DESC
12
+ Perform semantic search across memory entries using natural language queries.
13
+
14
+ This tool uses AI embeddings to find conceptually related memories even when
15
+ exact keywords don't match. Results are ranked by semantic similarity and
16
+ filtered by specified criteria.
17
+
18
+ Use this tool when:
19
+ - Looking for concepts related to a topic (e.g., "authentication patterns")
20
+ - Exploring connections between ideas
21
+ - Finding memories when you don't know exact terminology
22
+ - Researching a subject area
23
+
24
+ Use MemoryGrep instead when:
25
+ - Searching for exact text or regex patterns
26
+ - Looking for specific code snippets or examples
27
+
28
+ Use MemoryGlob instead when:
29
+ - Browsing by path structure (e.g., "concept/ruby/*")
30
+ - Listing all memories in a category
31
+
32
+ Examples:
33
+ MemorySearch(query: "authentication patterns", top_k: 5, threshold: 0.5)
34
+ MemorySearch(query: "async programming", filter_type: "concept")
35
+ MemorySearch(query: "API design", filter_domain: "programming")
36
+ DESC
37
+
38
+ param :query,
39
+ type: "string",
40
+ desc: "Natural language search query describing what you're looking for",
41
+ required: true
42
+
43
+ param :top_k,
44
+ type: "integer",
45
+ desc: "Maximum number of results to return (default: 10, max: 100)",
46
+ required: false
47
+
48
+ param :threshold,
49
+ type: "number",
50
+ desc: "Minimum similarity score 0.0-1.0 (default: 0.0, higher = stricter)",
51
+ required: false
52
+
53
+ param :filter_type,
54
+ type: "string",
55
+ desc: "Filter by type: 'concept', 'fact', 'skill', 'experience', or comma-separated list",
56
+ required: false
57
+
58
+ param :filter_domain,
59
+ type: "string",
60
+ desc: "Filter by domain prefix (e.g., 'programming/ruby')",
61
+ required: false
62
+
63
+ # Maximum results to prevent context overflow
64
+ MAX_RESULTS = 50
65
+
66
+ # Initialize with storage instance
67
+ #
68
+ # @param storage [Core::Storage] Storage instance
69
+ # @param agent_name [String, Symbol] Agent identifier
70
+ def initialize(storage:, agent_name:)
71
+ super()
72
+ @storage = storage
73
+ @agent_name = agent_name.to_sym
74
+ end
75
+
76
+ # Override name to return simple "MemorySearch"
77
+ def name
78
+ "MemorySearch"
79
+ end
80
+
81
+ # Execute the tool
82
+ #
83
+ # @param query [String] Natural language search query
84
+ # @param top_k [Integer] Maximum number of results
85
+ # @param threshold [Float] Minimum similarity score
86
+ # @param filter_type [String, nil] Type filter
87
+ # @param filter_domain [String, nil] Domain filter
88
+ # @return [String] Formatted search results
89
+ def execute(query:, top_k: 10, threshold: 0.0, filter_type: nil, filter_domain: nil)
90
+ # Reset state for multiple types post-filtering
91
+ @requested_types = nil
92
+
93
+ # 1. Check semantic index availability
94
+ unless @storage.semantic_index
95
+ return validation_error("Semantic search not available (no embedder configured)")
96
+ end
97
+
98
+ # 2. Validate parameters
99
+ top_k = validate_top_k(top_k)
100
+ return top_k if top_k.is_a?(String) # Error message
101
+
102
+ threshold = validate_threshold(threshold)
103
+ return threshold if threshold.is_a?(String) # Error message
104
+
105
+ # 3. Build filter hash (may set @requested_types for multi-type filtering)
106
+ filter = build_filter(filter_type, filter_domain)
107
+
108
+ # 4. Perform semantic search
109
+ # For multiple types, we get extra results to compensate for post-filtering
110
+ search_top_k = @requested_types && @requested_types.size > 1 ? top_k * 3 : top_k
111
+ results = @storage.semantic_index.search(
112
+ query: query,
113
+ top_k: search_top_k,
114
+ threshold: threshold,
115
+ filter: filter,
116
+ )
117
+
118
+ # 5. Format and return results (handles multi-type post-filtering)
119
+ format_results(results, query, threshold, top_k)
120
+ rescue StandardError => e
121
+ validation_error("Search failed: #{e.message}")
122
+ end
123
+
124
+ private
125
+
126
+ def validation_error(message)
127
+ "<tool_use_error>InputValidationError: #{message}</tool_use_error>"
128
+ end
129
+
130
+ def validate_top_k(value)
131
+ k = value.to_i
132
+ if k <= 0
133
+ return validation_error("top_k must be positive (got: #{value})")
134
+ end
135
+
136
+ [k, MAX_RESULTS].min
137
+ end
138
+
139
+ def validate_threshold(value)
140
+ t = value.to_f
141
+ if t < 0.0 || t > 1.0
142
+ return validation_error("threshold must be between 0.0 and 1.0 (got: #{value})")
143
+ end
144
+
145
+ t
146
+ end
147
+
148
+ def build_filter(filter_type, filter_domain)
149
+ filter = {}
150
+
151
+ # IMPORTANT: SemanticIndex's apply_filters uses equality checks (==)
152
+ # So filter["type"] = ["concept", "fact"] won't match metadata["type"] = "concept"
153
+ # For multiple types, we pass nil filter and post-filter results in Ruby
154
+ if filter_type
155
+ types = filter_type.split(",").map(&:strip)
156
+ if types.size == 1
157
+ # Single type: use SemanticIndex filter
158
+ filter["type"] = types.first
159
+ else
160
+ # Multiple types: will be filtered in format_results
161
+ # Store for post-filtering but don't add to SemanticIndex filter
162
+ @requested_types = types
163
+ end
164
+ end
165
+
166
+ # Domain can be used in filter (it's a string comparison)
167
+ filter["domain"] = filter_domain if filter_domain
168
+
169
+ filter.empty? ? nil : filter
170
+ end
171
+
172
+ def format_results(results, query, threshold, top_k)
173
+ # Post-filter for multiple types (if requested)
174
+ # This is needed because SemanticIndex's apply_filters only does equality checks
175
+ if @requested_types && @requested_types.size > 1
176
+ results = results.select do |result|
177
+ type = result.dig(:metadata, "type") || result.dig(:metadata, :type)
178
+ @requested_types.include?(type)
179
+ end
180
+ end
181
+
182
+ # Limit to requested top_k after filtering
183
+ results = results.take(top_k)
184
+
185
+ if results.empty?
186
+ return format_no_results(query, threshold)
187
+ end
188
+
189
+ header = "Found #{results.size} #{pluralize("memory", results.size)} " \
190
+ "matching \"#{query}\" (similarity >= #{threshold}):\n\n"
191
+
192
+ entries = results.map.with_index(1) do |result, idx|
193
+ format_result_entry(idx, result)
194
+ end
195
+
196
+ footer = "\n\nUse MemoryRead to view full content of any memory."
197
+
198
+ header + entries.join("\n\n") + footer
199
+ end
200
+
201
+ def format_no_results(query, threshold)
202
+ msg = "No memories found matching \"#{query}\""
203
+ msg += " with similarity >= #{threshold}" if threshold > 0.0
204
+ msg + ".\n\nTry:\n" \
205
+ "- Using a more general query\n" \
206
+ "- Lowering the threshold\n" \
207
+ "- Using MemoryGrep for keyword search\n" \
208
+ "- Using MemoryGlob to browse by path"
209
+ end
210
+
211
+ def format_result_entry(index, result)
212
+ lines = []
213
+ lines << "#{index}. memory://#{result[:path]} \"#{result[:title]}\" (score: #{format_score(result[:similarity])})"
214
+
215
+ # Add metadata for context (access nested metadata correctly)
216
+ metadata_parts = []
217
+ tags = result.dig(:metadata, "tags") || result.dig(:metadata, :tags)
218
+ domain = result.dig(:metadata, "domain") || result.dig(:metadata, :domain)
219
+ type = result.dig(:metadata, "type") || result.dig(:metadata, :type)
220
+
221
+ metadata_parts << "Tags: #{tags.join(", ")}" if tags&.any?
222
+ metadata_parts << "Domain: #{domain}" if domain
223
+ metadata_parts << "Type: #{type}" if type
224
+
225
+ lines << " #{metadata_parts.join(" | ")}" if metadata_parts.any?
226
+
227
+ lines.join("\n")
228
+ end
229
+
230
+ def format_score(score)
231
+ format("%.2f", score)
232
+ end
233
+
234
+ def pluralize(word, count)
235
+ count == 1 ? word : "#{word.sub(/y$/, "ie")}s" # memory -> memories
236
+ end
237
+ end
238
+ end
239
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmMemory
4
- VERSION = "2.2.5"
4
+ VERSION = "2.2.6"
5
5
  end
data/lib/swarm_memory.rb CHANGED
@@ -121,6 +121,8 @@ module SwarmMemory
121
121
  Tools::MemoryWrite.new(storage: storage, agent_name: agent_name)
122
122
  when :MemoryRead
123
123
  Tools::MemoryRead.new(storage: storage, agent_name: agent_name)
124
+ when :MemorySearch
125
+ Tools::MemorySearch.new(storage: storage, agent_name: agent_name)
124
126
  when :MemoryEdit
125
127
  Tools::MemoryEdit.new(storage: storage, agent_name: agent_name)
126
128
  when :MemoryDelete
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swarm_memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.5
4
+ version: 2.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -124,9 +124,9 @@ files:
124
124
  - lib/swarm_memory/optimization/analyzer.rb
125
125
  - lib/swarm_memory/optimization/defragmenter.rb
126
126
  - lib/swarm_memory/prompts/memory.md.erb
127
- - lib/swarm_memory/prompts/memory_assistant.md.erb
128
- - lib/swarm_memory/prompts/memory_researcher.md.erb
129
- - lib/swarm_memory/prompts/memory_retrieval.md.erb
127
+ - lib/swarm_memory/prompts/memory_full_access.md.erb
128
+ - lib/swarm_memory/prompts/memory_read_only.md.erb
129
+ - lib/swarm_memory/prompts/memory_read_write.md.erb
130
130
  - lib/swarm_memory/search/semantic_search.rb
131
131
  - lib/swarm_memory/search/text_search.rb
132
132
  - lib/swarm_memory/search/text_similarity.rb
@@ -139,6 +139,7 @@ files:
139
139
  - lib/swarm_memory/tools/memory_glob.rb
140
140
  - lib/swarm_memory/tools/memory_grep.rb
141
141
  - lib/swarm_memory/tools/memory_read.rb
142
+ - lib/swarm_memory/tools/memory_search.rb
142
143
  - lib/swarm_memory/tools/memory_write.rb
143
144
  - lib/swarm_memory/tools/title_lookup.rb
144
145
  - lib/swarm_memory/utils.rb