swarm_memory 2.1.7 → 2.2.1
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/integration/sdk_plugin.rb +97 -21
- data/lib/swarm_memory/optimization/defragmenter.rb +1 -1
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +0 -1
- data/lib/swarm_memory/tools/load_skill.rb +0 -1
- data/lib/swarm_memory/tools/memory_edit.rb +2 -1
- data/lib/swarm_memory/tools/memory_read.rb +1 -1
- data/lib/swarm_memory/version.rb +1 -1
- data/lib/swarm_memory.rb +0 -3
- metadata +3 -4
- data/lib/swarm_memory/tools/memory_multi_edit.rb +0 -281
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ec97e62d89963ca0ba1c40d2d4bed76f3cd7ce12c5971594cf93a877228d894
|
|
4
|
+
data.tar.gz: a6cd54d0e51d4743968758bb3363aad5cdae6e7e21ff25dd688c8f5c36ecfb24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d96db1ab430ec011d572e2472866a121a470ebe3b6afca676d78f1c87d64ba3bdbd4857731a7c25feb597e350dec82749464ab98fca738b93bf799c35ada8776
|
|
7
|
+
data.tar.gz: 1a8ae4efa93e6259d24962e59453f10ca003913cd2a555086848c586d948896965888ac5c8d8c2dde44961e2d8e33fb4b2a53b908667e29be9e8cc6f82d1a243
|
|
@@ -22,6 +22,9 @@ module SwarmMemory
|
|
|
22
22
|
# Track memory mode for each agent: { agent_name => mode }
|
|
23
23
|
# Modes: :assistant (default), :retrieval, :researcher
|
|
24
24
|
@modes = {}
|
|
25
|
+
# Track threshold configuration for each agent: { agent_name => config }
|
|
26
|
+
# Enables per-adapter threshold tuning with ENV fallback
|
|
27
|
+
@threshold_configs = {}
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# Plugin identifier
|
|
@@ -49,7 +52,6 @@ module SwarmMemory
|
|
|
49
52
|
:MemoryGrep,
|
|
50
53
|
:MemoryWrite,
|
|
51
54
|
:MemoryEdit,
|
|
52
|
-
:MemoryMultiEdit,
|
|
53
55
|
:MemoryDelete,
|
|
54
56
|
:MemoryDefrag,
|
|
55
57
|
]
|
|
@@ -75,7 +77,6 @@ module SwarmMemory
|
|
|
75
77
|
:MemoryGrep,
|
|
76
78
|
:MemoryWrite,
|
|
77
79
|
:MemoryEdit,
|
|
78
|
-
:MemoryMultiEdit,
|
|
79
80
|
:MemoryDelete,
|
|
80
81
|
:MemoryDefrag,
|
|
81
82
|
]
|
|
@@ -199,24 +200,36 @@ module SwarmMemory
|
|
|
199
200
|
end
|
|
200
201
|
end
|
|
201
202
|
|
|
202
|
-
# Check if
|
|
203
|
+
# Check if memory is configured for this agent
|
|
204
|
+
#
|
|
205
|
+
# Delegates adapter-specific validation to the adapter itself.
|
|
206
|
+
# Filesystem adapter requires 'directory', custom adapters may use other keys.
|
|
203
207
|
#
|
|
204
208
|
# @param agent_definition [Agent::Definition] Agent definition
|
|
205
|
-
# @return [Boolean] True if agent has memory configuration
|
|
206
|
-
def
|
|
209
|
+
# @return [Boolean] True if agent has valid memory configuration
|
|
210
|
+
def memory_configured?(agent_definition)
|
|
207
211
|
memory_config = agent_definition.plugin_config(:memory)
|
|
208
212
|
return false if memory_config.nil?
|
|
209
213
|
|
|
210
|
-
# MemoryConfig object (from DSL)
|
|
214
|
+
# MemoryConfig object (from DSL) - delegates to its enabled? method
|
|
211
215
|
return memory_config.enabled? if memory_config.respond_to?(:enabled?)
|
|
212
216
|
|
|
213
|
-
# Hash (from YAML)
|
|
214
|
-
|
|
217
|
+
# Hash (from YAML)
|
|
218
|
+
return false unless memory_config.is_a?(Hash)
|
|
219
|
+
return false if memory_config.empty?
|
|
220
|
+
|
|
221
|
+
adapter = (memory_config[:adapter] || memory_config["adapter"] || :filesystem).to_sym
|
|
222
|
+
|
|
223
|
+
case adapter
|
|
224
|
+
when :filesystem
|
|
225
|
+
# Filesystem adapter requires directory
|
|
215
226
|
directory = memory_config[:directory] || memory_config["directory"]
|
|
216
|
-
|
|
227
|
+
!directory.nil? && !directory.to_s.strip.empty?
|
|
228
|
+
else
|
|
229
|
+
# Custom adapters: presence of config is sufficient
|
|
230
|
+
# Adapter will validate its own requirements during initialization
|
|
231
|
+
true
|
|
217
232
|
end
|
|
218
|
-
|
|
219
|
-
false
|
|
220
233
|
end
|
|
221
234
|
|
|
222
235
|
# Contribute to agent serialization
|
|
@@ -293,9 +306,18 @@ module SwarmMemory
|
|
|
293
306
|
|
|
294
307
|
builder.instance_eval do
|
|
295
308
|
memory do
|
|
309
|
+
# Standard options
|
|
296
310
|
directory(memory_config[:directory]) if memory_config[:directory]
|
|
297
311
|
adapter(memory_config[:adapter]) if memory_config[:adapter]
|
|
298
312
|
mode(memory_config[:mode]) if memory_config[:mode]
|
|
313
|
+
|
|
314
|
+
# Pass through all custom adapter options
|
|
315
|
+
# Handle both symbol and string keys (YAML may have either)
|
|
316
|
+
standard_keys = [:directory, :adapter, :mode, "directory", "adapter", "mode"]
|
|
317
|
+
custom_keys = memory_config.keys - standard_keys
|
|
318
|
+
custom_keys.each do |key|
|
|
319
|
+
option(key.to_sym, memory_config[key]) # Normalize to symbol
|
|
320
|
+
end
|
|
299
321
|
end
|
|
300
322
|
end
|
|
301
323
|
end
|
|
@@ -336,6 +358,7 @@ module SwarmMemory
|
|
|
336
358
|
# Store storage and mode using BASE NAME
|
|
337
359
|
@storages[base_name] = storage # ← Changed from agent_name to base_name
|
|
338
360
|
@modes[base_name] = mode # ← Changed from agent_name to base_name
|
|
361
|
+
@threshold_configs[base_name] = extract_threshold_config(memory_config)
|
|
339
362
|
|
|
340
363
|
# Get mode-specific tools
|
|
341
364
|
allowed_tools = tools_for_mode(mode)
|
|
@@ -384,20 +407,27 @@ module SwarmMemory
|
|
|
384
407
|
# V7.0: Extract base name for storage lookup (delegation instances share storage)
|
|
385
408
|
base_name = agent_name.to_s.split("@").first.to_sym
|
|
386
409
|
storage = @storages[base_name] # ← Changed from agent_name to base_name
|
|
410
|
+
config = @threshold_configs[base_name] || {}
|
|
387
411
|
|
|
388
412
|
return [] unless storage&.semantic_index
|
|
389
413
|
return [] if prompt.nil? || prompt.empty?
|
|
390
414
|
|
|
391
415
|
# Adaptive threshold based on query length
|
|
392
416
|
# Short queries use lower threshold as they have less semantic richness
|
|
393
|
-
#
|
|
417
|
+
# Fallback chain: config → ENV → default
|
|
394
418
|
word_count = prompt.split.size
|
|
395
|
-
word_cutoff =
|
|
419
|
+
word_cutoff = config[:adaptive_word_cutoff] ||
|
|
420
|
+
ENV["SWARM_MEMORY_ADAPTIVE_WORD_CUTOFF"]&.to_i ||
|
|
421
|
+
10
|
|
396
422
|
|
|
397
423
|
threshold = if word_count < word_cutoff
|
|
398
|
-
|
|
424
|
+
config[:discovery_threshold_short] ||
|
|
425
|
+
ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD_SHORT"]&.to_f ||
|
|
426
|
+
0.25
|
|
399
427
|
else
|
|
400
|
-
|
|
428
|
+
config[:discovery_threshold] ||
|
|
429
|
+
ENV["SWARM_MEMORY_DISCOVERY_THRESHOLD"]&.to_f ||
|
|
430
|
+
0.35
|
|
401
431
|
end
|
|
402
432
|
reminders = []
|
|
403
433
|
|
|
@@ -482,9 +512,15 @@ module SwarmMemory
|
|
|
482
512
|
}
|
|
483
513
|
end
|
|
484
514
|
|
|
485
|
-
# Get actual weights being used (
|
|
486
|
-
|
|
487
|
-
|
|
515
|
+
# Get actual weights being used (fallback chain: config → ENV → defaults)
|
|
516
|
+
base_name = agent_name.to_s.split("@").first.to_sym
|
|
517
|
+
config = @threshold_configs[base_name] || {}
|
|
518
|
+
semantic_weight = config[:semantic_weight] ||
|
|
519
|
+
ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"]&.to_f ||
|
|
520
|
+
0.5
|
|
521
|
+
keyword_weight = config[:keyword_weight] ||
|
|
522
|
+
ENV["SWARM_MEMORY_KEYWORD_WEIGHT"]&.to_f ||
|
|
523
|
+
0.5
|
|
488
524
|
|
|
489
525
|
SwarmSDK::LogStream.emit(
|
|
490
526
|
type: "semantic_skill_search",
|
|
@@ -545,9 +581,15 @@ module SwarmMemory
|
|
|
545
581
|
}
|
|
546
582
|
end
|
|
547
583
|
|
|
548
|
-
# Get actual weights being used (
|
|
549
|
-
|
|
550
|
-
|
|
584
|
+
# Get actual weights being used (fallback chain: config → ENV → defaults)
|
|
585
|
+
base_name = agent_name.to_s.split("@").first.to_sym
|
|
586
|
+
config = @threshold_configs[base_name] || {}
|
|
587
|
+
semantic_weight = config[:semantic_weight] ||
|
|
588
|
+
ENV["SWARM_MEMORY_SEMANTIC_WEIGHT"]&.to_f ||
|
|
589
|
+
0.5
|
|
590
|
+
keyword_weight = config[:keyword_weight] ||
|
|
591
|
+
ENV["SWARM_MEMORY_KEYWORD_WEIGHT"]&.to_f ||
|
|
592
|
+
0.5
|
|
551
593
|
|
|
552
594
|
SwarmSDK::LogStream.emit(
|
|
553
595
|
type: "semantic_memory_search",
|
|
@@ -626,6 +668,40 @@ module SwarmMemory
|
|
|
626
668
|
|
|
627
669
|
reminder
|
|
628
670
|
end
|
|
671
|
+
|
|
672
|
+
# Extract threshold configuration from memory config
|
|
673
|
+
#
|
|
674
|
+
# Supports both MemoryConfig objects (from DSL) and Hash configs (from YAML).
|
|
675
|
+
# Extracts semantic search thresholds and hybrid search weights.
|
|
676
|
+
#
|
|
677
|
+
# @param memory_config [MemoryConfig, Hash, nil] Memory configuration
|
|
678
|
+
# @return [Hash] Threshold config with symbol keys
|
|
679
|
+
def extract_threshold_config(memory_config)
|
|
680
|
+
return {} unless memory_config
|
|
681
|
+
|
|
682
|
+
threshold_keys = [
|
|
683
|
+
:discovery_threshold,
|
|
684
|
+
:discovery_threshold_short,
|
|
685
|
+
:adaptive_word_cutoff,
|
|
686
|
+
:semantic_weight,
|
|
687
|
+
:keyword_weight,
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
if memory_config.respond_to?(:adapter_options)
|
|
691
|
+
# MemoryConfig object (from DSL)
|
|
692
|
+
memory_config.adapter_options.slice(*threshold_keys)
|
|
693
|
+
elsif memory_config.is_a?(Hash)
|
|
694
|
+
# Hash (from YAML) - handle both symbol and string keys
|
|
695
|
+
result = {}
|
|
696
|
+
threshold_keys.each do |key|
|
|
697
|
+
value = memory_config[key] || memory_config[key.to_s]
|
|
698
|
+
result[key] = value if value
|
|
699
|
+
end
|
|
700
|
+
result
|
|
701
|
+
else
|
|
702
|
+
{}
|
|
703
|
+
end
|
|
704
|
+
end
|
|
629
705
|
end
|
|
630
706
|
end
|
|
631
707
|
end
|
|
@@ -191,7 +191,7 @@ module SwarmMemory
|
|
|
191
191
|
" Semantic similarity: N/A (no embeddings)"
|
|
192
192
|
end
|
|
193
193
|
report << ""
|
|
194
|
-
report << " **Suggestion:** Review both entries and consider merging with
|
|
194
|
+
report << " **Suggestion:** Review both entries and consider merging with MemoryEdit"
|
|
195
195
|
report << ""
|
|
196
196
|
end
|
|
197
197
|
|
|
@@ -28,7 +28,6 @@ You have persistent memory that learns from conversations and helps you answer q
|
|
|
28
28
|
- `MemoryGlob` - Browse memory by path pattern
|
|
29
29
|
- `MemoryWrite` - Create new memory
|
|
30
30
|
- `MemoryEdit` - Update existing memory
|
|
31
|
-
- `MemoryMultiEdit` - Update multiple memories at once
|
|
32
31
|
- `MemoryDelete` - Delete a memory
|
|
33
32
|
- `MemoryDefrag` - Optimize memory storage
|
|
34
33
|
- `LoadSkill` - Load a skill and swap tools
|
|
@@ -162,11 +162,12 @@ module SwarmMemory
|
|
|
162
162
|
# Get existing entry metadata
|
|
163
163
|
entry = @storage.read_entry(file_path: file_path)
|
|
164
164
|
|
|
165
|
-
# Write updated content back (preserving the title)
|
|
165
|
+
# Write updated content back (preserving the title and metadata)
|
|
166
166
|
@storage.write(
|
|
167
167
|
file_path: file_path,
|
|
168
168
|
content: new_content,
|
|
169
169
|
title: entry.title,
|
|
170
|
+
metadata: entry.metadata,
|
|
170
171
|
)
|
|
171
172
|
|
|
172
173
|
# Build success message
|
|
@@ -35,7 +35,7 @@ module SwarmMemory
|
|
|
35
35
|
- MemoryRead(file_path: "skill/debugging/api-errors.md") - Read a skill before loading it
|
|
36
36
|
|
|
37
37
|
**Important:**
|
|
38
|
-
- Always read entries before editing them with MemoryEdit
|
|
38
|
+
- Always read entries before editing them with MemoryEdit
|
|
39
39
|
- Line numbers in output are for reference only - don't include them when editing
|
|
40
40
|
- Each read is tracked to enforce read-before-edit patterns
|
|
41
41
|
DESC
|
data/lib/swarm_memory/version.rb
CHANGED
data/lib/swarm_memory.rb
CHANGED
|
@@ -123,8 +123,6 @@ module SwarmMemory
|
|
|
123
123
|
Tools::MemoryRead.new(storage: storage, agent_name: agent_name)
|
|
124
124
|
when :MemoryEdit
|
|
125
125
|
Tools::MemoryEdit.new(storage: storage, agent_name: agent_name)
|
|
126
|
-
when :MemoryMultiEdit
|
|
127
|
-
Tools::MemoryMultiEdit.new(storage: storage, agent_name: agent_name)
|
|
128
126
|
when :MemoryDelete
|
|
129
127
|
Tools::MemoryDelete.new(storage: storage)
|
|
130
128
|
when :MemoryGlob
|
|
@@ -158,7 +156,6 @@ module SwarmMemory
|
|
|
158
156
|
Tools::MemoryWrite.new(storage: storage, agent_name: agent_name),
|
|
159
157
|
Tools::MemoryRead.new(storage: storage, agent_name: agent_name),
|
|
160
158
|
Tools::MemoryEdit.new(storage: storage, agent_name: agent_name),
|
|
161
|
-
Tools::MemoryMultiEdit.new(storage: storage, agent_name: agent_name),
|
|
162
159
|
Tools::MemoryDelete.new(storage: storage),
|
|
163
160
|
Tools::MemoryGlob.new(storage: storage),
|
|
164
161
|
Tools::MemoryGrep.new(storage: storage),
|
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.1
|
|
4
|
+
version: 2.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paulo Arruda
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 2.
|
|
74
|
+
version: 2.5.0
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 2.
|
|
81
|
+
version: 2.5.0
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: zeitwerk
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -138,7 +138,6 @@ files:
|
|
|
138
138
|
- lib/swarm_memory/tools/memory_edit.rb
|
|
139
139
|
- lib/swarm_memory/tools/memory_glob.rb
|
|
140
140
|
- lib/swarm_memory/tools/memory_grep.rb
|
|
141
|
-
- lib/swarm_memory/tools/memory_multi_edit.rb
|
|
142
141
|
- lib/swarm_memory/tools/memory_read.rb
|
|
143
142
|
- lib/swarm_memory/tools/memory_write.rb
|
|
144
143
|
- lib/swarm_memory/utils.rb
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SwarmMemory
|
|
4
|
-
module Tools
|
|
5
|
-
# Tool for performing multiple edits to a memory entry
|
|
6
|
-
#
|
|
7
|
-
# Applies multiple edit operations sequentially to a single memory entry.
|
|
8
|
-
# Each edit sees the result of all previous edits, allowing for
|
|
9
|
-
# coordinated multi-step transformations.
|
|
10
|
-
# Each agent has its own isolated memory storage.
|
|
11
|
-
class MemoryMultiEdit < RubyLLM::Tool
|
|
12
|
-
description <<~DESC
|
|
13
|
-
Perform multiple exact string replacements in a single memory entry (applies edits sequentially).
|
|
14
|
-
|
|
15
|
-
REQUIRED: Provide BOTH parameters - file_path and edits_json.
|
|
16
|
-
|
|
17
|
-
**Required Parameters:**
|
|
18
|
-
- file_path (REQUIRED): Path to memory entry - MUST start with concept/, fact/, skill/, or experience/
|
|
19
|
-
- edits_json (REQUIRED): JSON array of edit operations - each must have old_string, new_string, and optionally replace_all
|
|
20
|
-
|
|
21
|
-
**MEMORY STRUCTURE (4 Fixed Categories Only):**
|
|
22
|
-
- concept/{domain}/** - Abstract ideas
|
|
23
|
-
- fact/{subfolder}/** - Concrete information
|
|
24
|
-
- skill/{domain}/** - Procedures
|
|
25
|
-
- experience/** - Lessons
|
|
26
|
-
INVALID: documentation/, reference/, project/, code/, parallel/
|
|
27
|
-
|
|
28
|
-
**JSON Format:**
|
|
29
|
-
```json
|
|
30
|
-
[
|
|
31
|
-
{"old_string": "text to find", "new_string": "replacement text", "replace_all": false},
|
|
32
|
-
{"old_string": "another find", "new_string": "another replace", "replace_all": true}
|
|
33
|
-
]
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
**CRITICAL - Before Using This Tool:**
|
|
37
|
-
1. You MUST use MemoryRead on the entry first - edits without reading will FAIL
|
|
38
|
-
2. Copy text exactly from MemoryRead output, EXCLUDING the line number prefix
|
|
39
|
-
3. Line number format: " 123→actual content" - only use text AFTER the arrow
|
|
40
|
-
4. Edits are applied SEQUENTIALLY - later edits see results of earlier edits
|
|
41
|
-
5. If ANY edit fails, NO changes are saved (all-or-nothing)
|
|
42
|
-
|
|
43
|
-
**How Sequential Edits Work:**
|
|
44
|
-
```
|
|
45
|
-
Original: "status: pending, priority: low"
|
|
46
|
-
|
|
47
|
-
Edit 1: "pending" → "in-progress"
|
|
48
|
-
Result: "status: in-progress, priority: low"
|
|
49
|
-
|
|
50
|
-
Edit 2: "low" → "high" (sees Edit 1's result)
|
|
51
|
-
Final: "status: in-progress, priority: high"
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**Use Cases:**
|
|
55
|
-
- Making multiple coordinated changes in one operation
|
|
56
|
-
- Updating several related fields at once
|
|
57
|
-
- Chaining transformations where order matters
|
|
58
|
-
- Bulk find-and-replace operations
|
|
59
|
-
|
|
60
|
-
**Examples:**
|
|
61
|
-
```
|
|
62
|
-
# Update multiple fields in an experience
|
|
63
|
-
MemoryMultiEdit(
|
|
64
|
-
file_path: "experience/api-debugging.md",
|
|
65
|
-
edits_json: '[
|
|
66
|
-
{"old_string": "status: in-progress", "new_string": "status: resolved"},
|
|
67
|
-
{"old_string": "confidence: medium", "new_string": "confidence: high"}
|
|
68
|
-
]'
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
# Rename function and update calls in a concept
|
|
72
|
-
MemoryMultiEdit(
|
|
73
|
-
file_path: "concept/ruby/functions.md",
|
|
74
|
-
edits_json: '[
|
|
75
|
-
{"old_string": "def old_func_name", "new_string": "def new_func_name"},
|
|
76
|
-
{"old_string": "old_func_name()", "new_string": "new_func_name()", "replace_all": true}
|
|
77
|
-
]'
|
|
78
|
-
)
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
**Important Notes:**
|
|
82
|
-
- All edits in the array must be valid JSON objects
|
|
83
|
-
- Each old_string must be different from its new_string
|
|
84
|
-
- Each old_string must be unique in content UNLESS replace_all is true
|
|
85
|
-
- Failed edit shows which previous edits succeeded
|
|
86
|
-
- More efficient than multiple MemoryEdit calls
|
|
87
|
-
DESC
|
|
88
|
-
|
|
89
|
-
param :file_path,
|
|
90
|
-
desc: "Path to memory entry - MUST start with concept/, fact/, skill/, or experience/ (e.g., 'experience/api-debugging.md', 'concept/ruby/functions.md')",
|
|
91
|
-
required: true
|
|
92
|
-
|
|
93
|
-
param :edits_json,
|
|
94
|
-
type: "string",
|
|
95
|
-
desc: <<~DESC.chomp,
|
|
96
|
-
JSON array of edit operations. Each edit must have:
|
|
97
|
-
old_string (exact text to replace),
|
|
98
|
-
new_string (replacement text),
|
|
99
|
-
and optionally replace_all (boolean, default false).
|
|
100
|
-
Example: [{"old_string":"foo","new_string":"bar","replace_all":false}]
|
|
101
|
-
DESC
|
|
102
|
-
required: true
|
|
103
|
-
|
|
104
|
-
# Initialize with storage instance and agent name
|
|
105
|
-
#
|
|
106
|
-
# @param storage [Core::Storage] Storage instance
|
|
107
|
-
# @param agent_name [String, Symbol] Agent identifier
|
|
108
|
-
def initialize(storage:, agent_name:)
|
|
109
|
-
super()
|
|
110
|
-
@storage = storage
|
|
111
|
-
@agent_name = agent_name.to_sym
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Override name to return simple "MemoryMultiEdit"
|
|
115
|
-
def name
|
|
116
|
-
"MemoryMultiEdit"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Execute the tool
|
|
120
|
-
#
|
|
121
|
-
# @param file_path [String] Path to memory entry
|
|
122
|
-
# @param edits_json [String] JSON array of edit operations
|
|
123
|
-
# @return [String] Success message or error
|
|
124
|
-
def execute(file_path:, edits_json:)
|
|
125
|
-
# Validate inputs
|
|
126
|
-
return validation_error("file_path is required") if file_path.nil? || file_path.to_s.strip.empty?
|
|
127
|
-
|
|
128
|
-
# Parse JSON
|
|
129
|
-
edits = begin
|
|
130
|
-
JSON.parse(edits_json)
|
|
131
|
-
rescue JSON::ParserError
|
|
132
|
-
nil
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
return validation_error("Invalid JSON format. Please provide a valid JSON array of edit operations.") if edits.nil?
|
|
136
|
-
|
|
137
|
-
return validation_error("edits must be an array") unless edits.is_a?(Array)
|
|
138
|
-
return validation_error("edits array cannot be empty") if edits.empty?
|
|
139
|
-
|
|
140
|
-
# Read current content (this will raise ArgumentError if entry doesn't exist)
|
|
141
|
-
content = @storage.read(file_path: file_path)
|
|
142
|
-
|
|
143
|
-
# Enforce read-before-edit with content verification
|
|
144
|
-
unless Core::StorageReadTracker.entry_read?(@agent_name, file_path, @storage)
|
|
145
|
-
return validation_error(
|
|
146
|
-
"Cannot edit memory entry without reading it first. " \
|
|
147
|
-
"You must use MemoryRead on 'memory://#{file_path}' before editing it. " \
|
|
148
|
-
"This ensures you have the current content to match against.",
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Validate edit operations
|
|
153
|
-
validated_edits = []
|
|
154
|
-
edits.each_with_index do |edit, index|
|
|
155
|
-
unless edit.is_a?(Hash)
|
|
156
|
-
return validation_error("Edit at index #{index} must be a hash/object with old_string and new_string")
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Convert string keys to symbols for consistency
|
|
160
|
-
edit = edit.transform_keys(&:to_sym)
|
|
161
|
-
|
|
162
|
-
unless edit[:old_string]
|
|
163
|
-
return validation_error("Edit at index #{index} missing required field 'old_string'")
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
unless edit[:new_string]
|
|
167
|
-
return validation_error("Edit at index #{index} missing required field 'new_string'")
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# old_string and new_string must be different
|
|
171
|
-
if edit[:old_string] == edit[:new_string]
|
|
172
|
-
return validation_error("Edit at index #{index}: old_string and new_string must be different")
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
validated_edits << {
|
|
176
|
-
old_string: edit[:old_string].to_s,
|
|
177
|
-
new_string: edit[:new_string].to_s,
|
|
178
|
-
replace_all: edit[:replace_all] == true,
|
|
179
|
-
index: index,
|
|
180
|
-
}
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Apply edits sequentially
|
|
184
|
-
results = []
|
|
185
|
-
current_content = content
|
|
186
|
-
|
|
187
|
-
validated_edits.each do |edit|
|
|
188
|
-
# Check if old_string exists in current content
|
|
189
|
-
unless current_content.include?(edit[:old_string])
|
|
190
|
-
return error_with_results(
|
|
191
|
-
<<~ERROR.chomp,
|
|
192
|
-
Edit #{edit[:index]}: old_string not found in memory entry.
|
|
193
|
-
Make sure it matches exactly, including all whitespace and indentation.
|
|
194
|
-
Do not include line number prefixes from MemoryRead tool output.
|
|
195
|
-
Note: This edit follows #{edit[:index]} previous edit(s) which may have changed the content.
|
|
196
|
-
ERROR
|
|
197
|
-
results,
|
|
198
|
-
)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Count occurrences
|
|
202
|
-
occurrences = current_content.scan(edit[:old_string]).count
|
|
203
|
-
|
|
204
|
-
# If not replace_all and multiple occurrences, error
|
|
205
|
-
if !edit[:replace_all] && occurrences > 1
|
|
206
|
-
return error_with_results(
|
|
207
|
-
<<~ERROR.chomp,
|
|
208
|
-
Edit #{edit[:index]}: Found #{occurrences} occurrences of old_string.
|
|
209
|
-
Either provide more surrounding context to make the match unique, or set replace_all: true to replace all occurrences.
|
|
210
|
-
ERROR
|
|
211
|
-
results,
|
|
212
|
-
)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Perform replacement
|
|
216
|
-
new_content = if edit[:replace_all]
|
|
217
|
-
current_content.gsub(edit[:old_string], edit[:new_string])
|
|
218
|
-
else
|
|
219
|
-
current_content.sub(edit[:old_string], edit[:new_string])
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
# Record result
|
|
223
|
-
replaced_count = edit[:replace_all] ? occurrences : 1
|
|
224
|
-
results << {
|
|
225
|
-
index: edit[:index],
|
|
226
|
-
status: "success",
|
|
227
|
-
occurrences: replaced_count,
|
|
228
|
-
message: "Replaced #{replaced_count} occurrence(s)",
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
# Update content for next edit
|
|
232
|
-
current_content = new_content
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Get existing entry
|
|
236
|
-
entry = @storage.read_entry(file_path: file_path)
|
|
237
|
-
|
|
238
|
-
# Write updated content back (preserving the title)
|
|
239
|
-
@storage.write(
|
|
240
|
-
file_path: file_path,
|
|
241
|
-
content: current_content,
|
|
242
|
-
title: entry.title,
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
# Build success message
|
|
246
|
-
total_replacements = results.sum { |r| r[:occurrences] }
|
|
247
|
-
message = "Successfully applied #{validated_edits.size} edit(s) to memory://#{file_path}\n"
|
|
248
|
-
message += "Total replacements: #{total_replacements}\n\n"
|
|
249
|
-
message += "Details:\n"
|
|
250
|
-
results.each do |result|
|
|
251
|
-
message += " Edit #{result[:index]}: #{result[:message]}\n"
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
message
|
|
255
|
-
rescue ArgumentError => e
|
|
256
|
-
validation_error(e.message)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
private
|
|
260
|
-
|
|
261
|
-
def validation_error(message)
|
|
262
|
-
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def error_with_results(message, results)
|
|
266
|
-
output = "<tool_use_error>InputValidationError: #{message}\n\n"
|
|
267
|
-
|
|
268
|
-
if results.any?
|
|
269
|
-
output += "Previous successful edits before error:\n"
|
|
270
|
-
results.each do |result|
|
|
271
|
-
output += " Edit #{result[:index]}: #{result[:message]}\n"
|
|
272
|
-
end
|
|
273
|
-
output += "\n"
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
output += "Note: The memory entry has NOT been modified. All or nothing approach - if any edit fails, no changes are saved.</tool_use_error>"
|
|
277
|
-
output
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
end
|