swarm_sdk 2.7.14 → 3.0.0.alpha2

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
  4. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  5. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  6. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  7. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  8. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  9. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  10. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  11. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  12. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  13. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  14. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  15. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  16. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  17. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  18. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  19. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  20. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  24. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  25. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  26. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  27. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  28. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  29. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  30. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  31. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  32. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  33. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  34. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  35. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  36. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  37. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  38. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  39. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  40. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  41. data/lib/swarm_sdk/v3/tools/document_converters/base.rb +84 -0
  42. data/lib/swarm_sdk/v3/tools/document_converters/docx_converter.rb +120 -0
  43. data/lib/swarm_sdk/v3/tools/document_converters/pdf_converter.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/document_converters/xlsx_converter.rb +128 -0
  45. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  46. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  47. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  48. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  49. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  50. data/lib/swarm_sdk/v3/tools/read.rb +213 -0
  51. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  52. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  53. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  54. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  55. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  56. data/lib/swarm_sdk/v3.rb +145 -0
  57. metadata +88 -149
  58. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  59. data/lib/swarm_sdk/agent/builder.rb +0 -705
  60. data/lib/swarm_sdk/agent/chat.rb +0 -1438
  61. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  62. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  63. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  64. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  65. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  66. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  67. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  68. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  69. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  70. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  71. data/lib/swarm_sdk/agent/context.rb +0 -115
  72. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  73. data/lib/swarm_sdk/agent/definition.rb +0 -588
  74. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  75. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
  76. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  77. data/lib/swarm_sdk/agent_registry.rb +0 -146
  78. data/lib/swarm_sdk/builders/base_builder.rb +0 -558
  79. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  80. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
  81. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  82. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  83. data/lib/swarm_sdk/config.rb +0 -368
  84. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  85. data/lib/swarm_sdk/configuration/translator.rb +0 -285
  86. data/lib/swarm_sdk/configuration.rb +0 -165
  87. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  88. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  89. data/lib/swarm_sdk/context_compactor.rb +0 -335
  90. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  91. data/lib/swarm_sdk/context_management/context.rb +0 -328
  92. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  93. data/lib/swarm_sdk/defaults.rb +0 -251
  94. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  95. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  96. data/lib/swarm_sdk/hooks/context.rb +0 -197
  97. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  98. data/lib/swarm_sdk/hooks/error.rb +0 -29
  99. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  100. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  101. data/lib/swarm_sdk/hooks/result.rb +0 -150
  102. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  103. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  104. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  105. data/lib/swarm_sdk/log_collector.rb +0 -227
  106. data/lib/swarm_sdk/log_stream.rb +0 -127
  107. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  108. data/lib/swarm_sdk/model_aliases.json +0 -8
  109. data/lib/swarm_sdk/models.json +0 -44002
  110. data/lib/swarm_sdk/models.rb +0 -161
  111. data/lib/swarm_sdk/node_context.rb +0 -245
  112. data/lib/swarm_sdk/observer/builder.rb +0 -81
  113. data/lib/swarm_sdk/observer/config.rb +0 -45
  114. data/lib/swarm_sdk/observer/manager.rb +0 -248
  115. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  116. data/lib/swarm_sdk/permissions/config.rb +0 -239
  117. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  118. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  119. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  120. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  121. data/lib/swarm_sdk/plugin.rb +0 -309
  122. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  123. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  124. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
  125. data/lib/swarm_sdk/restore_result.rb +0 -65
  126. data/lib/swarm_sdk/result.rb +0 -241
  127. data/lib/swarm_sdk/snapshot.rb +0 -156
  128. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  129. data/lib/swarm_sdk/state_restorer.rb +0 -476
  130. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  131. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  132. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
  133. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  134. data/lib/swarm_sdk/swarm/executor.rb +0 -446
  135. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
  136. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  137. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
  138. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
  139. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  140. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  141. data/lib/swarm_sdk/swarm.rb +0 -973
  142. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  143. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  144. data/lib/swarm_sdk/tools/base.rb +0 -63
  145. data/lib/swarm_sdk/tools/bash.rb +0 -280
  146. data/lib/swarm_sdk/tools/clock.rb +0 -46
  147. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  148. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  149. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  150. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  151. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  152. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  153. data/lib/swarm_sdk/tools/edit.rb +0 -145
  154. data/lib/swarm_sdk/tools/glob.rb +0 -166
  155. data/lib/swarm_sdk/tools/grep.rb +0 -235
  156. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  157. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  158. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  159. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  160. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  161. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  162. data/lib/swarm_sdk/tools/read.rb +0 -261
  163. data/lib/swarm_sdk/tools/registry.rb +0 -205
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  165. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  166. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  167. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  168. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  169. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  170. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  171. data/lib/swarm_sdk/tools/think.rb +0 -100
  172. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  173. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  174. data/lib/swarm_sdk/tools/write.rb +0 -112
  175. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  176. data/lib/swarm_sdk/utils.rb +0 -68
  177. data/lib/swarm_sdk/validation_result.rb +0 -33
  178. data/lib/swarm_sdk/version.rb +0 -5
  179. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  180. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  181. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  182. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  183. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  184. data/lib/swarm_sdk/workflow.rb +0 -589
  185. data/lib/swarm_sdk.rb +0 -721
@@ -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