swarm_sdk 2.7.14 → 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 (181) 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/edit.rb +111 -0
  42. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  43. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  44. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  45. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  46. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  47. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  48. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  49. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  50. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  51. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  52. data/lib/swarm_sdk/v3.rb +145 -0
  53. metadata +83 -148
  54. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  55. data/lib/swarm_sdk/agent/builder.rb +0 -705
  56. data/lib/swarm_sdk/agent/chat.rb +0 -1438
  57. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  58. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  59. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  60. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  61. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  62. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  63. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  64. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  65. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  66. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  67. data/lib/swarm_sdk/agent/context.rb +0 -115
  68. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  69. data/lib/swarm_sdk/agent/definition.rb +0 -588
  70. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  71. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
  72. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  73. data/lib/swarm_sdk/agent_registry.rb +0 -146
  74. data/lib/swarm_sdk/builders/base_builder.rb +0 -558
  75. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  76. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
  77. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  78. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  79. data/lib/swarm_sdk/config.rb +0 -368
  80. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  81. data/lib/swarm_sdk/configuration/translator.rb +0 -285
  82. data/lib/swarm_sdk/configuration.rb +0 -165
  83. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  84. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  85. data/lib/swarm_sdk/context_compactor.rb +0 -335
  86. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  87. data/lib/swarm_sdk/context_management/context.rb +0 -328
  88. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  89. data/lib/swarm_sdk/defaults.rb +0 -251
  90. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  91. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  92. data/lib/swarm_sdk/hooks/context.rb +0 -197
  93. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  94. data/lib/swarm_sdk/hooks/error.rb +0 -29
  95. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  96. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  97. data/lib/swarm_sdk/hooks/result.rb +0 -150
  98. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  99. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  100. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  101. data/lib/swarm_sdk/log_collector.rb +0 -227
  102. data/lib/swarm_sdk/log_stream.rb +0 -127
  103. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  104. data/lib/swarm_sdk/model_aliases.json +0 -8
  105. data/lib/swarm_sdk/models.json +0 -44002
  106. data/lib/swarm_sdk/models.rb +0 -161
  107. data/lib/swarm_sdk/node_context.rb +0 -245
  108. data/lib/swarm_sdk/observer/builder.rb +0 -81
  109. data/lib/swarm_sdk/observer/config.rb +0 -45
  110. data/lib/swarm_sdk/observer/manager.rb +0 -248
  111. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  112. data/lib/swarm_sdk/permissions/config.rb +0 -239
  113. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  114. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  115. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  116. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  117. data/lib/swarm_sdk/plugin.rb +0 -309
  118. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  119. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  120. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
  121. data/lib/swarm_sdk/restore_result.rb +0 -65
  122. data/lib/swarm_sdk/result.rb +0 -241
  123. data/lib/swarm_sdk/snapshot.rb +0 -156
  124. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  125. data/lib/swarm_sdk/state_restorer.rb +0 -476
  126. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  127. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  128. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
  129. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  130. data/lib/swarm_sdk/swarm/executor.rb +0 -446
  131. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
  132. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  133. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
  134. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
  135. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  136. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  137. data/lib/swarm_sdk/swarm.rb +0 -973
  138. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  139. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  140. data/lib/swarm_sdk/tools/base.rb +0 -63
  141. data/lib/swarm_sdk/tools/bash.rb +0 -280
  142. data/lib/swarm_sdk/tools/clock.rb +0 -46
  143. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  144. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  145. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  146. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  147. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  148. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  149. data/lib/swarm_sdk/tools/edit.rb +0 -145
  150. data/lib/swarm_sdk/tools/glob.rb +0 -166
  151. data/lib/swarm_sdk/tools/grep.rb +0 -235
  152. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  153. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  154. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  155. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  156. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  157. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  158. data/lib/swarm_sdk/tools/read.rb +0 -261
  159. data/lib/swarm_sdk/tools/registry.rb +0 -205
  160. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  161. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  163. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  164. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  165. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  166. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  167. data/lib/swarm_sdk/tools/think.rb +0 -100
  168. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  169. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  170. data/lib/swarm_sdk/tools/write.rb +0 -112
  171. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  172. data/lib/swarm_sdk/utils.rb +0 -68
  173. data/lib/swarm_sdk/validation_result.rb +0 -33
  174. data/lib/swarm_sdk/version.rb +0 -5
  175. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  176. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  177. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  178. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  179. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  180. data/lib/swarm_sdk/workflow.rb +0 -589
  181. data/lib/swarm_sdk.rb +0 -721
