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,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ # Assembles working context from memory tiers
7
+ #
8
+ # Combines retrieved memory cards, recent turns (STM buffer),
9
+ # cluster summaries, active constraints, and an exploration sample
10
+ # into a coherent working context for the LLM.
11
+ #
12
+ # Includes an "exploration sprinkle" — 1-2 low-exposure cards that are
13
+ # loosely relevant to the query. This prevents permanent forgetting of
14
+ # rarely-accessed memories by giving them occasional exposure.
15
+ #
16
+ # ## Associative Memory
17
+ #
18
+ # When `associative_memory: true`, exploration cards are labeled under a
19
+ # distinct "YOU ALSO REMEMBER:" section, and a brief guidance is appended
20
+ # after the memory context encouraging the LLM to naturally bring up
21
+ # these tangential memories when the conversation allows — like a person
22
+ # who says "btw, how was Porto?" after discussing Portugal.
23
+ #
24
+ # When `associative_memory: false` (default), exploration cards are
25
+ # formatted identically to retrieved cards. They still serve their
26
+ # anti-forgetting purpose (exposure bumping via `record_access!`),
27
+ # but the LLM is not encouraged to surface them conversationally.
28
+ #
29
+ # @example
30
+ # builder = ContextBuilder.new(retriever: retriever, adapter: adapter)
31
+ # context = builder.build(query: "How does auth work?", recent_turns: messages)
32
+ class ContextBuilder
33
+ # @param retriever [Retriever] Hybrid search retriever
34
+ # @param adapter [Adapters::Base] Storage adapter
35
+ # @param retrieval_top_k [Integer] Cards to retrieve per query
36
+ # @param embedder [Embedder, nil] Embedder for exploration similarity
37
+ # @param associative_memory [Boolean] Whether to label exploration cards distinctly
38
+ def initialize(retriever:, adapter:, retrieval_top_k: 15, embedder: nil, associative_memory: false)
39
+ @retriever = retriever
40
+ @adapter = adapter
41
+ @retrieval_top_k = retrieval_top_k
42
+ @embedder = embedder
43
+ @associative_memory = associative_memory
44
+ @config = Configuration.instance
45
+ end
46
+
47
+ # Build working context for a query
48
+ #
49
+ # @param query [String] Current user query
50
+ # @param recent_turns [Array<Hash>] Recent conversation turns (STM)
51
+ # @param system_prompt [String, nil] Agent's system prompt
52
+ # @param read_only [Boolean] When true, skips recording access on retrieved cards
53
+ # @return [Array<Hash>] Messages array ready for LLM
54
+ #
55
+ # @example
56
+ # messages = builder.build(
57
+ # query: "What auth system are we using?",
58
+ # recent_turns: last_8_turns,
59
+ # system_prompt: "You are a backend developer.",
60
+ # )
61
+ #
62
+ # @example Read-only mode (for subtasks)
63
+ # messages = builder.build(
64
+ # query: "Check auth approach",
65
+ # read_only: true,
66
+ # )
67
+ def build(query:, recent_turns: [], system_prompt: nil, read_only: false)
68
+ DebugLog.log("context_builder", "build: query=#{query[0..60].inspect}")
69
+
70
+ retrieved_cards = DebugLog.time("context_builder", "retriever.search(top_k=#{@retrieval_top_k})") do
71
+ @retriever.search(query, top_k: @retrieval_top_k)
72
+ end
73
+
74
+ exploration_cards = DebugLog.time("context_builder", "find_exploration_cards") do
75
+ find_exploration_cards(query, retrieved_cards)
76
+ end
77
+ all_cards = retrieved_cards + exploration_cards
78
+
79
+ relevant_clusters = find_relevant_clusters(all_cards)
80
+ active_constraints = find_active_constraints(all_cards)
81
+
82
+ all_cards = DebugLog.time("context_builder", "deduplicate_cards(#{all_cards.size})") do
83
+ deduplicate_cards(all_cards)
84
+ end
85
+
86
+ DebugLog.log("context_builder", "retrieved=#{retrieved_cards.size} exploration=#{exploration_cards.size} deduped=#{all_cards.size} clusters=#{relevant_clusters.size} constraints=#{active_constraints.size}")
87
+
88
+ # Record access on all cards included in context (skip in read-only mode)
89
+ unless read_only
90
+ all_cards.each do |card|
91
+ card.record_access!
92
+ @adapter.write_card(card)
93
+ end
94
+ end
95
+
96
+ messages = []
97
+
98
+ # Build memory context from all tiers
99
+ memory_context = format_memory_context(
100
+ retrieved_cards, exploration_cards, relevant_clusters, active_constraints
101
+ )
102
+
103
+ # System prompt with memory context
104
+ if system_prompt
105
+ system_content = system_prompt
106
+ system_content = "#{system_content}\n\n#{memory_context}" unless memory_context.empty?
107
+ messages << { role: "system", content: system_content }
108
+ elsif !memory_context.empty?
109
+ messages << { role: "system", content: memory_context }
110
+ end
111
+
112
+ # Recent turns (STM buffer)
113
+ messages.concat(recent_turns)
114
+
115
+ messages
116
+ end
117
+
118
+ private
119
+
120
+ # Deduplicate cards for working context
121
+ #
122
+ # Removes near-duplicate cards by:
123
+ # 1. Preferring canonical cards over merged duplicates
124
+ # 2. Using the adapter's similarity method to detect near-duplicates (>0.92)
125
+ #
126
+ # All similarity computation is delegated to the adapter so that
127
+ # storage backends like pgvector can compute it server-side.
128
+ #
129
+ # @param cards [Array<Card>] Cards to deduplicate
130
+ # @return [Array<Card>] Deduplicated cards
131
+ def deduplicate_cards(cards)
132
+ return cards if cards.size <= 1
133
+
134
+ # Prefer canonical cards — skip cards that have been merged into another
135
+ cards = cards.reject { |c| c.canonical_id && cards.any? { |other| other.id == c.canonical_id } }
136
+
137
+ # Remove near-duplicates by embedding similarity (via adapter)
138
+ kept = []
139
+ cards.each do |card|
140
+ duplicate = kept.any? do |existing|
141
+ next false unless card.embedding && existing.embedding
142
+
143
+ @adapter.similarity(card.embedding, existing.embedding) > @config.dedup_similarity_threshold
144
+ end
145
+ kept << card unless duplicate
146
+ end
147
+
148
+ kept
149
+ end
150
+
151
+ # Find low-exposure cards loosely relevant to the query
152
+ #
153
+ # Prevents permanent forgetting by occasionally surfacing rarely-accessed
154
+ # memories that have some relevance to the current query.
155
+ #
156
+ # Uses the adapter's vector_search to find loosely relevant cards among
157
+ # the low-exposure pool, keeping similarity computation in the adapter.
158
+ #
159
+ # @param query [String] Current query
160
+ # @param retrieved_cards [Array<Card>] Already-retrieved cards to exclude
161
+ # @return [Array<Card>] Exploration cards (0-2)
162
+ def find_exploration_cards(query, retrieved_cards)
163
+ return [] unless @embedder
164
+
165
+ retrieved_ids = Set.new(retrieved_cards.map(&:id))
166
+ all_cards = @adapter.list_cards.reject { |c| retrieved_ids.include?(c.id) || c.embedding.nil? }
167
+ return [] if all_cards.empty?
168
+
169
+ # Sort by exposure (ascending) — least-exposed first
170
+ tracker = ExposureTracker.new(@adapter)
171
+ scored = all_cards.map { |c| { card: c, exposure: tracker.exposure_score(c) } }
172
+ scored.sort_by! { |s| s[:exposure] }
173
+
174
+ # From the least-exposed cards, pick ones with minimum similarity to query
175
+ query_embedding = @embedder.embed(query)
176
+ candidates = scored.take([scored.size, 20].min).filter_map do |entry|
177
+ sim = @adapter.similarity(query_embedding, entry[:card].embedding)
178
+ next if sim < @config.exploration_min_similarity
179
+
180
+ { card: entry[:card], similarity: sim }
181
+ end
182
+
183
+ candidates.sort_by { |c| -c[:similarity] }
184
+ .take(@config.exploration_sample_size)
185
+ .map { |c| c[:card] }
186
+ end
187
+
188
+ # Rehydrate a compressed card (L3/L4) with neighbor and cluster context
189
+ #
190
+ # Pulls 1-hop neighbors via the adapter and includes relevant cluster
191
+ # summaries to reconstruct richer context from compressed traces.
192
+ #
193
+ # @param card [Card] Compressed card (compression_level >= 3)
194
+ # @param clusters [Array<Cluster>] Relevant clusters already found
195
+ # @return [String] Rehydrated context string
196
+ def rehydrate_card(card, clusters)
197
+ parts = ["[Rehydrated] [#{card.type.to_s.capitalize}] #{card.text}"]
198
+
199
+ # Pull 1-hop neighbors for additional context
200
+ neighbors = collect_neighbors(card.id)
201
+ unless neighbors.empty?
202
+ neighbor_texts = neighbors.take(3).map { |n| " - #{n.text[0..150]}" }
203
+ parts << " Related context:"
204
+ parts.concat(neighbor_texts)
205
+ end
206
+
207
+ # Include cluster summary if available
208
+ card_cluster = clusters.find { |c| c.card_ids.include?(card.id) }
209
+ if card_cluster && !card_cluster.rolling_summary.empty?
210
+ parts << " Topic: #{card_cluster.title} — #{card_cluster.rolling_summary[0..200]}"
211
+ end
212
+
213
+ parts.join("\n")
214
+ end
215
+
216
+ # Collect 1-hop neighbor cards via edges
217
+ #
218
+ # @param card_id [String] Card ID to find neighbors for
219
+ # @return [Array<Card>] Neighbor cards (excluding compressed L4 cards)
220
+ def collect_neighbors(card_id)
221
+ edges = @adapter.edges_for(card_id)
222
+ neighbor_ids = edges.map do |edge|
223
+ edge.from_id == card_id ? edge.to_id : edge.from_id
224
+ end.uniq
225
+
226
+ neighbor_ids.filter_map do |nid|
227
+ card = @adapter.read_card(nid)
228
+ card if card && card.compression_level < 4
229
+ end
230
+ end
231
+
232
+ # Find clusters relevant to the retrieved cards
233
+ #
234
+ # @param cards [Array<Card>] Retrieved cards
235
+ # @return [Array<Cluster>] Relevant clusters
236
+ def find_relevant_clusters(cards)
237
+ return [] if cards.empty?
238
+
239
+ card_ids = Set.new(cards.map(&:id))
240
+ @adapter.list_clusters
241
+ .select { |c| c.card_ids.any? { |id| card_ids.include?(id) } }
242
+ .reject { |c| c.rolling_summary.empty? }
243
+ end
244
+
245
+ # Find active constraints not already in the retrieved cards
246
+ #
247
+ # Constraints (type: :constraint) with high importance (>=0.7) should
248
+ # appear in every context build to prevent accidental violation.
249
+ # Only high-importance constraints are always surfaced; lower-importance
250
+ # ones must be found through retrieval.
251
+ #
252
+ # @param already_retrieved [Array<Card>] Cards already in context
253
+ # @return [Array<Card>] Active constraint cards
254
+ def find_active_constraints(already_retrieved)
255
+ retrieved_ids = Set.new(already_retrieved.map(&:id))
256
+
257
+ @adapter.list_cards
258
+ .select { |c| c.type == :constraint && c.importance >= 0.7 && !retrieved_ids.include?(c.id) }
259
+ .sort_by { |c| -c.importance }
260
+ .take(3)
261
+ end
262
+
263
+ # Format all memory tiers into a single context string
264
+ #
265
+ # Includes: active constraints, retrieved cards, cluster summaries,
266
+ # and exploration sprinkle — in that priority order.
267
+ #
268
+ # @param retrieved [Array<Card>] Retrieved cards
269
+ # @param exploration [Array<Card>] Exploration sprinkle cards
270
+ # @param clusters [Array<Cluster>] Relevant clusters
271
+ # @param constraints [Array<Card>] Active constraint cards
272
+ # @return [String] Formatted memory context
273
+ def format_memory_context(retrieved, exploration = [], clusters = [], constraints = [])
274
+ has_content = !retrieved.empty? || !exploration.empty? || !clusters.empty? || !constraints.empty?
275
+ return "" unless has_content
276
+
277
+ sections = []
278
+
279
+ # Active constraints first (highest priority)
280
+ unless constraints.empty?
281
+ sections << "ACTIVE CONSTRAINTS:"
282
+ constraints.each { |c| sections << "- #{c.text}" }
283
+ sections << ""
284
+ end
285
+
286
+ # Retrieved cards (rehydrate compressed ones)
287
+ retrieved.each do |card|
288
+ sections << if card.compression_level >= 3
289
+ rehydrate_card(card, clusters)
290
+ else
291
+ "[#{card.type.to_s.capitalize}] #{card.text}"
292
+ end
293
+ end
294
+
295
+ # Exploration sprinkle
296
+ unless exploration.empty?
297
+ if @associative_memory
298
+ sections << ""
299
+ sections << "YOU ALSO REMEMBER:"
300
+ end
301
+ exploration.each do |card|
302
+ sections << "[#{card.type.to_s.capitalize}] #{card.text}"
303
+ end
304
+ end
305
+
306
+ # Cluster summaries
307
+ unless clusters.empty?
308
+ sections << ""
309
+ sections << "TOPIC SUMMARIES:"
310
+ clusters.each do |cluster|
311
+ sections << "#{cluster.title}: #{cluster.rolling_summary}"
312
+ next if cluster.decision_log.empty?
313
+
314
+ sections << "Key decisions: #{cluster.decision_log.join("; ")}"
315
+ end
316
+ end
317
+
318
+ context = <<~CONTEXT.strip
319
+ <memory-context>
320
+ The following information was retrieved from your memory:
321
+
322
+ #{sections.join("\n")}
323
+ </memory-context>
324
+ CONTEXT
325
+
326
+ if @associative_memory && !exploration.empty?
327
+ context = "#{context}\n\n" \
328
+ "The \"YOU ALSO REMEMBER\" section contains tangential memories. " \
329
+ "Only bring these up when the conversation is open-ended or exploratory. " \
330
+ "Never mention them during task-focused work like writing code, debugging, " \
331
+ "or following specific instructions."
332
+ end
333
+
334
+ context
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ # A typed relationship between two memory cards
7
+ #
8
+ # Edges form a knowledge graph connecting related cards.
9
+ # They enable graph-based retrieval — when a card is relevant,
10
+ # its neighbors can be pulled in for richer context.
11
+ #
12
+ # @example Create a dependency edge
13
+ # edge = Edge.new(
14
+ # from_id: "card_a1b2c3",
15
+ # to_id: "card_d4e5f6",
16
+ # type: :depends_on,
17
+ # weight: 0.8,
18
+ # )
19
+ class Edge
20
+ TYPES = [
21
+ :elaborates,
22
+ :depends_on,
23
+ :supports,
24
+ :contradicts,
25
+ :same_entity,
26
+ :same_episode,
27
+ :decision_reason,
28
+ ].freeze
29
+
30
+ # @return [String] Source card ID
31
+ attr_reader :from_id
32
+
33
+ # @return [String] Target card ID
34
+ attr_reader :to_id
35
+
36
+ # @return [Symbol] Relationship type
37
+ attr_reader :type
38
+
39
+ # @return [Float] Edge weight (0.0-1.0)
40
+ attr_reader :weight
41
+
42
+ # @return [Time] Creation timestamp
43
+ attr_reader :created_at
44
+
45
+ # Create a new edge
46
+ #
47
+ # @param from_id [String] Source card ID
48
+ # @param to_id [String] Target card ID
49
+ # @param type [Symbol] Relationship type
50
+ # @param weight [Float] Edge weight (0.0-1.0)
51
+ # @param created_at [Time, nil] Creation time
52
+ #
53
+ # @raise [ArgumentError] If type is invalid
54
+ def initialize(from_id:, to_id:, type:, weight: 1.0, created_at: nil)
55
+ @from_id = from_id
56
+ @to_id = to_id
57
+ @type = type.to_sym
58
+ @weight = weight.to_f.clamp(0.0, 1.0)
59
+ @created_at = created_at || Time.now
60
+
61
+ validate!
62
+ end
63
+
64
+ # Serialize to a hash
65
+ #
66
+ # @return [Hash]
67
+ def to_h
68
+ {
69
+ from_id: @from_id,
70
+ to_id: @to_id,
71
+ type: @type.to_s,
72
+ weight: @weight,
73
+ created_at: @created_at.iso8601,
74
+ }
75
+ end
76
+
77
+ class << self
78
+ # Deserialize from a hash
79
+ #
80
+ # @param hash [Hash] Serialized edge data
81
+ # @return [Edge]
82
+ def from_h(hash)
83
+ hash = hash.transform_keys(&:to_sym)
84
+ new(
85
+ from_id: hash[:from_id],
86
+ to_id: hash[:to_id],
87
+ type: hash[:type]&.to_sym,
88
+ weight: hash[:weight] || 1.0,
89
+ created_at: hash[:created_at] ? Time.parse(hash[:created_at]) : nil,
90
+ )
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # @raise [ArgumentError] If validation fails
97
+ def validate!
98
+ raise ArgumentError, "from_id is required" if @from_id.nil? || @from_id.strip.empty?
99
+ raise ArgumentError, "to_id is required" if @to_id.nil? || @to_id.strip.empty?
100
+ raise ArgumentError, "Invalid edge type: #{@type}. Must be one of: #{TYPES.join(", ")}" unless TYPES.include?(@type)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ # ONNX-based text embedder using Informers gem
7
+ #
8
+ # Uses the `Informers.pipeline("embedding", ...)` API to generate
9
+ # sentence embeddings via ONNX inference. Lazy-loads the model on
10
+ # first use to avoid startup cost.
11
+ #
12
+ # Model name and cache directory are read from {Configuration},
13
+ # with environment variable overrides.
14
+ #
15
+ # The model is automatically downloaded on first use if not cached.
16
+ # Use {#preload!} to trigger an eager download before first embedding.
17
+ #
18
+ # @example Basic usage
19
+ # embedder = Embedder.new
20
+ # vector = embedder.embed("The API uses JWT tokens")
21
+ # vector.length #=> 384
22
+ #
23
+ # @example Eager download
24
+ # embedder = Embedder.new
25
+ # embedder.preload! # Downloads model if not cached
26
+ #
27
+ # @example Custom model via environment variable
28
+ # ENV["SWARM_EMBEDDING_MODEL"] = "sentence-transformers/all-MiniLM-L6-v2"
29
+ # embedder = Embedder.new
30
+ class Embedder
31
+ DIMENSIONS = 384
32
+
33
+ # Default embedding model (QA-optimized, 384 dimensions)
34
+ DEFAULT_MODEL = "sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
35
+
36
+ # Environment variable for model override
37
+ MODEL_ENV_VAR = "SWARM_EMBEDDING_MODEL"
38
+
39
+ # Number of dimensions in the embedding vector
40
+ #
41
+ # @return [Integer]
42
+ def dimensions
43
+ DIMENSIONS
44
+ end
45
+
46
+ # The model name used for embeddings
47
+ #
48
+ # Resolution order:
49
+ # 1. SWARM_EMBEDDING_MODEL environment variable
50
+ # 2. Configuration.instance.embedding_model
51
+ # 3. DEFAULT_MODEL constant
52
+ #
53
+ # @return [String] Sentence-transformer model identifier
54
+ def model_name
55
+ ENV[MODEL_ENV_VAR] || Configuration.instance.embedding_model || DEFAULT_MODEL
56
+ end
57
+
58
+ # Generate an embedding vector for text
59
+ #
60
+ # Lazy-loads the model on first call. The model will be downloaded
61
+ # automatically if not cached locally.
62
+ #
63
+ # @param text [String] Text to embed
64
+ # @return [Array<Float>] Embedding vector (384 dimensions)
65
+ # @raise [MemoryError] If model loading or embedding fails
66
+ #
67
+ # @example
68
+ # vector = embedder.embed("Hello world")
69
+ # vector.length #=> 384
70
+ def embed(text)
71
+ pipeline.call(text)
72
+ rescue StandardError => e
73
+ raise MemoryError, "Embedding failed for text (#{text.length} chars): #{e.message}"
74
+ end
75
+
76
+ # Generate embeddings for multiple texts
77
+ #
78
+ # More efficient than calling embed() in a loop because the
79
+ # pipeline can batch internally.
80
+ #
81
+ # @param texts [Array<String>] Texts to embed
82
+ # @return [Array<Array<Float>>] Embedding vectors
83
+ # @raise [MemoryError] If embedding fails
84
+ def embed_batch(texts)
85
+ return [] if texts.empty?
86
+
87
+ pipeline.call(texts)
88
+ rescue StandardError => e
89
+ raise MemoryError, "Batch embedding failed (#{texts.size} texts): #{e.message}"
90
+ end
91
+
92
+ # Eagerly download and load the embedding model
93
+ #
94
+ # Triggers model download if not already cached. Useful for
95
+ # ensuring the model is available before first use. This is a
96
+ # one-time download of ~90MB.
97
+ #
98
+ # @return [void]
99
+ # @raise [MemoryError] If model download or loading fails
100
+ #
101
+ # @example
102
+ # embedder = Embedder.new
103
+ # embedder.preload! # Downloads model files (~90MB)
104
+ def preload!
105
+ pipeline
106
+ nil
107
+ rescue StandardError => e
108
+ raise MemoryError, "Failed to preload embedding model '#{model_name}': #{e.message}"
109
+ end
110
+
111
+ # Check if the embedding model is cached locally
112
+ #
113
+ # Verifies that the model directory exists and contains the
114
+ # required tokenizer and ONNX model files.
115
+ #
116
+ # @return [Boolean] true if model files exist on disk
117
+ #
118
+ # @example
119
+ # embedder = Embedder.new
120
+ # embedder.cached? #=> true (if already downloaded)
121
+ def cached?
122
+ cache_dir = resolve_cache_dir
123
+ model_dir = File.join(cache_dir, model_name)
124
+ return false unless File.directory?(model_dir)
125
+
126
+ # Check for required model files
127
+ tokenizer = File.join(model_dir, "tokenizer.json")
128
+ onnx_model = File.join(model_dir, "onnx", "model.onnx")
129
+ File.exist?(tokenizer) && File.exist?(onnx_model)
130
+ end
131
+
132
+ private
133
+
134
+ # Lazy-load the Informers embedding pipeline
135
+ #
136
+ # Uses `Informers.pipeline("embedding", model_name)` — the current
137
+ # recommended API. The pipeline handles model downloading, tokenization,
138
+ # and ONNX inference internally.
139
+ #
140
+ # @return [Informers::EmbeddingPipeline] Lazy-loaded pipeline
141
+ def pipeline
142
+ @pipeline ||= begin
143
+ require "informers"
144
+ configure_cache_dir!
145
+ Informers.pipeline("embedding", model_name)
146
+ rescue LoadError
147
+ raise MemoryError,
148
+ "Informers gem is not available. Install with: gem install informers"
149
+ rescue StandardError => e
150
+ raise MemoryError,
151
+ "Failed to load embedding model '#{model_name}': #{e.message}"
152
+ end
153
+ end
154
+
155
+ # Configure Informers cache directory if set in configuration
156
+ #
157
+ # Sets the global Informers.cache_dir which affects all
158
+ # subsequent model loading.
159
+ #
160
+ # @return [void]
161
+ def configure_cache_dir!
162
+ cache_dir = Configuration.instance.embedding_cache_dir
163
+ return unless cache_dir
164
+
165
+ FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)
166
+ Informers.cache_dir = cache_dir
167
+ end
168
+
169
+ # Resolve the cache directory for checking model presence
170
+ #
171
+ # Uses the configured cache dir, falls back to the XDG default
172
+ # that Informers uses internally.
173
+ #
174
+ # @return [String] Path to cache directory
175
+ def resolve_cache_dir
176
+ Configuration.instance.embedding_cache_dir ||
177
+ File.join(
178
+ ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache")),
179
+ "informers",
180
+ )
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end