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 +4 -4
- data/lib/swarm_memory/adapters/base.rb +28 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +5 -5
- data/lib/swarm_memory/dsl/memory_config.rb +4 -4
- data/lib/swarm_memory/integration/sdk_plugin.rb +21 -19
- data/lib/swarm_memory/prompts/{memory_researcher.md.erb → memory_full_access.md.erb} +1 -1
- data/lib/swarm_memory/prompts/{memory_assistant.md.erb → memory_read_write.md.erb} +1 -1
- data/lib/swarm_memory/tools/memory_glob.rb +3 -14
- data/lib/swarm_memory/tools/memory_grep.rb +90 -6
- data/lib/swarm_memory/tools/memory_search.rb +239 -0
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +2 -0
- metadata +5 -4
- /data/lib/swarm_memory/prompts/{memory_retrieval.md.erb → memory_read_only.md.erb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa8c626431db042fc20905dc9aff6871b38ade85e1bbed5dd9a6770b2567ebbd
|
|
4
|
+
data.tar.gz: 947ebabdc79659228284e8d9af16b17763bcfc5ae471093fcc9f5678083abc91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
17
|
+
# @example Read-only mode - Q&A without learning
|
|
18
18
|
# memory do
|
|
19
19
|
# directory "team-knowledge/"
|
|
20
|
-
# mode :
|
|
20
|
+
# mode :read_only
|
|
21
21
|
# end
|
|
22
22
|
#
|
|
23
|
-
# @example
|
|
23
|
+
# @example Full access mode - Knowledge management with Delete and Defrag
|
|
24
24
|
# memory do
|
|
25
25
|
# directory "team-knowledge/"
|
|
26
|
-
# mode :
|
|
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 = :
|
|
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
|
-
# - :
|
|
76
|
-
# - :
|
|
77
|
-
# - :
|
|
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: :
|
|
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 :
|
|
67
|
+
when :read_only
|
|
67
68
|
# Read-only tools for Q&A agents
|
|
68
|
-
[:MemoryRead, :MemoryGlob, :MemoryGrep]
|
|
69
|
-
when :
|
|
70
|
-
# Read + Write + Edit for learning
|
|
71
|
-
[:MemoryRead, :MemoryGlob, :MemoryGrep, :MemoryWrite, :MemoryEdit]
|
|
72
|
-
when :
|
|
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
|
|
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"] || :
|
|
185
|
+
(memory_config[:mode] || memory_config["mode"] || :read_write).to_sym
|
|
184
186
|
else
|
|
185
|
-
:
|
|
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 :
|
|
191
|
-
when :
|
|
192
|
-
else "
|
|
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
|
|
213
|
-
if mode == :
|
|
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
|
|
394
|
-
unless mode == :
|
|
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
|
|
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 =
|
|
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 <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/swarm_memory/version.rb
CHANGED
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.
|
|
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/
|
|
128
|
-
- lib/swarm_memory/prompts/
|
|
129
|
-
- lib/swarm_memory/prompts/
|
|
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
|
|
File without changes
|