swarm_sdk 2.7.13 → 3.0.0.alpha1

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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +43 -22
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +6 -0
  4. data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
  5. data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +3 -4
  6. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  7. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  8. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  9. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  10. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  11. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  12. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  13. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  14. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  15. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  16. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  17. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  18. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  19. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  20. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  24. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  25. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  26. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  27. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  28. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  29. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  30. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  31. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  32. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  33. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  34. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  35. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  36. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  37. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  38. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  39. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  40. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  41. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  42. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  43. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  45. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  46. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  47. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  48. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  49. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  50. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  51. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  52. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  53. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  54. data/lib/swarm_sdk/v3.rb +145 -0
  55. metadata +84 -148
  56. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  57. data/lib/swarm_sdk/agent/builder.rb +0 -680
  58. data/lib/swarm_sdk/agent/chat.rb +0 -1432
  59. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  60. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  61. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  62. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  63. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  64. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  65. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  66. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  67. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  68. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  69. data/lib/swarm_sdk/agent/context.rb +0 -115
  70. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  71. data/lib/swarm_sdk/agent/definition.rb +0 -581
  72. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  73. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -161
  74. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  75. data/lib/swarm_sdk/agent_registry.rb +0 -146
  76. data/lib/swarm_sdk/builders/base_builder.rb +0 -553
  77. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  78. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -39
  79. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  80. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  81. data/lib/swarm_sdk/config.rb +0 -367
  82. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  83. data/lib/swarm_sdk/configuration/translator.rb +0 -283
  84. data/lib/swarm_sdk/configuration.rb +0 -165
  85. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  86. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  87. data/lib/swarm_sdk/context_compactor.rb +0 -335
  88. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  89. data/lib/swarm_sdk/context_management/context.rb +0 -328
  90. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  91. data/lib/swarm_sdk/defaults.rb +0 -251
  92. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  93. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  94. data/lib/swarm_sdk/hooks/context.rb +0 -197
  95. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  96. data/lib/swarm_sdk/hooks/error.rb +0 -29
  97. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  98. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  99. data/lib/swarm_sdk/hooks/result.rb +0 -150
  100. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  101. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  102. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  103. data/lib/swarm_sdk/log_collector.rb +0 -227
  104. data/lib/swarm_sdk/log_stream.rb +0 -127
  105. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  106. data/lib/swarm_sdk/model_aliases.json +0 -8
  107. data/lib/swarm_sdk/models.json +0 -44002
  108. data/lib/swarm_sdk/models.rb +0 -161
  109. data/lib/swarm_sdk/node_context.rb +0 -245
  110. data/lib/swarm_sdk/observer/builder.rb +0 -81
  111. data/lib/swarm_sdk/observer/config.rb +0 -45
  112. data/lib/swarm_sdk/observer/manager.rb +0 -236
  113. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  114. data/lib/swarm_sdk/permissions/config.rb +0 -239
  115. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  116. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  117. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  118. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  119. data/lib/swarm_sdk/plugin.rb +0 -309
  120. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  121. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  122. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -117
  123. data/lib/swarm_sdk/restore_result.rb +0 -65
  124. data/lib/swarm_sdk/result.rb +0 -212
  125. data/lib/swarm_sdk/snapshot.rb +0 -156
  126. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  127. data/lib/swarm_sdk/state_restorer.rb +0 -476
  128. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  129. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  130. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -195
  131. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  132. data/lib/swarm_sdk/swarm/executor.rb +0 -290
  133. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -151
  134. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  135. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -360
  136. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -270
  137. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  138. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  139. data/lib/swarm_sdk/swarm.rb +0 -843
  140. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  141. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  142. data/lib/swarm_sdk/tools/base.rb +0 -63
  143. data/lib/swarm_sdk/tools/bash.rb +0 -280
  144. data/lib/swarm_sdk/tools/clock.rb +0 -46
  145. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  146. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  147. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  148. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  149. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  150. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  151. data/lib/swarm_sdk/tools/edit.rb +0 -145
  152. data/lib/swarm_sdk/tools/glob.rb +0 -166
  153. data/lib/swarm_sdk/tools/grep.rb +0 -235
  154. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  155. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  156. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  157. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  158. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  159. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  160. data/lib/swarm_sdk/tools/read.rb +0 -261
  161. data/lib/swarm_sdk/tools/registry.rb +0 -205
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  163. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  165. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  166. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  167. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  168. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  169. data/lib/swarm_sdk/tools/think.rb +0 -100
  170. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  171. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  172. data/lib/swarm_sdk/tools/write.rb +0 -112
  173. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  174. data/lib/swarm_sdk/utils.rb +0 -68
  175. data/lib/swarm_sdk/validation_result.rb +0 -33
  176. data/lib/swarm_sdk/version.rb +0 -5
  177. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  178. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  179. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  180. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  181. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  182. data/lib/swarm_sdk/workflow.rb +0 -589
  183. data/lib/swarm_sdk.rb +0 -718
