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,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