@@ -0,0 +1,496 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ # Compression ladder for memory cards
7
+ #
8
+ # Demotes low-exposure cards through compression levels:
9
+ # - L0: Raw (original text)
10
+ # - L1: Faithful summary
11
+ # - L2: Sparse bullets
12
+ # - L3: Schema-only (entities + type)
13
+ # - L4: Embedding + title only
14
+ #
15
+ # Also supports **promotion**: when a compressed card (L1-L4) is
16
+ # accessed frequently, it gets promoted one level up by rebuilding
17
+ # a richer summary from its graph neighbors and cluster context.
18
+ #
19
+ # Uses LLM for L0→L1 and L1→L2 transitions. L3 and L4 are
20
+ # mechanical transformations that don't require LLM calls.
21
+ #
22
+ # @example Compress
23
+ # compressor = Compressor.new(adapter: adapter, chat: llm_chat)
24
+ # compressor.compress_card(card) # Advances one level
25
+ #
26
+ # @example Promote
27
+ # compressor.promote_card(card) # Moves one level up if eligible
28
+ class Compressor
29
+ # Maximum cards per LLM call to stay within token limits
30
+ BATCH_SIZE = 20
31
+
32
+ # @param adapter [Adapters::Base] Storage adapter
33
+ # @param chat [RubyLLM::Chat, nil] LLM chat for summarization (nil = skip LLM steps)
34
+ def initialize(adapter:, chat: nil)
35
+ @adapter = adapter
36
+ @chat = chat
37
+ end
38
+
39
+ # Compress a card one level down the ladder
40
+ #
41
+ # @param card [Card] Card to compress
42
+ # @return [Card] Updated card (also persisted to adapter)
43
+ def compress_card(card)
44
+ case card.compression_level
45
+ when 0 then compress_l0_to_l1(card)
46
+ when 1 then compress_l1_to_l2(card)
47
+ when 2 then compress_l2_to_l3(card)
48
+ when 3 then compress_l3_to_l4(card)
49
+ else card # Already at max compression
50
+ end
51
+ end
52
+
53
+ # Compress all low-exposure cards using batched LLM calls
54
+ #
55
+ # Groups cards by compression level transition and batches LLM calls
56
+ # for L0→L1 and L1→L2. L2→L3 and L3→L4 are mechanical (no LLM).
57
+ #
58
+ # @param exposure_tracker [ExposureTracker] For scoring cards
59
+ # @param threshold [Float] Exposure score threshold
60
+ # @return [Integer] Number of cards compressed
61
+ def compress_low_exposure(exposure_tracker:, threshold: 1.0)
62
+ candidates = exposure_tracker.low_exposure_cards(threshold: threshold)
63
+ eligible = candidates.reject { |c| c.compression_level >= 4 }
64
+ DebugLog.log("compressor", "compress_low_exposure: #{eligible.size}/#{candidates.size} eligible cards")
65
+ return 0 if eligible.empty?
66
+
67
+ # Group by current compression level
68
+ l0_cards = eligible.select { |c| c.compression_level == 0 }
69
+ l1_cards = eligible.select { |c| c.compression_level == 1 }
70
+ l2_cards = eligible.select { |c| c.compression_level == 2 }
71
+ l3_cards = eligible.select { |c| c.compression_level == 3 }
72
+
73
+ count = 0
74
+
75
+ # Batch LLM transitions (L0→L1, L1→L2)
76
+ count += batch_compress_l0_to_l1(l0_cards) if l0_cards.any?
77
+ count += batch_compress_l1_to_l2(l1_cards) if l1_cards.any?
78
+
79
+ # Mechanical transitions (no LLM needed)
80
+ l2_cards.each { |c| compress_l2_to_l3(c) }
81
+ count += l2_cards.size
82
+
83
+ l3_cards.each { |c| compress_l3_to_l4(c) }
84
+ count += l3_cards.size
85
+
86
+ DebugLog.log("compressor", "compressed #{count} cards")
87
+ count
88
+ end
89
+
90
+ # Promote a compressed card one level up
91
+ #
92
+ # When a compressed card is accessed frequently, it should be promoted
93
+ # to recover detail. L4→L3 and L3→L2 are mechanical (rebuild from
94
+ # entities/type). L2→L1 and L1→L0 use LLM to rebuild richer text
95
+ # from graph neighbors and cluster context.
96
+ #
97
+ # @param card [Card] Compressed card (compression_level > 0)
98
+ # @return [Card] Updated card at one level higher, or unchanged if L0
99
+ def promote_card(card)
100
+ case card.compression_level
101
+ when 4 then promote_l4_to_l3(card)
102
+ when 3 then promote_l3_to_l2(card)
103
+ when 2 then promote_l2_to_l1(card)
104
+ when 1 then promote_l1_to_l0(card)
105
+ else card # Already at L0
106
+ end
107
+ end
108
+
109
+ # Promote all eligible compressed cards that have high access
110
+ #
111
+ # Cards at L1+ with access_count above the threshold are promoted
112
+ # one level toward L0. Uses batched LLM calls for L2→L1 and L1→L0
113
+ # promotions.
114
+ #
115
+ # @param access_threshold [Integer] Minimum access count for promotion
116
+ # @return [Integer] Number of cards promoted
117
+ def promote_eligible(access_threshold: 5)
118
+ eligible = @adapter.list_cards.select do |card|
119
+ card.compression_level >= 1 && card.access_count >= access_threshold
120
+ end
121
+ return 0 if eligible.empty?
122
+
123
+ # Group by current compression level
124
+ l4_cards = eligible.select { |c| c.compression_level == 4 }
125
+ l3_cards = eligible.select { |c| c.compression_level == 3 }
126
+ l2_cards = eligible.select { |c| c.compression_level == 2 }
127
+ l1_cards = eligible.select { |c| c.compression_level == 1 }
128
+
129
+ count = 0
130
+
131
+ # Mechanical promotions (L4→L3, L3→L2)
132
+ l4_cards.each { |c| promote_l4_to_l3(c) }
133
+ count += l4_cards.size
134
+
135
+ l3_cards.each { |c| promote_l3_to_l2(c) }
136
+ count += l3_cards.size
137
+
138
+ # Batch LLM promotions (L2→L1, L1→L0)
139
+ count += batch_promote_l2_to_l1(l2_cards) if l2_cards.any?
140
+ count += batch_promote_l1_to_l0(l1_cards) if l1_cards.any?
141
+
142
+ count
143
+ end
144
+
145
+ private
146
+
147
+ # Batch compress L0 cards to L1 using a single LLM call per batch
148
+ #
149
+ # @param cards [Array<Card>] L0 cards to compress
150
+ # @return [Integer] Number of cards compressed
151
+ def batch_compress_l0_to_l1(cards)
152
+ batch_llm_compress(cards, 1, "Summarize each card faithfully in 1-2 sentences.")
153
+ end
154
+
155
+ # Batch compress L1 cards to L2 using a single LLM call per batch
156
+ #
157
+ # @param cards [Array<Card>] L1 cards to compress
158
+ # @return [Integer] Number of cards compressed
159
+ def batch_compress_l1_to_l2(cards)
160
+ batch_llm_compress(cards, 2, "Convert each card to 2-3 bullet points.")
161
+ end
162
+
163
+ # Batch compress cards using XML-tagged LLM calls
164
+ #
165
+ # Groups cards into batches of BATCH_SIZE and makes one LLM call per
166
+ # batch. Falls back to individual compression for any cards missing
167
+ # from the batch response.
168
+ #
169
+ # @param cards [Array<Card>] Cards to compress
170
+ # @param target_level [Integer] Target compression level
171
+ # @param task_description [String] LLM instruction
172
+ # @return [Integer] Number of cards compressed
173
+ def batch_llm_compress(cards, target_level, task_description)
174
+ return cards.each { |c| compress_card(c) }.size unless @chat
175
+
176
+ count = 0
177
+ cards.each_slice(BATCH_SIZE) do |batch|
178
+ results = llm_batch_summarize(batch, task_description)
179
+
180
+ batch.each_with_index do |card, i|
181
+ summary = results[i + 1] # 1-indexed IDs
182
+ if summary
183
+ card.text = summary
184
+ card.compression_level = target_level
185
+ card.updated_at = Time.now
186
+ @adapter.write_card(card)
187
+ else
188
+ # Missing from batch response — fall back to individual
189
+ compress_card(card)
190
+ end
191
+ count += 1
192
+ end
193
+ end
194
+ count
195
+ end
196
+
197
+ # Make a batched LLM call with XML-tagged cards
198
+ #
199
+ # @param cards [Array<Card>] Cards to summarize
200
+ # @param task_description [String] LLM instruction
201
+ # @return [Hash<Integer, String>] Mapping of card ID (1-indexed) to content
202
+ def llm_batch_summarize(cards, task_description)
203
+ @chat.reset_messages!
204
+
205
+ input_cards = cards.each_with_index.map do |card, i|
206
+ "<card id=\"#{i + 1}\">\n#{card.text}\n</card>"
207
+ end.join("\n\n")
208
+
209
+ prompt = <<~PROMPT
210
+ #{task_description}
211
+
212
+ #{input_cards}
213
+
214
+ Return each result inside <card id="N">RESULT</card> tags matching the input IDs.
215
+ PROMPT
216
+
217
+ response = @chat.ask(prompt)
218
+ parse_card_responses(response.content)
219
+ rescue StandardError => e
220
+ EventStream.emit(type: "memory_compression_batch_error", error: "#{e.class}: #{e.message}")
221
+ {} # Return empty hash to trigger individual fallback
222
+ end
223
+
224
+ # Parse <card id="N">content</card> tags from LLM response
225
+ #
226
+ # @param text [String] LLM response text
227
+ # @return [Hash<Integer, String>] Mapping of card ID to content
228
+ def parse_card_responses(text)
229
+ results = {}
230
+ text.scan(%r{<card\s+id="(\d+)">(.*?)</card>}mi) do |id, content|
231
+ results[id.to_i] = content.strip
232
+ end
233
+ results
234
+ end
235
+
236
+ # Batch promote L2 cards to L1 using LLM
237
+ #
238
+ # @param cards [Array<Card>] L2 cards to promote
239
+ # @return [Integer] Number of cards promoted
240
+ def batch_promote_l2_to_l1(cards)
241
+ batch_llm_promote(cards, 1, "Write a faithful 1-2 sentence summary for each card:")
242
+ end
243
+
244
+ # Batch promote L1 cards to L0 using LLM
245
+ #
246
+ # @param cards [Array<Card>] L1 cards to promote
247
+ # @return [Integer] Number of cards promoted
248
+ def batch_promote_l1_to_l0(cards)
249
+ batch_llm_promote(cards, 0, "Expand each card into a detailed paragraph (max 250 words):")
250
+ end
251
+
252
+ # Batch promote cards using XML-tagged LLM calls with neighbor context
253
+ #
254
+ # @param cards [Array<Card>] Cards to promote
255
+ # @param target_level [Integer] Target compression level
256
+ # @param task_description [String] LLM instruction
257
+ # @return [Integer] Number of cards promoted
258
+ def batch_llm_promote(cards, target_level, task_description)
259
+ return cards.each { |c| promote_card(c) }.size unless @chat
260
+
261
+ count = 0
262
+ cards.each_slice(BATCH_SIZE) do |batch|
263
+ results = llm_batch_promote(batch, task_description)
264
+
265
+ batch.each_with_index do |card, i|
266
+ rebuilt = results[i + 1] # 1-indexed IDs
267
+ if rebuilt
268
+ card.text = rebuilt
269
+ card.compression_level = target_level
270
+ card.updated_at = Time.now
271
+ @adapter.write_card(card)
272
+ else
273
+ # Missing from batch response — fall back to individual
274
+ promote_card(card)
275
+ end
276
+ count += 1
277
+ end
278
+ end
279
+ count
280
+ end
281
+
282
+ # Make a batched LLM promotion call with XML-tagged cards and neighbor context
283
+ #
284
+ # @param cards [Array<Card>] Cards to promote
285
+ # @param task_description [String] LLM instruction
286
+ # @return [Hash<Integer, String>] Mapping of card ID (1-indexed) to content
287
+ def llm_batch_promote(cards, task_description)
288
+ @chat.reset_messages!
289
+
290
+ input_cards = cards.each_with_index.map do |card, i|
291
+ neighbor_context = gather_neighbor_context(card)
292
+ context_section = neighbor_context.empty? ? "" : "\nRelated context:\n#{neighbor_context}"
293
+ "<card id=\"#{i + 1}\">\n#{card.text}\nType: #{card.type}\nEntities: #{card.entities.join(", ")}#{context_section}\n</card>"
294
+ end.join("\n\n")
295
+
296
+ prompt = <<~PROMPT
297
+ #{task_description}
298
+
299
+ #{input_cards}
300
+
301
+ Return each result inside <card id="N">RESULT</card> tags matching the input IDs.
302
+ PROMPT
303
+
304
+ response = @chat.ask(prompt)
305
+ parse_card_responses(response.content)
306
+ rescue StandardError => e
307
+ EventStream.emit(type: "memory_promotion_batch_error", error: "#{e.class}: #{e.message}")
308
+ {} # Return empty hash to trigger individual fallback
309
+ end
310
+
311
+ # L0 → L1: Raw text → faithful summary
312
+ #
313
+ # @param card [Card] L0 card
314
+ # @return [Card] Updated card at L1
315
+ def compress_l0_to_l1(card)
316
+ if @chat
317
+ summary = llm_summarize(card.text, "Summarize this faithfully in 1-2 sentences:")
318
+ card.text = summary
319
+ else
320
+ EventStream.emit(
321
+ type: "memory_compression_no_llm",
322
+ card_id: card.id,
323
+ from_level: 0,
324
+ to_level: 1,
325
+ )
326
+ end
327
+ card.compression_level = 1
328
+ card.updated_at = Time.now
329
+ @adapter.write_card(card)
330
+ card
331
+ end
332
+
333
+ # L1 → L2: Summary → sparse bullets
334
+ #
335
+ # @param card [Card] L1 card
336
+ # @return [Card] Updated card at L2
337
+ def compress_l1_to_l2(card)
338
+ if @chat
339
+ bullets = llm_summarize(card.text, "Convert to 2-3 bullet points:")
340
+ card.text = bullets
341
+ else
342
+ EventStream.emit(
343
+ type: "memory_compression_no_llm",
344
+ card_id: card.id,
345
+ from_level: 1,
346
+ to_level: 2,
347
+ )
348
+ end
349
+ card.compression_level = 2
350
+ card.updated_at = Time.now
351
+ @adapter.write_card(card)
352
+ card
353
+ end
354
+
355
+ # L2 → L3: Bullets → schema only (no LLM needed)
356
+ #
357
+ # @param card [Card] L2 card
358
+ # @return [Card] Updated card at L3
359
+ def compress_l2_to_l3(card)
360
+ schema = "Type: #{card.type} | Entities: #{card.entities.join(", ")}"
361
+ card.text = schema
362
+ card.compression_level = 3
363
+ card.updated_at = Time.now
364
+ @adapter.write_card(card)
365
+ card
366
+ end
367
+
368
+ # L3 → L4: Schema → embedding + title only
369
+ #
370
+ # @param card [Card] L3 card
371
+ # @return [Card] Updated card at L4
372
+ def compress_l3_to_l4(card)
373
+ card.text = "[Compressed] #{card.type}: #{card.entities.first || "unknown"}"
374
+ card.compression_level = 4
375
+ card.updated_at = Time.now
376
+ @adapter.write_card(card)
377
+ card
378
+ end
379
+
380
+ # L4 → L3: Rebuild schema from card metadata
381
+ #
382
+ # @param card [Card] L4 card
383
+ # @return [Card] Updated card at L3
384
+ def promote_l4_to_l3(card)
385
+ card.text = "Type: #{card.type} | Entities: #{card.entities.join(", ")}"
386
+ card.compression_level = 3
387
+ card.updated_at = Time.now
388
+ @adapter.write_card(card)
389
+ card
390
+ end
391
+
392
+ # L3 → L2: Rebuild bullets from neighbor context
393
+ #
394
+ # @param card [Card] L3 card
395
+ # @return [Card] Updated card at L2
396
+ def promote_l3_to_l2(card)
397
+ neighbor_context = gather_neighbor_context(card)
398
+ if @chat && !neighbor_context.empty?
399
+ rebuilt = llm_rebuild(card, neighbor_context, "Convert to 2-3 bullet points capturing key details:")
400
+ card.text = rebuilt
401
+ end
402
+ card.compression_level = 2
403
+ card.updated_at = Time.now
404
+ @adapter.write_card(card)
405
+ card
406
+ end
407
+
408
+ # L2 → L1: Rebuild faithful summary from neighbor context
409
+ #
410
+ # @param card [Card] L2 card
411
+ # @return [Card] Updated card at L1
412
+ def promote_l2_to_l1(card)
413
+ neighbor_context = gather_neighbor_context(card)
414
+ if @chat && !neighbor_context.empty?
415
+ rebuilt = llm_rebuild(card, neighbor_context, "Write a faithful 1-2 sentence summary:")
416
+ card.text = rebuilt
417
+ end
418
+ card.compression_level = 1
419
+ card.updated_at = Time.now
420
+ @adapter.write_card(card)
421
+ card
422
+ end
423
+
424
+ # L1 → L0: Rebuild richer text from neighbor context
425
+ #
426
+ # @param card [Card] L1 card
427
+ # @return [Card] Updated card at L0
428
+ def promote_l1_to_l0(card)
429
+ neighbor_context = gather_neighbor_context(card)
430
+ if @chat && !neighbor_context.empty?
431
+ rebuilt = llm_rebuild(card, neighbor_context, "Expand into a detailed paragraph (max 250 words):")
432
+ card.text = rebuilt
433
+ end
434
+ card.compression_level = 0
435
+ card.updated_at = Time.now
436
+ @adapter.write_card(card)
437
+ card
438
+ end
439
+
440
+ # Gather text from 1-hop graph neighbors for rehydration
441
+ #
442
+ # @param card [Card] Card to find neighbors for
443
+ # @return [String] Concatenated neighbor context
444
+ def gather_neighbor_context(card)
445
+ edges = @adapter.edges_for(card.id)
446
+ neighbor_ids = edges.map { |e| e.from_id == card.id ? e.to_id : e.from_id }.uniq
447
+ neighbors = neighbor_ids.take(4).filter_map { |nid| @adapter.read_card(nid) }
448
+ neighbors.map(&:text).join("\n")
449
+ end
450
+
451
+ # Use LLM to rebuild richer text from card + neighbor context
452
+ #
453
+ # @param card [Card] Card being promoted
454
+ # @param neighbor_context [String] Context from neighbors
455
+ # @param instruction [String] Rebuild instruction
456
+ # @return [String] Rebuilt text
457
+ def llm_rebuild(card, neighbor_context, instruction)
458
+ @chat.reset_messages!
459
+ response = @chat.ask(<<~PROMPT)
460
+ #{instruction}
461
+
462
+ Current card (#{card.type}): #{card.text}
463
+ Entities: #{card.entities.join(", ")}
464
+
465
+ Related context from neighboring cards:
466
+ #{neighbor_context}
467
+ PROMPT
468
+ response.content.strip
469
+ rescue StandardError => e
470
+ EventStream.emit(type: "memory_promotion_llm_error", error: "#{e.class}: #{e.message}")
471
+ card.text # Fall back to current text
472
+ end
473
+
474
+ # Use LLM to summarize text
475
+ #
476
+ # Resets the background chat before each call to prevent message
477
+ # accumulation across multiple compression operations.
478
+ #
479
+ # @param text [String] Text to summarize
480
+ # @param instruction [String] Summarization instruction
481
+ # @return [String] Summary
482
+ def llm_summarize(text, instruction)
483
+ @chat.reset_messages!
484
+ response = @chat.ask("#{instruction}\n\n#{text}")
485
+ response.content.strip
486
+ rescue StandardError => e
487
+ EventStream.emit(
488
+ type: "memory_compression_llm_error",
489
+ error: "#{e.class}: #{e.message}",
490
+ )
491
+ text # Fall back to original text on LLM error
492
+ end
493
+ end
494
+ end
495
+ end
496
+ end