swarm_memory 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/claude_swarm/cli.rb +9 -11
  3. data/lib/claude_swarm/commands/ps.rb +1 -2
  4. data/lib/claude_swarm/configuration.rb +30 -7
  5. data/lib/claude_swarm/mcp_generator.rb +4 -10
  6. data/lib/claude_swarm/orchestrator.rb +43 -44
  7. data/lib/claude_swarm/system_utils.rb +4 -4
  8. data/lib/claude_swarm/version.rb +1 -1
  9. data/lib/claude_swarm.rb +5 -9
  10. data/lib/swarm_cli/commands/mcp_serve.rb +2 -2
  11. data/lib/swarm_cli/commands/mcp_tools.rb +3 -3
  12. data/lib/swarm_cli/config_loader.rb +14 -13
  13. data/lib/swarm_cli/version.rb +1 -1
  14. data/lib/swarm_cli.rb +2 -0
  15. data/lib/swarm_memory/adapters/base.rb +4 -4
  16. data/lib/swarm_memory/adapters/filesystem_adapter.rb +0 -12
  17. data/lib/swarm_memory/core/storage.rb +66 -6
  18. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  19. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  20. data/lib/swarm_memory/integration/sdk_plugin.rb +24 -4
  21. data/lib/swarm_memory/optimization/defragmenter.rb +4 -0
  22. data/lib/swarm_memory/tools/memory_edit.rb +3 -2
  23. data/lib/swarm_memory/tools/memory_glob.rb +24 -1
  24. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  25. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  26. data/lib/swarm_memory/tools/memory_write.rb +2 -2
  27. data/lib/swarm_memory/version.rb +1 -1
  28. data/lib/swarm_memory.rb +7 -0
  29. data/lib/swarm_sdk/agent/builder.rb +33 -0
  30. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  31. data/lib/swarm_sdk/agent/chat/hook_integration.rb +41 -0
  32. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  33. data/lib/swarm_sdk/agent/chat.rb +199 -52
  34. data/lib/swarm_sdk/agent/context.rb +6 -2
  35. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  36. data/lib/swarm_sdk/agent/definition.rb +32 -23
  37. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  38. data/lib/swarm_sdk/configuration.rb +420 -103
  39. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  40. data/lib/swarm_sdk/log_collector.rb +31 -5
  41. data/lib/swarm_sdk/log_stream.rb +37 -8
  42. data/lib/swarm_sdk/model_aliases.json +4 -1
  43. data/lib/swarm_sdk/node/agent_config.rb +39 -9
  44. data/lib/swarm_sdk/node/builder.rb +158 -42
  45. data/lib/swarm_sdk/node_context.rb +75 -0
  46. data/lib/swarm_sdk/node_orchestrator.rb +492 -18
  47. data/lib/swarm_sdk/plugin.rb +73 -1
  48. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  49. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -126
  50. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  51. data/lib/swarm_sdk/restore_result.rb +65 -0
  52. data/lib/swarm_sdk/result.rb +32 -6
  53. data/lib/swarm_sdk/snapshot.rb +156 -0
  54. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  55. data/lib/swarm_sdk/state_restorer.rb +491 -0
  56. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  57. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  58. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  59. data/lib/swarm_sdk/swarm/builder.rb +208 -11
  60. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  61. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  62. data/lib/swarm_sdk/swarm.rb +367 -90
  63. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  64. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  65. data/lib/swarm_sdk/tools/delegate.rb +94 -9
  66. data/lib/swarm_sdk/tools/read.rb +17 -5
  67. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +23 -2
  68. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +23 -2
  69. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +21 -4
  70. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  71. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  72. data/lib/swarm_sdk/tools/stores/storage.rb +4 -4
  73. data/lib/swarm_sdk/tools/think.rb +4 -1
  74. data/lib/swarm_sdk/tools/todo_write.rb +20 -8
  75. data/lib/swarm_sdk/utils.rb +18 -0
  76. data/lib/swarm_sdk/validation_result.rb +33 -0
  77. data/lib/swarm_sdk/version.rb +1 -1
  78. data/lib/swarm_sdk.rb +365 -28
  79. metadata +17 -5
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # Reconstructs a complete StateSnapshot from event logs
5
+ #
6
+ # This class enables full swarm state reconstruction from event streams,
7
+ # supporting session persistence, time travel debugging, and event sourcing.
8
+ #
9
+ # ## Usage
10
+ #
11
+ # # Collect events during execution
12
+ # events = []
13
+ # swarm.execute("Build feature") { |event| events << event }
14
+ #
15
+ # # Save events to storage (DB, file, Redis, etc.)
16
+ # File.write("session.json", JSON.generate(events))
17
+ #
18
+ # # Later: Reconstruct snapshot from events
19
+ # events = JSON.parse(File.read("session.json"), symbolize_names: true)
20
+ # snapshot_data = SwarmSDK::SnapshotFromEvents.reconstruct(events)
21
+ #
22
+ # # Restore swarm from reconstructed snapshot
23
+ # swarm = SwarmSDK::Swarm.from_config("swarm.yml")
24
+ # swarm.restore(snapshot_data)
25
+ #
26
+ # ## What Gets Reconstructed
27
+ #
28
+ # - Swarm metadata (swarm_id, parent_swarm_id, first_message_sent)
29
+ # - Agent conversations (all RubyLLM::Message objects)
30
+ # - Agent context state (warnings, compression, todowrite, skills)
31
+ # - Delegation instance conversations
32
+ # - Scratchpad contents
33
+ # - Read tracking with digests
34
+ # - Memory read tracking with digests
35
+ #
36
+ # ## Requirements
37
+ #
38
+ # Events must have:
39
+ # - :timestamp field (ISO 8601 format) for chronological ordering
40
+ # - :agent field to identify which agent
41
+ # - :type field to identify event type
42
+ #
43
+ # @see EventsToMessages for message reconstruction details
44
+ class SnapshotFromEvents
45
+ class << self
46
+ # Reconstruct a complete StateSnapshot from event stream
47
+ #
48
+ # @param events [Array<Hash>] Event stream with timestamps
49
+ # @return [Hash] Complete StateSnapshot hash compatible with StateRestorer
50
+ #
51
+ # @example
52
+ # snapshot_data = SnapshotFromEvents.reconstruct(events)
53
+ # swarm.restore(snapshot_data)
54
+ def reconstruct(events)
55
+ new(events).reconstruct
56
+ end
57
+ end
58
+
59
+ # Initialize reconstructor
60
+ #
61
+ # @param events [Array<Hash>] Event stream
62
+ def initialize(events)
63
+ # Sort events by timestamp for chronological processing
64
+ @events = events.sort_by { |e| parse_timestamp(e[:timestamp]) }
65
+ end
66
+
67
+ # Reconstruct complete snapshot
68
+ #
69
+ # @return [Hash] StateSnapshot hash
70
+ def reconstruct
71
+ {
72
+ version: "1.0.0",
73
+ type: "swarm",
74
+ snapshot_at: @events.last&.fetch(:timestamp, Time.now.utc.iso8601),
75
+ swarm_sdk_version: SwarmSDK::VERSION,
76
+ swarm: reconstruct_swarm_metadata,
77
+ agents: reconstruct_all_agents,
78
+ delegation_instances: reconstruct_all_delegations,
79
+ scratchpad: reconstruct_scratchpad,
80
+ read_tracking: reconstruct_read_tracking,
81
+ memory_read_tracking: reconstruct_memory_read_tracking,
82
+ }
83
+ end
84
+
85
+ private
86
+
87
+ # Reconstruct swarm metadata
88
+ #
89
+ # @return [Hash] Swarm metadata
90
+ def reconstruct_swarm_metadata
91
+ first_event = @events.first || {}
92
+
93
+ {
94
+ id: first_event[:swarm_id],
95
+ parent_id: first_event[:parent_swarm_id],
96
+ first_message_sent: !@events.empty?,
97
+ }
98
+ end
99
+
100
+ # Reconstruct all primary agents
101
+ #
102
+ # @return [Hash] { agent_name => { conversation:, context_state:, system_prompt: } }
103
+ def reconstruct_all_agents
104
+ # Get unique primary agent names (exclude delegations with @)
105
+ agent_names = @events
106
+ .map { |e| e[:agent] }
107
+ .compact
108
+ .uniq
109
+ .reject { |name| name.to_s.include?("@") }
110
+
111
+ agent_names.each_with_object({}) do |agent, hash|
112
+ hash[agent.to_s] = {
113
+ conversation: reconstruct_conversation(agent),
114
+ context_state: reconstruct_context_state(agent),
115
+ system_prompt: reconstruct_system_prompt(agent),
116
+ }
117
+ end
118
+ end
119
+
120
+ # Reconstruct all delegation instances
121
+ #
122
+ # @return [Hash] { "delegate@delegator" => { conversation:, context_state:, system_prompt: } }
123
+ def reconstruct_all_delegations
124
+ # Get unique delegation instance names (contain @)
125
+ delegation_names = @events
126
+ .map { |e| e[:agent] }
127
+ .compact
128
+ .uniq
129
+ .select { |name| name.to_s.include?("@") }
130
+
131
+ delegation_names.each_with_object({}) do |delegation, hash|
132
+ hash[delegation.to_s] = {
133
+ conversation: reconstruct_conversation(delegation),
134
+ context_state: reconstruct_context_state(delegation),
135
+ system_prompt: reconstruct_system_prompt(delegation),
136
+ }
137
+ end
138
+ end
139
+
140
+ # Reconstruct conversation for an agent
141
+ #
142
+ # @param agent [Symbol, String] Agent name
143
+ # @return [Array<Hash>] Serialized RubyLLM::Message objects
144
+ def reconstruct_conversation(agent)
145
+ messages = SwarmSDK::EventsToMessages.reconstruct(@events, agent: agent)
146
+ messages.map { |msg| serialize_message(msg) }
147
+ end
148
+
149
+ # Serialize a RubyLLM::Message to hash format
150
+ #
151
+ # Matches the format used by StateSnapshot.
152
+ #
153
+ # @param msg [RubyLLM::Message] Message to serialize
154
+ # @return [Hash] Serialized message
155
+ def serialize_message(msg)
156
+ hash = { role: msg.role, content: msg.content }
157
+
158
+ # Serialize tool calls if present
159
+ # Must manually extract fields - RubyLLM::ToolCall#to_h doesn't reliably serialize id/name
160
+ # msg.tool_calls is a Hash<String, ToolCall>, so we need .values
161
+ if msg.tool_calls && !msg.tool_calls.empty?
162
+ hash[:tool_calls] = msg.tool_calls.values.map do |tc|
163
+ {
164
+ id: tc.id,
165
+ name: tc.name,
166
+ arguments: tc.arguments,
167
+ }
168
+ end
169
+ end
170
+
171
+ # Add optional fields
172
+ hash[:tool_call_id] = msg.tool_call_id if msg.tool_call_id
173
+ hash[:input_tokens] = msg.input_tokens if msg.input_tokens
174
+ hash[:output_tokens] = msg.output_tokens if msg.output_tokens
175
+ hash[:model_id] = msg.model_id if msg.model_id
176
+
177
+ hash
178
+ end
179
+
180
+ # Reconstruct context state for an agent
181
+ #
182
+ # @param agent [Symbol, String] Agent name
183
+ # @return [Hash] Context state
184
+ def reconstruct_context_state(agent)
185
+ {
186
+ warning_thresholds_hit: reconstruct_warning_thresholds(agent),
187
+ compression_applied: reconstruct_compression_applied(agent),
188
+ last_todowrite_message_index: reconstruct_todowrite_index(agent),
189
+ active_skill_path: reconstruct_active_skill_path(agent),
190
+ }
191
+ end
192
+
193
+ # Reconstruct which warning thresholds were hit
194
+ #
195
+ # @param agent [Symbol, String] Agent name
196
+ # @return [Array<Integer>] Sorted array of thresholds that were hit
197
+ def reconstruct_warning_thresholds(agent)
198
+ @events
199
+ .select { |e| e[:type] == "context_threshold_hit" && normalize_agent(e[:agent]) == normalize_agent(agent) }
200
+ .map { |e| e[:threshold] }
201
+ .uniq
202
+ .sort
203
+ end
204
+
205
+ # Reconstruct compression state
206
+ #
207
+ # @param agent [Symbol, String] Agent name
208
+ # @return [Boolean, nil] true if compression was applied, nil otherwise
209
+ def reconstruct_compression_applied(agent)
210
+ compression_event = @events
211
+ .select { |e| e[:type] == "compression_completed" && normalize_agent(e[:agent]) == normalize_agent(agent) }
212
+ .last
213
+
214
+ # NOTE: StateSnapshot stores nil (not false) when no compression applied
215
+ compression_event ? true : nil
216
+ end
217
+
218
+ # Reconstruct last TodoWrite message index
219
+ #
220
+ # Finds the last TodoWrite tool call and determines which message index it corresponds to.
221
+ #
222
+ # @param agent [Symbol, String] Agent name
223
+ # @return [Integer, nil] Message index or nil if no TodoWrite calls
224
+ def reconstruct_todowrite_index(agent)
225
+ # Find last TodoWrite tool call
226
+ last_todowrite_call = @events
227
+ .select { |e| e[:type] == "tool_call" && e[:tool] == "TodoWrite" && normalize_agent(e[:agent]) == normalize_agent(agent) }
228
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
229
+ .last
230
+
231
+ return unless last_todowrite_call
232
+
233
+ # Reconstruct messages to find index
234
+ messages = SwarmSDK::EventsToMessages.reconstruct(@events, agent: agent)
235
+
236
+ # Find the message containing this tool_call_id
237
+ messages.each_with_index do |msg, idx|
238
+ if msg.role == :assistant && msg.tool_calls
239
+ return idx if msg.tool_calls.key?(last_todowrite_call[:tool_call_id])
240
+ end
241
+ end
242
+
243
+ nil
244
+ end
245
+
246
+ # Reconstruct active skill path
247
+ #
248
+ # @param agent [Symbol, String] Agent name
249
+ # @return [String, nil] Skill path or nil if no skill loaded
250
+ def reconstruct_active_skill_path(agent)
251
+ last_load_skill = @events
252
+ .select { |e| e[:type] == "tool_call" && e[:tool] == "LoadSkill" && normalize_agent(e[:agent]) == normalize_agent(agent) }
253
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
254
+ .last
255
+
256
+ return unless last_load_skill
257
+
258
+ # Extract skill path from arguments (handle both symbol and string keys)
259
+ args = last_load_skill[:arguments]
260
+ args[:file_path] || args["file_path"]
261
+ end
262
+
263
+ # Reconstruct system prompt from the last agent_start event
264
+ #
265
+ # Uses the LAST agent_start event for this agent, which represents the most
266
+ # recent system prompt that was active. This handles cases where the swarm
267
+ # was restarted with updated configuration.
268
+ #
269
+ # @param agent [Symbol, String] Agent name
270
+ # @return [String, nil] System prompt or nil if no agent_start event found
271
+ def reconstruct_system_prompt(agent)
272
+ last_agent_start = @events
273
+ .select { |e| e[:type] == "agent_start" && normalize_agent(e[:agent]) == normalize_agent(agent) }
274
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
275
+ .last
276
+
277
+ return unless last_agent_start
278
+
279
+ # Handle both symbol and string keys from JSON
280
+ last_agent_start[:system_prompt] || last_agent_start["system_prompt"]
281
+ end
282
+
283
+ # Reconstruct scratchpad contents
284
+ #
285
+ # Replays all ScratchpadWrite tool calls in chronological order.
286
+ # Later writes to the same path overwrite earlier ones.
287
+ #
288
+ # @return [Hash] { path => { content:, title:, updated_at:, size: } }
289
+ def reconstruct_scratchpad
290
+ scratchpad = {}
291
+
292
+ @events
293
+ .select { |e| e[:type] == "tool_call" && e[:tool] == "ScratchpadWrite" }
294
+ .sort_by { |e| parse_timestamp(e[:timestamp]) }
295
+ .each do |event|
296
+ args = event[:arguments]
297
+
298
+ # Handle both symbol and string keys
299
+ path = args[:file_path] || args["file_path"]
300
+ content = args[:content] || args["content"]
301
+ title = args[:title] || args["title"]
302
+
303
+ next unless path && content
304
+
305
+ scratchpad[path] = {
306
+ content: content,
307
+ title: title,
308
+ updated_at: event[:timestamp],
309
+ size: content.bytesize,
310
+ }
311
+ end
312
+
313
+ scratchpad
314
+ end
315
+
316
+ # Reconstruct read tracking (file digests)
317
+ #
318
+ # Extracts digests from Read tool_result metadata.
319
+ # Later reads to the same file update the digest.
320
+ #
321
+ # @return [Hash] { agent_name => { file_path => digest } }
322
+ def reconstruct_read_tracking
323
+ tracking = {}
324
+
325
+ @events
326
+ .select { |e| e[:type] == "tool_result" && e[:tool] == "Read" }
327
+ .each do |event|
328
+ agent = event[:agent].to_s # Use string key to match StateSnapshot format
329
+ digest = event.dig(:metadata, :read_digest)
330
+ path = event.dig(:metadata, :read_path)
331
+
332
+ next unless digest && path
333
+
334
+ tracking[agent] ||= {}
335
+ tracking[agent][path] = digest
336
+ end
337
+
338
+ tracking
339
+ end
340
+
341
+ # Reconstruct memory read tracking (entry digests)
342
+ #
343
+ # Extracts digests from MemoryRead tool_result metadata.
344
+ # Later reads to the same entry update the digest.
345
+ #
346
+ # @return [Hash] { agent_name => { entry_path => digest } }
347
+ def reconstruct_memory_read_tracking
348
+ tracking = {}
349
+
350
+ @events
351
+ .select { |e| e[:type] == "tool_result" && e[:tool] == "MemoryRead" }
352
+ .each do |event|
353
+ agent = event[:agent].to_s # Use string key to match StateSnapshot format
354
+ digest = event.dig(:metadata, :read_digest)
355
+ path = event.dig(:metadata, :read_path)
356
+
357
+ next unless digest && path
358
+
359
+ tracking[agent] ||= {}
360
+ tracking[agent][path] = digest
361
+ end
362
+
363
+ tracking
364
+ end
365
+
366
+ # Parse timestamp string to Time object
367
+ #
368
+ # @param timestamp [String, nil] ISO 8601 timestamp
369
+ # @return [Time] Parsed time or epoch if nil/invalid
370
+ def parse_timestamp(timestamp)
371
+ return Time.at(0) unless timestamp
372
+
373
+ Time.parse(timestamp)
374
+ rescue ArgumentError
375
+ Time.at(0)
376
+ end
377
+
378
+ # Normalize agent name for comparison
379
+ #
380
+ # @param agent [Symbol, String, nil] Agent name
381
+ # @return [Symbol] Normalized agent name
382
+ def normalize_agent(agent)
383
+ agent.to_s.to_sym
384
+ end
385
+ end
386
+ end