@@ -0,0 +1,489 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ # Orchestrator for the memory system
7
+ #
8
+ # Coordinates all memory components: adapter, embedder, retriever,
9
+ # context builder, ingestion pipeline, compressor, and consolidator.
10
+ # This is the main entry point for Agent to interact with memory.
11
+ #
12
+ # @example
13
+ # store = Store.new(adapter: adapter, embedder: embedder)
14
+ # store.load
15
+ #
16
+ # # Retrieve relevant context for a query
17
+ # messages = store.build_context(query: "auth", recent_turns: turns)
18
+ #
19
+ # # Ingest a new turn
20
+ # store.ingest_turn(text: "We use JWT...", turn_id: "turn_001")
21
+ #
22
+ # store.save
23
+ class Store
24
+ # @return [Adapters::Base] Storage adapter
25
+ attr_reader :adapter
26
+
27
+ # @return [Embedder] Text embedder
28
+ attr_reader :embedder
29
+
30
+ # @return [Retriever] Hybrid search retriever
31
+ attr_reader :retriever
32
+
33
+ # @param adapter [Adapters::Base] Storage adapter
34
+ # @param embedder [Embedder] Text embedder
35
+ # @param retrieval_top_k [Integer] Cards to retrieve per query
36
+ # @param semantic_weight [Float] Semantic search weight
37
+ # @param keyword_weight [Float] Keyword search weight
38
+ # @param chat [RubyLLM::Chat, nil] LLM chat for compression/ingestion
39
+ # @param associative_memory [Boolean] Whether to enable associative memory
40
+ def initialize(adapter:, embedder:, retrieval_top_k: 15, semantic_weight: 0.5, keyword_weight: 0.5, chat: nil,
41
+ associative_memory: false)
42
+ @adapter = adapter
43
+ @embedder = embedder
44
+ @chat = chat
45
+
46
+ @retriever = Retriever.new(
47
+ adapter: adapter,
48
+ embedder: embedder,
49
+ semantic_weight: semantic_weight,
50
+ keyword_weight: keyword_weight,
51
+ )
52
+
53
+ @context_builder = ContextBuilder.new(
54
+ retriever: @retriever,
55
+ adapter: adapter,
56
+ retrieval_top_k: retrieval_top_k,
57
+ embedder: embedder,
58
+ associative_memory: associative_memory,
59
+ )
60
+
61
+ @ingestion_pipeline = IngestionPipeline.new(
62
+ adapter: adapter,
63
+ embedder: embedder,
64
+ chat: chat,
65
+ )
66
+
67
+ @exposure_tracker = ExposureTracker.new(adapter)
68
+
69
+ @compressor = Compressor.new(
70
+ adapter: adapter,
71
+ chat: chat,
72
+ )
73
+
74
+ @consolidator = Consolidator.new(
75
+ adapter: adapter,
76
+ embedder: embedder,
77
+ )
78
+ end
79
+
80
+ # Load memory state from durable storage
81
+ #
82
+ # @return [void]
83
+ def load
84
+ DebugLog.time("store", "adapter.load") { @adapter.load }
85
+ end
86
+
87
+ # Save memory state to durable storage
88
+ #
89
+ # @return [void]
90
+ def save
91
+ DebugLog.time("store", "adapter.save") { @adapter.save }
92
+ end
93
+
94
+ # Build working context for a query
95
+ #
96
+ # @param query [String] Current user query
97
+ # @param recent_turns [Array<Hash>] Recent conversation turns
98
+ # @param system_prompt [String, nil] Agent's system prompt
99
+ # @param read_only [Boolean] When true, skips recording access on retrieved cards
100
+ # @return [Array<Hash>] Messages array for LLM
101
+ def build_context(query:, recent_turns: [], system_prompt: nil, read_only: false)
102
+ @context_builder.build(
103
+ query: query,
104
+ recent_turns: recent_turns,
105
+ system_prompt: system_prompt,
106
+ read_only: read_only,
107
+ )
108
+ end
109
+
110
+ # Ingest a conversation turn into memory
111
+ #
112
+ # @param text [String] Full turn text
113
+ # @param turn_id [String] Unique turn identifier
114
+ # @return [Array<Card>] Created memory cards
115
+ def ingest_turn(text:, turn_id:)
116
+ @ingestion_pipeline.ingest(turn_text: text, turn_id: turn_id)
117
+ end
118
+
119
+ # Search memory for relevant cards
120
+ #
121
+ # @param query [String] Search query
122
+ # @param top_k [Integer] Number of results
123
+ # @return [Array<Card>] Matching cards
124
+ def search(query, top_k: 15)
125
+ @retriever.search(query, top_k: top_k)
126
+ end
127
+
128
+ # Run compression on low-exposure cards
129
+ #
130
+ # @param threshold [Float] Exposure score threshold
131
+ # @return [Integer] Number of cards compressed
132
+ def compress(threshold: 1.0)
133
+ @compressor.compress_low_exposure(
134
+ exposure_tracker: @exposure_tracker,
135
+ threshold: threshold,
136
+ )
137
+ end
138
+
139
+ # Run consolidation (dedup, cluster updates)
140
+ #
141
+ # @return [Hash] Summary of consolidation actions
142
+ def consolidate
143
+ @consolidator.run
144
+ end
145
+
146
+ # Promote compressed cards that have been accessed frequently
147
+ #
148
+ # Cards at L1+ with access_count above the threshold get promoted
149
+ # one level toward L0, rebuilding richer text from graph neighbors.
150
+ #
151
+ # @param access_threshold [Integer] Minimum access count for promotion
152
+ # @return [Integer] Number of cards promoted
153
+ #
154
+ # @example
155
+ # promoted = store.promote(access_threshold: 5)
156
+ def promote(access_threshold: 5)
157
+ @compressor.promote_eligible(access_threshold: access_threshold)
158
+ end
159
+
160
+ # Prune L4 cards with low retention priority
161
+ #
162
+ # Retention priority: R = w1*importance + w2*exposure_score + w3*reuse_potential - w4*size
163
+ # Cards at compression level 4 with R below the threshold are deleted,
164
+ # along with their edges and cluster references.
165
+ #
166
+ # @param threshold [Float] Minimum retention priority to keep (default: 0.3)
167
+ # @param weights [Hash] Weight parameters for retention formula
168
+ # @option weights [Float] :importance Weight for card importance (default: 0.4)
169
+ # @option weights [Float] :exposure Weight for exposure score (default: 0.3)
170
+ # @option weights [Float] :reuse Weight for reuse potential (default: 0.2)
171
+ # @option weights [Float] :size Weight for size penalty (default: 0.1)
172
+ # @return [Integer] Number of cards pruned
173
+ #
174
+ # @example Prune with default settings
175
+ # pruned = store.prune
176
+ #
177
+ # @example Prune with custom threshold
178
+ # pruned = store.prune(threshold: 0.5)
179
+ def prune(threshold: 0.3, weights: {})
180
+ w = {
181
+ importance: 0.4,
182
+ exposure: 0.3,
183
+ reuse: 0.2,
184
+ size: 0.1,
185
+ }.merge(weights)
186
+
187
+ l4_cards = @adapter.list_cards.select { |c| c.compression_level == 4 }
188
+ return 0 if l4_cards.empty?
189
+
190
+ pruned = 0
191
+
192
+ l4_cards.each do |card|
193
+ priority = retention_priority(card, w)
194
+ next if priority >= threshold
195
+
196
+ delete_card_with_cleanup(card.id)
197
+ pruned += 1
198
+ end
199
+
200
+ pruned
201
+ end
202
+
203
+ # Run all memory defragmentation operations
204
+ #
205
+ # Consolidates duplicates, compresses low-exposure cards,
206
+ # promotes frequently-accessed cards, and prunes L4 cards.
207
+ # Saves state after all operations complete.
208
+ #
209
+ # @return [Hash] Summary of defragmentation actions
210
+ #
211
+ # @example
212
+ # result = store.defrag!
213
+ # #=> { duplicates_merged: 0, conflicts_detected: 0,
214
+ # # cards_compressed: 3, cards_promoted: 1, cards_pruned: 0 }
215
+ def defrag!
216
+ result = {}
217
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+
219
+ DebugLog.time("store", "defrag!") do
220
+ # Calculate work items upfront for accurate progress tracking
221
+ all_cards = @adapter.list_cards
222
+ all_clusters = @adapter.list_clusters
223
+
224
+ # Consolidate phase items
225
+ dedup_cards = all_cards.select(&:embedding)
226
+ conflict_types = [:constraint, :decision, :fact]
227
+ conflict_candidates = all_cards.select { |c| conflict_types.include?(c.type) && c.embedding }
228
+
229
+ # Compress phase items (estimate - actual may differ after consolidate)
230
+ compress_candidates = @exposure_tracker.low_exposure_cards(threshold: 1.0)
231
+ compress_eligible = compress_candidates.reject { |c| c.compression_level >= 4 }
232
+
233
+ # Promote phase items
234
+ promote_eligible = all_cards.select do |card|
235
+ card.compression_level >= 1 &&
236
+ card.access_count >= Configuration.instance.promotion_access_threshold
237
+ end
238
+
239
+ # Prune phase items
240
+ prune_candidates = all_cards.select { |c| c.compression_level == 4 }
241
+
242
+ # Build phase breakdown for SDK user
243
+ phases = [
244
+ { name: "consolidate_dedup", items: dedup_cards.size },
245
+ { name: "consolidate_conflicts", items: conflict_candidates.size },
246
+ { name: "consolidate_clusters", items: all_clusters.size },
247
+ { name: "compress", items: compress_eligible.size },
248
+ { name: "promote", items: promote_eligible.size },
249
+ { name: "prune", items: prune_candidates.size },
250
+ ]
251
+ total_items = phases.sum { |p| p[:items] }
252
+
253
+ # Emit start event with full breakdown
254
+ EventStream.emit(
255
+ type: "memory_defrag_start",
256
+ total_items: total_items,
257
+ total_cards: all_cards.size,
258
+ total_clusters: all_clusters.size,
259
+ phases: phases,
260
+ )
261
+
262
+ # Track cumulative progress across phases
263
+ items_completed = 0
264
+
265
+ # Phase 1: Consolidate
266
+ consolidation = DebugLog.time("store", "consolidate") do
267
+ @consolidator.run_with_progress(items_completed, total_items)
268
+ end
269
+ result[:duplicates_merged] = consolidation[:duplicates_merged]
270
+ result[:conflicts_detected] = consolidation[:conflicts_detected]
271
+ result[:clusters_updated] = consolidation[:clusters_updated]
272
+ items_completed += dedup_cards.size + conflict_candidates.size + all_clusters.size
273
+
274
+ # Phase 2: Compress
275
+ result[:cards_compressed] = DebugLog.time("store", "compress") do
276
+ compress_with_progress(threshold: 1.0, base_completed: items_completed, total_items: total_items)
277
+ end
278
+ items_completed += compress_eligible.size
279
+
280
+ # Phase 3: Promote
281
+ result[:cards_promoted] = DebugLog.time("store", "promote") do
282
+ promote_with_progress(
283
+ access_threshold: Configuration.instance.promotion_access_threshold,
284
+ base_completed: items_completed,
285
+ total_items: total_items,
286
+ )
287
+ end
288
+ items_completed += promote_eligible.size
289
+
290
+ # Phase 4: Prune
291
+ result[:cards_pruned] = DebugLog.time("store", "prune") do
292
+ prune_with_progress(base_completed: items_completed, total_items: total_items)
293
+ end
294
+
295
+ # Phase 5: Save
296
+ save
297
+ end
298
+
299
+ # Emit completion event with summary
300
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
301
+ EventStream.emit(
302
+ type: "memory_defrag_complete",
303
+ elapsed_ms: elapsed_ms,
304
+ duplicates_merged: result[:duplicates_merged],
305
+ conflicts_detected: result[:conflicts_detected],
306
+ clusters_updated: result[:clusters_updated],
307
+ cards_compressed: result[:cards_compressed],
308
+ cards_promoted: result[:cards_promoted],
309
+ cards_pruned: result[:cards_pruned],
310
+ )
311
+
312
+ result
313
+ end
314
+
315
+ # Get memory statistics
316
+ #
317
+ # @return [Hash] Memory stats
318
+ def stats
319
+ cards = @adapter.list_cards
320
+ clusters = @adapter.list_clusters
321
+
322
+ {
323
+ total_cards: cards.size,
324
+ total_clusters: clusters.size,
325
+ compression_levels: cards.group_by(&:compression_level).transform_values(&:size),
326
+ card_types: cards.group_by(&:type).transform_values(&:size),
327
+ }
328
+ end
329
+
330
+ private
331
+
332
+ # Calculate retention priority for a card
333
+ #
334
+ # @param card [Card] Card to evaluate
335
+ # @param weights [Hash] Weight parameters
336
+ # @return [Float] Retention priority score
337
+ def retention_priority(card, weights)
338
+ exposure = @exposure_tracker.exposure_score(card)
339
+ reuse = reuse_potential(card)
340
+ size = card.text.split(/\s+/).size / 250.0 # Normalized by max card size
341
+
342
+ weights[:importance] * card.importance +
343
+ weights[:exposure] * [exposure, 1.0].min +
344
+ weights[:reuse] * reuse -
345
+ weights[:size] * size
346
+ end
347
+
348
+ # Estimate reuse potential for a card
349
+ #
350
+ # Based on how "general" the card is (entity connectivity)
351
+ # and whether it's a type that tends to be reused (preferences, constraints).
352
+ #
353
+ # @param card [Card] Card to evaluate
354
+ # @return [Float] Reuse potential (0.0-1.0)
355
+ def reuse_potential(card)
356
+ # Preferences and constraints tend to be reused
357
+ type_boost = [:preference, :constraint].include?(card.type) ? 0.3 : 0.0
358
+
359
+ # More connected cards (via edges) have higher reuse potential
360
+ edge_count = @adapter.edges_for(card.id).size
361
+ connectivity = [edge_count / 5.0, 1.0].min * 0.4
362
+
363
+ # More entities = more general/connected
364
+ entity_score = [card.entities.size / 5.0, 1.0].min * 0.3
365
+
366
+ type_boost + connectivity + entity_score
367
+ end
368
+
369
+ # Delete a card and clean up its edges and cluster references
370
+ #
371
+ # @param card_id [String] Card ID to delete
372
+ # @return [void]
373
+ def delete_card_with_cleanup(card_id)
374
+ @adapter.delete_edges_for(card_id)
375
+
376
+ @adapter.list_clusters.each do |cluster|
377
+ next unless cluster.card_ids.include?(card_id)
378
+
379
+ cluster.remove_card(card_id)
380
+ @adapter.write_cluster(cluster)
381
+ end
382
+
383
+ @adapter.delete_card(card_id)
384
+ end
385
+
386
+ # Compress low-exposure cards with progress events
387
+ #
388
+ # @param threshold [Float] Exposure score threshold
389
+ # @param base_completed [Integer] Items completed before this phase
390
+ # @param total_items [Integer] Total items across all phases
391
+ # @return [Integer] Number of cards compressed
392
+ def compress_with_progress(threshold:, base_completed:, total_items:)
393
+ candidates = @exposure_tracker.low_exposure_cards(threshold: threshold)
394
+ eligible = candidates.reject { |c| c.compression_level >= 4 }
395
+ return 0 if eligible.empty?
396
+
397
+ compressed = 0
398
+ eligible.each_with_index do |card, index|
399
+ @compressor.compress_card(card)
400
+ compressed += 1
401
+
402
+ EventStream.emit(
403
+ type: "memory_defrag_progress",
404
+ phase: "compress",
405
+ description: "Compressing low-exposure cards to save space",
406
+ phase_current: index + 1,
407
+ phase_total: eligible.size,
408
+ overall_current: base_completed + index + 1,
409
+ overall_total: total_items,
410
+ )
411
+ end
412
+
413
+ compressed
414
+ end
415
+
416
+ # Promote eligible cards with progress events
417
+ #
418
+ # @param access_threshold [Integer] Minimum access count for promotion
419
+ # @param base_completed [Integer] Items completed before this phase
420
+ # @param total_items [Integer] Total items across all phases
421
+ # @return [Integer] Number of cards promoted
422
+ def promote_with_progress(access_threshold:, base_completed:, total_items:)
423
+ eligible = @adapter.list_cards.select do |card|
424
+ card.compression_level >= 1 && card.access_count >= access_threshold
425
+ end
426
+ return 0 if eligible.empty?
427
+
428
+ promoted = 0
429
+ eligible.each_with_index do |card, index|
430
+ @compressor.promote_card(card)
431
+ promoted += 1
432
+
433
+ EventStream.emit(
434
+ type: "memory_defrag_progress",
435
+ phase: "promote",
436
+ description: "Promoting frequently-accessed cards to restore detail",
437
+ phase_current: index + 1,
438
+ phase_total: eligible.size,
439
+ overall_current: base_completed + index + 1,
440
+ overall_total: total_items,
441
+ )
442
+ end
443
+
444
+ promoted
445
+ end
446
+
447
+ # Prune L4 cards with progress events
448
+ #
449
+ # @param threshold [Float] Minimum retention priority to keep
450
+ # @param weights [Hash] Weight parameters for retention formula
451
+ # @param base_completed [Integer] Items completed before this phase
452
+ # @param total_items [Integer] Total items across all phases
453
+ # @return [Integer] Number of cards pruned
454
+ def prune_with_progress(threshold: 0.3, weights: {}, base_completed:, total_items:)
455
+ w = {
456
+ importance: 0.4,
457
+ exposure: 0.3,
458
+ reuse: 0.2,
459
+ size: 0.1,
460
+ }.merge(weights)
461
+
462
+ l4_cards = @adapter.list_cards.select { |c| c.compression_level == 4 }
463
+ return 0 if l4_cards.empty?
464
+
465
+ pruned = 0
466
+ l4_cards.each_with_index do |card, index|
467
+ priority = retention_priority(card, w)
468
+ if priority < threshold
469
+ delete_card_with_cleanup(card.id)
470
+ pruned += 1
471
+ end
472
+
473
+ EventStream.emit(
474
+ type: "memory_defrag_progress",
475
+ phase: "prune",
476
+ description: "Removing low-value compressed cards",
477
+ phase_current: index + 1,
478
+ phase_total: l4_cards.size,
479
+ overall_current: base_completed + index + 1,
480
+ overall_total: total_items,
481
+ )
482
+ end
483
+
484
+ pruned
485
+ end
486
+ end
487
+ end
488
+ end
489
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Skills
6
+ # Stateless utility for discovering skills and generating XML metadata
7
+ #
8
+ # Skills are directories containing a SKILL.md file with YAML frontmatter.
9
+ # The loader scans configured directories, parses frontmatter, and produces
10
+ # XML metadata for injection into agent system prompts.
11
+ #
12
+ # @example Scan and format skills
13
+ # manifests = Loader.scan(["/path/to/skills"])
14
+ # xml = Loader.format_xml(manifests)
15
+ # # => "<available_skills>\n <skill>..."
16
+ module Loader
17
+ SKILL_FILENAME = "SKILL.md"
18
+
19
+ class << self
20
+ # Scan directories recursively for SKILL.md files and return parsed manifests
21
+ #
22
+ # SKILL.md is the definitive marker for a skill directory per the
23
+ # agentskills.io spec. Uses Dir.glob with `**/SKILL.md` for fast
24
+ # recursive discovery. Skips missing directories and invalid YAML
25
+ # silently via DebugLog. Deduplicates by absolute SKILL.md path.
26
+ #
27
+ # @param directories [Array<String>] Root paths to scan
28
+ # @return [Array<Manifest>] Discovered skill manifests
29
+ #
30
+ # @example
31
+ # manifests = Loader.scan(["/skills", "/more-skills"])
32
+ # manifests.each { |m| puts "#{m.name}: #{m.description}" }
33
+ def scan(directories)
34
+ seen = {}
35
+
36
+ Array(directories).each do |dir|
37
+ next unless File.directory?(dir)
38
+
39
+ Dir.glob(File.join(dir, "**", SKILL_FILENAME)).sort.each do |skill_path|
40
+ parse_and_register(skill_path, seen)
41
+ end
42
+ end
43
+
44
+ seen.values
45
+ end
46
+
47
+ # Format manifests as XML for system prompt injection
48
+ #
49
+ # Produces an `<available_skills>` XML block following the agentskills.io
50
+ # standard. Values are HTML-escaped for safety.
51
+ #
52
+ # @param manifests [Array<Manifest>] Skill manifests to format
53
+ # @return [String] XML string, or empty string if no manifests
54
+ #
55
+ # @example
56
+ # xml = Loader.format_xml(manifests)
57
+ # # => "<available_skills>\n <skill>\n <name>..."
58
+ def format_xml(manifests)
59
+ return "" if manifests.empty?
60
+
61
+ entries = manifests.map { |m| format_skill_entry(m) }
62
+ skills_block = "<available_skills>\n#{entries.join("\n")}\n</available_skills>"
63
+
64
+ <<~PROMPT.strip
65
+ # Skills
66
+
67
+ You have skills available. Each skill has a name, description, and a SKILL.md file location.
68
+ When a user's task matches a skill description, activate the skill by reading its SKILL.md file to get full instructions. Do NOT read SKILL.md files unless you are actually activating a skill to perform a task. The name and description below are sufficient for listing or discussing available skills.
69
+
70
+ #{skills_block}
71
+ PROMPT
72
+ end
73
+
74
+ private
75
+
76
+ # Parse a SKILL.md and register the manifest if valid
77
+ #
78
+ # @param path [String] Path to SKILL.md
79
+ # @param seen [Hash<String, Manifest>] Dedup registry
80
+ # @return [void]
81
+ def parse_and_register(path, seen)
82
+ absolute = File.expand_path(path)
83
+ return if seen.key?(absolute)
84
+
85
+ manifest = parse_skill_md(absolute)
86
+ seen[absolute] = manifest if manifest
87
+ end
88
+
89
+ # Parse a SKILL.md file into a Manifest
90
+ #
91
+ # Extracts YAML frontmatter between --- delimiters. Requires
92
+ # `name` and `description` fields in the frontmatter.
93
+ #
94
+ # @param path [String] Absolute path to SKILL.md
95
+ # @return [Manifest, nil] Parsed manifest or nil on failure
96
+ def parse_skill_md(path)
97
+ content = File.read(path)
98
+ frontmatter = extract_frontmatter(content)
99
+ return unless frontmatter
100
+
101
+ data = YAML.safe_load(frontmatter)
102
+ return unless data.is_a?(Hash) && data["name"] && data["description"]
103
+
104
+ Manifest.new(
105
+ name: data["name"].to_s,
106
+ description: data["description"].to_s,
107
+ location: path,
108
+ )
109
+ rescue StandardError => e
110
+ DebugLog.log("skills", "Failed to parse #{path}: #{e.message}")
111
+ nil
112
+ end
113
+
114
+ # Extract YAML frontmatter from markdown content
115
+ #
116
+ # @param content [String] File content
117
+ # @return [String, nil] Frontmatter text or nil if not found
118
+ def extract_frontmatter(content)
119
+ return unless content.start_with?("---")
120
+
121
+ parts = content.split("---", 3)
122
+ return if parts.length < 3
123
+
124
+ frontmatter = parts[1].strip
125
+ return if frontmatter.empty?
126
+
127
+ frontmatter
128
+ end
129
+
130
+ # Format a single skill manifest as an XML entry
131
+ #
132
+ # @param manifest [Manifest] Skill manifest
133
+ # @return [String] Indented XML entry
134
+ def format_skill_entry(manifest)
135
+ <<~XML.chomp
136
+ #{" " * 2}<skill>
137
+ #{" " * 4}<name>#{CGI.escapeHTML(manifest.name)}</name>
138
+ #{" " * 4}<description>#{CGI.escapeHTML(manifest.description)}</description>
139
+ #{" " * 4}<location>#{CGI.escapeHTML(manifest.location)}</location>
140
+ #{" " * 2}</skill>
141
+ XML
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Skills
6
+ # Immutable value object for parsed SKILL.md frontmatter
7
+ #
8
+ # Represents a single discovered skill with its metadata.
9
+ # Created by {Loader.scan} from SKILL.md files found on disk.
10
+ #
11
+ # @example
12
+ # manifest = Manifest.new(
13
+ # name: "pdf-processing",
14
+ # description: "Extract text and tables from PDF files",
15
+ # location: "/path/to/pdf-processing/SKILL.md",
16
+ # )
17
+ # manifest.name #=> "pdf-processing"
18
+ # manifest.description #=> "Extract text and tables from PDF files"
19
+ # manifest.location #=> "/path/to/pdf-processing/SKILL.md"
20
+ # manifest.frozen? #=> true
21
+ class Manifest
22
+ # @return [String] Skill name from frontmatter
23
+ attr_reader :name
24
+
25
+ # @return [String] Skill description from frontmatter
26
+ attr_reader :description
27
+
28
+ # @return [String] Absolute path to the SKILL.md file
29
+ attr_reader :location
30
+
31
+ # Create a new skill manifest
32
+ #
33
+ # @param name [String] Skill name
34
+ # @param description [String] Skill description
35
+ # @param location [String] Absolute path to SKILL.md
36
+ def initialize(name:, description:, location:)
37
+ @name = name
38
+ @description = description
39
+ @location = location
40
+ freeze
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end