swarm_sdk 2.7.13 → 3.0.0.alpha1

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