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.
- checksums.yaml +4 -4
- data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +16 -0
- data/lib/swarm_sdk/ruby_llm_patches/init.rb +4 -1
- data/lib/swarm_sdk/v3/agent.rb +1165 -0
- data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
- data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
- data/lib/swarm_sdk/v3/configuration.rb +490 -0
- data/lib/swarm_sdk/v3/debug_log.rb +86 -0
- data/lib/swarm_sdk/v3/event_stream.rb +130 -0
- data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
- data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
- data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
- data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
- data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
- data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
- data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
- data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
- data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
- data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
- data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
- data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
- data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
- data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
- data/lib/swarm_sdk/v3/memory/card.rb +206 -0
- data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
- data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
- data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
- data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
- data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
- data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
- data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
- data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
- data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
- data/lib/swarm_sdk/v3/memory/store.rb +489 -0
- data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
- data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
- data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
- data/lib/swarm_sdk/v3/tools/base.rb +80 -0
- data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
- data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
- data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
- data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
- data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
- data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
- data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
- data/lib/swarm_sdk/v3/tools/read.rb +181 -0
- data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
- data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
- data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
- data/lib/swarm_sdk/v3/tools/think.rb +88 -0
- data/lib/swarm_sdk/v3/tools/write.rb +87 -0
- data/lib/swarm_sdk/v3.rb +145 -0
- metadata +83 -148
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
- data/lib/swarm_sdk/agent/builder.rb +0 -705
- data/lib/swarm_sdk/agent/chat.rb +0 -1438
- data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
- data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
- data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
- data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
- data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
- data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
- data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
- data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
- data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
- data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
- data/lib/swarm_sdk/agent/context.rb +0 -115
- data/lib/swarm_sdk/agent/context_manager.rb +0 -315
- data/lib/swarm_sdk/agent/definition.rb +0 -588
- data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
- data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -173
- data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
- data/lib/swarm_sdk/agent_registry.rb +0 -146
- data/lib/swarm_sdk/builders/base_builder.rb +0 -558
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
- data/lib/swarm_sdk/concerns/cleanupable.rb +0 -42
- data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
- data/lib/swarm_sdk/concerns/validatable.rb +0 -55
- data/lib/swarm_sdk/config.rb +0 -368
- data/lib/swarm_sdk/configuration/parser.rb +0 -397
- data/lib/swarm_sdk/configuration/translator.rb +0 -285
- data/lib/swarm_sdk/configuration.rb +0 -165
- data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
- data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
- data/lib/swarm_sdk/context_compactor.rb +0 -335
- data/lib/swarm_sdk/context_management/builder.rb +0 -128
- data/lib/swarm_sdk/context_management/context.rb +0 -328
- data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
- data/lib/swarm_sdk/defaults.rb +0 -251
- data/lib/swarm_sdk/events_to_messages.rb +0 -199
- data/lib/swarm_sdk/hooks/adapter.rb +0 -359
- data/lib/swarm_sdk/hooks/context.rb +0 -197
- data/lib/swarm_sdk/hooks/definition.rb +0 -80
- data/lib/swarm_sdk/hooks/error.rb +0 -29
- data/lib/swarm_sdk/hooks/executor.rb +0 -146
- data/lib/swarm_sdk/hooks/registry.rb +0 -147
- data/lib/swarm_sdk/hooks/result.rb +0 -150
- data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
- data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
- data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
- data/lib/swarm_sdk/log_collector.rb +0 -227
- data/lib/swarm_sdk/log_stream.rb +0 -127
- data/lib/swarm_sdk/markdown_parser.rb +0 -75
- data/lib/swarm_sdk/model_aliases.json +0 -8
- data/lib/swarm_sdk/models.json +0 -44002
- data/lib/swarm_sdk/models.rb +0 -161
- data/lib/swarm_sdk/node_context.rb +0 -245
- data/lib/swarm_sdk/observer/builder.rb +0 -81
- data/lib/swarm_sdk/observer/config.rb +0 -45
- data/lib/swarm_sdk/observer/manager.rb +0 -248
- data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
- data/lib/swarm_sdk/permissions/config.rb +0 -239
- data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
- data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
- data/lib/swarm_sdk/permissions/validator.rb +0 -173
- data/lib/swarm_sdk/permissions_builder.rb +0 -122
- data/lib/swarm_sdk/plugin.rb +0 -309
- data/lib/swarm_sdk/plugin_registry.rb +0 -101
- data/lib/swarm_sdk/proc_helpers.rb +0 -53
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -119
- data/lib/swarm_sdk/restore_result.rb +0 -65
- data/lib/swarm_sdk/result.rb +0 -241
- data/lib/swarm_sdk/snapshot.rb +0 -156
- data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
- data/lib/swarm_sdk/state_restorer.rb +0 -476
- data/lib/swarm_sdk/state_snapshot.rb +0 -334
- data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -204
- data/lib/swarm_sdk/swarm/builder.rb +0 -256
- data/lib/swarm_sdk/swarm/executor.rb +0 -446
- data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -162
- data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
- data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -361
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -290
- data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
- data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
- data/lib/swarm_sdk/swarm.rb +0 -973
- data/lib/swarm_sdk/swarm_loader.rb +0 -145
- data/lib/swarm_sdk/swarm_registry.rb +0 -136
- data/lib/swarm_sdk/tools/base.rb +0 -63
- data/lib/swarm_sdk/tools/bash.rb +0 -280
- data/lib/swarm_sdk/tools/clock.rb +0 -46
- data/lib/swarm_sdk/tools/delegate.rb +0 -389
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
- data/lib/swarm_sdk/tools/edit.rb +0 -145
- data/lib/swarm_sdk/tools/glob.rb +0 -166
- data/lib/swarm_sdk/tools/grep.rb +0 -235
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
- data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
- data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
- data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
- data/lib/swarm_sdk/tools/read.rb +0 -261
- data/lib/swarm_sdk/tools/registry.rb +0 -205
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
- data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
- data/lib/swarm_sdk/tools/think.rb +0 -100
- data/lib/swarm_sdk/tools/todo_write.rb +0 -237
- data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
- data/lib/swarm_sdk/tools/write.rb +0 -112
- data/lib/swarm_sdk/transcript_builder.rb +0 -278
- data/lib/swarm_sdk/utils.rb +0 -68
- data/lib/swarm_sdk/validation_result.rb +0 -33
- data/lib/swarm_sdk/version.rb +0 -5
- data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
- data/lib/swarm_sdk/workflow/builder.rb +0 -227
- data/lib/swarm_sdk/workflow/executor.rb +0 -497
- data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
- data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
- data/lib/swarm_sdk/workflow.rb +0 -589
- data/lib/swarm_sdk.rb +0 -721
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Memory
|
|
6
|
+
# Orchestrator for the memory system
|
|
7
|
+
#
|
|
8
|
+
# Coordinates all memory components: adapter, embedder, retriever,
|
|
9
|
+
# context builder, ingestion pipeline, compressor, and consolidator.
|
|
10
|
+
# This is the main entry point for Agent to interact with memory.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# store = Store.new(adapter: adapter, embedder: embedder)
|
|
14
|
+
# store.load
|
|
15
|
+
#
|
|
16
|
+
# # Retrieve relevant context for a query
|
|
17
|
+
# messages = store.build_context(query: "auth", recent_turns: turns)
|
|
18
|
+
#
|
|
19
|
+
# # Ingest a new turn
|
|
20
|
+
# store.ingest_turn(text: "We use JWT...", turn_id: "turn_001")
|
|
21
|
+
#
|
|
22
|
+
# store.save
|
|
23
|
+
class Store
|
|
24
|
+
# @return [Adapters::Base] Storage adapter
|
|
25
|
+
attr_reader :adapter
|
|
26
|
+
|
|
27
|
+
# @return [Embedder] Text embedder
|
|
28
|
+
attr_reader :embedder
|
|
29
|
+
|
|
30
|
+
# @return [Retriever] Hybrid search retriever
|
|
31
|
+
attr_reader :retriever
|
|
32
|
+
|
|
33
|
+
# @param adapter [Adapters::Base] Storage adapter
|
|
34
|
+
# @param embedder [Embedder] Text embedder
|
|
35
|
+
# @param retrieval_top_k [Integer] Cards to retrieve per query
|
|
36
|
+
# @param semantic_weight [Float] Semantic search weight
|
|
37
|
+
# @param keyword_weight [Float] Keyword search weight
|
|
38
|
+
# @param chat [RubyLLM::Chat, nil] LLM chat for compression/ingestion
|
|
39
|
+
# @param associative_memory [Boolean] Whether to enable associative memory
|
|
40
|
+
def initialize(adapter:, embedder:, retrieval_top_k: 15, semantic_weight: 0.5, keyword_weight: 0.5, chat: nil,
|
|
41
|
+
associative_memory: false)
|
|
42
|
+
@adapter = adapter
|
|
43
|
+
@embedder = embedder
|
|
44
|
+
@chat = chat
|
|
45
|
+
|
|
46
|
+
@retriever = Retriever.new(
|
|
47
|
+
adapter: adapter,
|
|
48
|
+
embedder: embedder,
|
|
49
|
+
semantic_weight: semantic_weight,
|
|
50
|
+
keyword_weight: keyword_weight,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@context_builder = ContextBuilder.new(
|
|
54
|
+
retriever: @retriever,
|
|
55
|
+
adapter: adapter,
|
|
56
|
+
retrieval_top_k: retrieval_top_k,
|
|
57
|
+
embedder: embedder,
|
|
58
|
+
associative_memory: associative_memory,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@ingestion_pipeline = IngestionPipeline.new(
|
|
62
|
+
adapter: adapter,
|
|
63
|
+
embedder: embedder,
|
|
64
|
+
chat: chat,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@exposure_tracker = ExposureTracker.new(adapter)
|
|
68
|
+
|
|
69
|
+
@compressor = Compressor.new(
|
|
70
|
+
adapter: adapter,
|
|
71
|
+
chat: chat,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@consolidator = Consolidator.new(
|
|
75
|
+
adapter: adapter,
|
|
76
|
+
embedder: embedder,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Load memory state from durable storage
|
|
81
|
+
#
|
|
82
|
+
# @return [void]
|
|
83
|
+
def load
|
|
84
|
+
DebugLog.time("store", "adapter.load") { @adapter.load }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Save memory state to durable storage
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
def save
|
|
91
|
+
DebugLog.time("store", "adapter.save") { @adapter.save }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build working context for a query
|
|
95
|
+
#
|
|
96
|
+
# @param query [String] Current user query
|
|
97
|
+
# @param recent_turns [Array<Hash>] Recent conversation turns
|
|
98
|
+
# @param system_prompt [String, nil] Agent's system prompt
|
|
99
|
+
# @param read_only [Boolean] When true, skips recording access on retrieved cards
|
|
100
|
+
# @return [Array<Hash>] Messages array for LLM
|
|
101
|
+
def build_context(query:, recent_turns: [], system_prompt: nil, read_only: false)
|
|
102
|
+
@context_builder.build(
|
|
103
|
+
query: query,
|
|
104
|
+
recent_turns: recent_turns,
|
|
105
|
+
system_prompt: system_prompt,
|
|
106
|
+
read_only: read_only,
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Ingest a conversation turn into memory
|
|
111
|
+
#
|
|
112
|
+
# @param text [String] Full turn text
|
|
113
|
+
# @param turn_id [String] Unique turn identifier
|
|
114
|
+
# @return [Array<Card>] Created memory cards
|
|
115
|
+
def ingest_turn(text:, turn_id:)
|
|
116
|
+
@ingestion_pipeline.ingest(turn_text: text, turn_id: turn_id)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Search memory for relevant cards
|
|
120
|
+
#
|
|
121
|
+
# @param query [String] Search query
|
|
122
|
+
# @param top_k [Integer] Number of results
|
|
123
|
+
# @return [Array<Card>] Matching cards
|
|
124
|
+
def search(query, top_k: 15)
|
|
125
|
+
@retriever.search(query, top_k: top_k)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Run compression on low-exposure cards
|
|
129
|
+
#
|
|
130
|
+
# @param threshold [Float] Exposure score threshold
|
|
131
|
+
# @return [Integer] Number of cards compressed
|
|
132
|
+
def compress(threshold: 1.0)
|
|
133
|
+
@compressor.compress_low_exposure(
|
|
134
|
+
exposure_tracker: @exposure_tracker,
|
|
135
|
+
threshold: threshold,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Run consolidation (dedup, cluster updates)
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash] Summary of consolidation actions
|
|
142
|
+
def consolidate
|
|
143
|
+
@consolidator.run
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Promote compressed cards that have been accessed frequently
|
|
147
|
+
#
|
|
148
|
+
# Cards at L1+ with access_count above the threshold get promoted
|
|
149
|
+
# one level toward L0, rebuilding richer text from graph neighbors.
|
|
150
|
+
#
|
|
151
|
+
# @param access_threshold [Integer] Minimum access count for promotion
|
|
152
|
+
# @return [Integer] Number of cards promoted
|
|
153
|
+
#
|
|
154
|
+
# @example
|
|
155
|
+
# promoted = store.promote(access_threshold: 5)
|
|
156
|
+
def promote(access_threshold: 5)
|
|
157
|
+
@compressor.promote_eligible(access_threshold: access_threshold)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Prune L4 cards with low retention priority
|
|
161
|
+
#
|
|
162
|
+
# Retention priority: R = w1*importance + w2*exposure_score + w3*reuse_potential - w4*size
|
|
163
|
+
# Cards at compression level 4 with R below the threshold are deleted,
|
|
164
|
+
# along with their edges and cluster references.
|
|
165
|
+
#
|
|
166
|
+
# @param threshold [Float] Minimum retention priority to keep (default: 0.3)
|
|
167
|
+
# @param weights [Hash] Weight parameters for retention formula
|
|
168
|
+
# @option weights [Float] :importance Weight for card importance (default: 0.4)
|
|
169
|
+
# @option weights [Float] :exposure Weight for exposure score (default: 0.3)
|
|
170
|
+
# @option weights [Float] :reuse Weight for reuse potential (default: 0.2)
|
|
171
|
+
# @option weights [Float] :size Weight for size penalty (default: 0.1)
|
|
172
|
+
# @return [Integer] Number of cards pruned
|
|
173
|
+
#
|
|
174
|
+
# @example Prune with default settings
|
|
175
|
+
# pruned = store.prune
|
|
176
|
+
#
|
|
177
|
+
# @example Prune with custom threshold
|
|
178
|
+
# pruned = store.prune(threshold: 0.5)
|
|
179
|
+
def prune(threshold: 0.3, weights: {})
|
|
180
|
+
w = {
|
|
181
|
+
importance: 0.4,
|
|
182
|
+
exposure: 0.3,
|
|
183
|
+
reuse: 0.2,
|
|
184
|
+
size: 0.1,
|
|
185
|
+
}.merge(weights)
|
|
186
|
+
|
|
187
|
+
l4_cards = @adapter.list_cards.select { |c| c.compression_level == 4 }
|
|
188
|
+
return 0 if l4_cards.empty?
|
|
189
|
+
|
|
190
|
+
pruned = 0
|
|
191
|
+
|
|
192
|
+
l4_cards.each do |card|
|
|
193
|
+
priority = retention_priority(card, w)
|
|
194
|
+
next if priority >= threshold
|
|
195
|
+
|
|
196
|
+
delete_card_with_cleanup(card.id)
|
|
197
|
+
pruned += 1
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
pruned
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Run all memory defragmentation operations
|
|
204
|
+
#
|
|
205
|
+
# Consolidates duplicates, compresses low-exposure cards,
|
|
206
|
+
# promotes frequently-accessed cards, and prunes L4 cards.
|
|
207
|
+
# Saves state after all operations complete.
|
|
208
|
+
#
|
|
209
|
+
# @return [Hash] Summary of defragmentation actions
|
|
210
|
+
#
|
|
211
|
+
# @example
|
|
212
|
+
# result = store.defrag!
|
|
213
|
+
# #=> { duplicates_merged: 0, conflicts_detected: 0,
|
|
214
|
+
# # cards_compressed: 3, cards_promoted: 1, cards_pruned: 0 }
|
|
215
|
+
def defrag!
|
|
216
|
+
result = {}
|
|
217
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
|
|
219
|
+
DebugLog.time("store", "defrag!") do
|
|
220
|
+
# Calculate work items upfront for accurate progress tracking
|
|
221
|
+
all_cards = @adapter.list_cards
|
|
222
|
+
all_clusters = @adapter.list_clusters
|
|
223
|
+
|
|
224
|
+
# Consolidate phase items
|
|
225
|
+
dedup_cards = all_cards.select(&:embedding)
|
|
226
|
+
conflict_types = [:constraint, :decision, :fact]
|
|
227
|
+
conflict_candidates = all_cards.select { |c| conflict_types.include?(c.type) && c.embedding }
|
|
228
|
+
|
|
229
|
+
# Compress phase items (estimate - actual may differ after consolidate)
|
|
230
|
+
compress_candidates = @exposure_tracker.low_exposure_cards(threshold: 1.0)
|
|
231
|
+
compress_eligible = compress_candidates.reject { |c| c.compression_level >= 4 }
|
|
232
|
+
|
|
233
|
+
# Promote phase items
|
|
234
|
+
promote_eligible = all_cards.select do |card|
|
|
235
|
+
card.compression_level >= 1 &&
|
|
236
|
+
card.access_count >= Configuration.instance.promotion_access_threshold
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Prune phase items
|
|
240
|
+
prune_candidates = all_cards.select { |c| c.compression_level == 4 }
|
|
241
|
+
|
|
242
|
+
# Build phase breakdown for SDK user
|
|
243
|
+
phases = [
|
|
244
|
+
{ name: "consolidate_dedup", items: dedup_cards.size },
|
|
245
|
+
{ name: "consolidate_conflicts", items: conflict_candidates.size },
|
|
246
|
+
{ name: "consolidate_clusters", items: all_clusters.size },
|
|
247
|
+
{ name: "compress", items: compress_eligible.size },
|
|
248
|
+
{ name: "promote", items: promote_eligible.size },
|
|
249
|
+
{ name: "prune", items: prune_candidates.size },
|
|
250
|
+
]
|
|
251
|
+
total_items = phases.sum { |p| p[:items] }
|
|
252
|
+
|
|
253
|
+
# Emit start event with full breakdown
|
|
254
|
+
EventStream.emit(
|
|
255
|
+
type: "memory_defrag_start",
|
|
256
|
+
total_items: total_items,
|
|
257
|
+
total_cards: all_cards.size,
|
|
258
|
+
total_clusters: all_clusters.size,
|
|
259
|
+
phases: phases,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Track cumulative progress across phases
|
|
263
|
+
items_completed = 0
|
|
264
|
+
|
|
265
|
+
# Phase 1: Consolidate
|
|
266
|
+
consolidation = DebugLog.time("store", "consolidate") do
|
|
267
|
+
@consolidator.run_with_progress(items_completed, total_items)
|
|
268
|
+
end
|
|
269
|
+
result[:duplicates_merged] = consolidation[:duplicates_merged]
|
|
270
|
+
result[:conflicts_detected] = consolidation[:conflicts_detected]
|
|
271
|
+
result[:clusters_updated] = consolidation[:clusters_updated]
|
|
272
|
+
items_completed += dedup_cards.size + conflict_candidates.size + all_clusters.size
|
|
273
|
+
|
|
274
|
+
# Phase 2: Compress
|
|
275
|
+
result[:cards_compressed] = DebugLog.time("store", "compress") do
|
|
276
|
+
compress_with_progress(threshold: 1.0, base_completed: items_completed, total_items: total_items)
|
|
277
|
+
end
|
|
278
|
+
items_completed += compress_eligible.size
|
|
279
|
+
|
|
280
|
+
# Phase 3: Promote
|
|
281
|
+
result[:cards_promoted] = DebugLog.time("store", "promote") do
|
|
282
|
+
promote_with_progress(
|
|
283
|
+
access_threshold: Configuration.instance.promotion_access_threshold,
|
|
284
|
+
base_completed: items_completed,
|
|
285
|
+
total_items: total_items,
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
items_completed += promote_eligible.size
|
|
289
|
+
|
|
290
|
+
# Phase 4: Prune
|
|
291
|
+
result[:cards_pruned] = DebugLog.time("store", "prune") do
|
|
292
|
+
prune_with_progress(base_completed: items_completed, total_items: total_items)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Phase 5: Save
|
|
296
|
+
save
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Emit completion event with summary
|
|
300
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
301
|
+
EventStream.emit(
|
|
302
|
+
type: "memory_defrag_complete",
|
|
303
|
+
elapsed_ms: elapsed_ms,
|
|
304
|
+
duplicates_merged: result[:duplicates_merged],
|
|
305
|
+
conflicts_detected: result[:conflicts_detected],
|
|
306
|
+
clusters_updated: result[:clusters_updated],
|
|
307
|
+
cards_compressed: result[:cards_compressed],
|
|
308
|
+
cards_promoted: result[:cards_promoted],
|
|
309
|
+
cards_pruned: result[:cards_pruned],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
result
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Get memory statistics
|
|
316
|
+
#
|
|
317
|
+
# @return [Hash] Memory stats
|
|
318
|
+
def stats
|
|
319
|
+
cards = @adapter.list_cards
|
|
320
|
+
clusters = @adapter.list_clusters
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
total_cards: cards.size,
|
|
324
|
+
total_clusters: clusters.size,
|
|
325
|
+
compression_levels: cards.group_by(&:compression_level).transform_values(&:size),
|
|
326
|
+
card_types: cards.group_by(&:type).transform_values(&:size),
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
# Calculate retention priority for a card
|
|
333
|
+
#
|
|
334
|
+
# @param card [Card] Card to evaluate
|
|
335
|
+
# @param weights [Hash] Weight parameters
|
|
336
|
+
# @return [Float] Retention priority score
|
|
337
|
+
def retention_priority(card, weights)
|
|
338
|
+
exposure = @exposure_tracker.exposure_score(card)
|
|
339
|
+
reuse = reuse_potential(card)
|
|
340
|
+
size = card.text.split(/\s+/).size / 250.0 # Normalized by max card size
|
|
341
|
+
|
|
342
|
+
weights[:importance] * card.importance +
|
|
343
|
+
weights[:exposure] * [exposure, 1.0].min +
|
|
344
|
+
weights[:reuse] * reuse -
|
|
345
|
+
weights[:size] * size
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Estimate reuse potential for a card
|
|
349
|
+
#
|
|
350
|
+
# Based on how "general" the card is (entity connectivity)
|
|
351
|
+
# and whether it's a type that tends to be reused (preferences, constraints).
|
|
352
|
+
#
|
|
353
|
+
# @param card [Card] Card to evaluate
|
|
354
|
+
# @return [Float] Reuse potential (0.0-1.0)
|
|
355
|
+
def reuse_potential(card)
|
|
356
|
+
# Preferences and constraints tend to be reused
|
|
357
|
+
type_boost = [:preference, :constraint].include?(card.type) ? 0.3 : 0.0
|
|
358
|
+
|
|
359
|
+
# More connected cards (via edges) have higher reuse potential
|
|
360
|
+
edge_count = @adapter.edges_for(card.id).size
|
|
361
|
+
connectivity = [edge_count / 5.0, 1.0].min * 0.4
|
|
362
|
+
|
|
363
|
+
# More entities = more general/connected
|
|
364
|
+
entity_score = [card.entities.size / 5.0, 1.0].min * 0.3
|
|
365
|
+
|
|
366
|
+
type_boost + connectivity + entity_score
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Delete a card and clean up its edges and cluster references
|
|
370
|
+
#
|
|
371
|
+
# @param card_id [String] Card ID to delete
|
|
372
|
+
# @return [void]
|
|
373
|
+
def delete_card_with_cleanup(card_id)
|
|
374
|
+
@adapter.delete_edges_for(card_id)
|
|
375
|
+
|
|
376
|
+
@adapter.list_clusters.each do |cluster|
|
|
377
|
+
next unless cluster.card_ids.include?(card_id)
|
|
378
|
+
|
|
379
|
+
cluster.remove_card(card_id)
|
|
380
|
+
@adapter.write_cluster(cluster)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
@adapter.delete_card(card_id)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Compress low-exposure cards with progress events
|
|
387
|
+
#
|
|
388
|
+
# @param threshold [Float] Exposure score threshold
|
|
389
|
+
# @param base_completed [Integer] Items completed before this phase
|
|
390
|
+
# @param total_items [Integer] Total items across all phases
|
|
391
|
+
# @return [Integer] Number of cards compressed
|
|
392
|
+
def compress_with_progress(threshold:, base_completed:, total_items:)
|
|
393
|
+
candidates = @exposure_tracker.low_exposure_cards(threshold: threshold)
|
|
394
|
+
eligible = candidates.reject { |c| c.compression_level >= 4 }
|
|
395
|
+
return 0 if eligible.empty?
|
|
396
|
+
|
|
397
|
+
compressed = 0
|
|
398
|
+
eligible.each_with_index do |card, index|
|
|
399
|
+
@compressor.compress_card(card)
|
|
400
|
+
compressed += 1
|
|
401
|
+
|
|
402
|
+
EventStream.emit(
|
|
403
|
+
type: "memory_defrag_progress",
|
|
404
|
+
phase: "compress",
|
|
405
|
+
description: "Compressing low-exposure cards to save space",
|
|
406
|
+
phase_current: index + 1,
|
|
407
|
+
phase_total: eligible.size,
|
|
408
|
+
overall_current: base_completed + index + 1,
|
|
409
|
+
overall_total: total_items,
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
compressed
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Promote eligible cards with progress events
|
|
417
|
+
#
|
|
418
|
+
# @param access_threshold [Integer] Minimum access count for promotion
|
|
419
|
+
# @param base_completed [Integer] Items completed before this phase
|
|
420
|
+
# @param total_items [Integer] Total items across all phases
|
|
421
|
+
# @return [Integer] Number of cards promoted
|
|
422
|
+
def promote_with_progress(access_threshold:, base_completed:, total_items:)
|
|
423
|
+
eligible = @adapter.list_cards.select do |card|
|
|
424
|
+
card.compression_level >= 1 && card.access_count >= access_threshold
|
|
425
|
+
end
|
|
426
|
+
return 0 if eligible.empty?
|
|
427
|
+
|
|
428
|
+
promoted = 0
|
|
429
|
+
eligible.each_with_index do |card, index|
|
|
430
|
+
@compressor.promote_card(card)
|
|
431
|
+
promoted += 1
|
|
432
|
+
|
|
433
|
+
EventStream.emit(
|
|
434
|
+
type: "memory_defrag_progress",
|
|
435
|
+
phase: "promote",
|
|
436
|
+
description: "Promoting frequently-accessed cards to restore detail",
|
|
437
|
+
phase_current: index + 1,
|
|
438
|
+
phase_total: eligible.size,
|
|
439
|
+
overall_current: base_completed + index + 1,
|
|
440
|
+
overall_total: total_items,
|
|
441
|
+
)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
promoted
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Prune L4 cards with progress events
|
|
448
|
+
#
|
|
449
|
+
# @param threshold [Float] Minimum retention priority to keep
|
|
450
|
+
# @param weights [Hash] Weight parameters for retention formula
|
|
451
|
+
# @param base_completed [Integer] Items completed before this phase
|
|
452
|
+
# @param total_items [Integer] Total items across all phases
|
|
453
|
+
# @return [Integer] Number of cards pruned
|
|
454
|
+
def prune_with_progress(threshold: 0.3, weights: {}, base_completed:, total_items:)
|
|
455
|
+
w = {
|
|
456
|
+
importance: 0.4,
|
|
457
|
+
exposure: 0.3,
|
|
458
|
+
reuse: 0.2,
|
|
459
|
+
size: 0.1,
|
|
460
|
+
}.merge(weights)
|
|
461
|
+
|
|
462
|
+
l4_cards = @adapter.list_cards.select { |c| c.compression_level == 4 }
|
|
463
|
+
return 0 if l4_cards.empty?
|
|
464
|
+
|
|
465
|
+
pruned = 0
|
|
466
|
+
l4_cards.each_with_index do |card, index|
|
|
467
|
+
priority = retention_priority(card, w)
|
|
468
|
+
if priority < threshold
|
|
469
|
+
delete_card_with_cleanup(card.id)
|
|
470
|
+
pruned += 1
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
EventStream.emit(
|
|
474
|
+
type: "memory_defrag_progress",
|
|
475
|
+
phase: "prune",
|
|
476
|
+
description: "Removing low-value compressed cards",
|
|
477
|
+
phase_current: index + 1,
|
|
478
|
+
phase_total: l4_cards.size,
|
|
479
|
+
overall_current: base_completed + index + 1,
|
|
480
|
+
overall_total: total_items,
|
|
481
|
+
)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
pruned
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Skills
|
|
6
|
+
# Stateless utility for discovering skills and generating XML metadata
|
|
7
|
+
#
|
|
8
|
+
# Skills are directories containing a SKILL.md file with YAML frontmatter.
|
|
9
|
+
# The loader scans configured directories, parses frontmatter, and produces
|
|
10
|
+
# XML metadata for injection into agent system prompts.
|
|
11
|
+
#
|
|
12
|
+
# @example Scan and format skills
|
|
13
|
+
# manifests = Loader.scan(["/path/to/skills"])
|
|
14
|
+
# xml = Loader.format_xml(manifests)
|
|
15
|
+
# # => "<available_skills>\n <skill>..."
|
|
16
|
+
module Loader
|
|
17
|
+
SKILL_FILENAME = "SKILL.md"
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Scan directories recursively for SKILL.md files and return parsed manifests
|
|
21
|
+
#
|
|
22
|
+
# SKILL.md is the definitive marker for a skill directory per the
|
|
23
|
+
# agentskills.io spec. Uses Dir.glob with `**/SKILL.md` for fast
|
|
24
|
+
# recursive discovery. Skips missing directories and invalid YAML
|
|
25
|
+
# silently via DebugLog. Deduplicates by absolute SKILL.md path.
|
|
26
|
+
#
|
|
27
|
+
# @param directories [Array<String>] Root paths to scan
|
|
28
|
+
# @return [Array<Manifest>] Discovered skill manifests
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# manifests = Loader.scan(["/skills", "/more-skills"])
|
|
32
|
+
# manifests.each { |m| puts "#{m.name}: #{m.description}" }
|
|
33
|
+
def scan(directories)
|
|
34
|
+
seen = {}
|
|
35
|
+
|
|
36
|
+
Array(directories).each do |dir|
|
|
37
|
+
next unless File.directory?(dir)
|
|
38
|
+
|
|
39
|
+
Dir.glob(File.join(dir, "**", SKILL_FILENAME)).sort.each do |skill_path|
|
|
40
|
+
parse_and_register(skill_path, seen)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
seen.values
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Format manifests as XML for system prompt injection
|
|
48
|
+
#
|
|
49
|
+
# Produces an `<available_skills>` XML block following the agentskills.io
|
|
50
|
+
# standard. Values are HTML-escaped for safety.
|
|
51
|
+
#
|
|
52
|
+
# @param manifests [Array<Manifest>] Skill manifests to format
|
|
53
|
+
# @return [String] XML string, or empty string if no manifests
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# xml = Loader.format_xml(manifests)
|
|
57
|
+
# # => "<available_skills>\n <skill>\n <name>..."
|
|
58
|
+
def format_xml(manifests)
|
|
59
|
+
return "" if manifests.empty?
|
|
60
|
+
|
|
61
|
+
entries = manifests.map { |m| format_skill_entry(m) }
|
|
62
|
+
skills_block = "<available_skills>\n#{entries.join("\n")}\n</available_skills>"
|
|
63
|
+
|
|
64
|
+
<<~PROMPT.strip
|
|
65
|
+
# Skills
|
|
66
|
+
|
|
67
|
+
You have skills available. Each skill has a name, description, and a SKILL.md file location.
|
|
68
|
+
When a user's task matches a skill description, activate the skill by reading its SKILL.md file to get full instructions. Do NOT read SKILL.md files unless you are actually activating a skill to perform a task. The name and description below are sufficient for listing or discussing available skills.
|
|
69
|
+
|
|
70
|
+
#{skills_block}
|
|
71
|
+
PROMPT
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Parse a SKILL.md and register the manifest if valid
|
|
77
|
+
#
|
|
78
|
+
# @param path [String] Path to SKILL.md
|
|
79
|
+
# @param seen [Hash<String, Manifest>] Dedup registry
|
|
80
|
+
# @return [void]
|
|
81
|
+
def parse_and_register(path, seen)
|
|
82
|
+
absolute = File.expand_path(path)
|
|
83
|
+
return if seen.key?(absolute)
|
|
84
|
+
|
|
85
|
+
manifest = parse_skill_md(absolute)
|
|
86
|
+
seen[absolute] = manifest if manifest
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse a SKILL.md file into a Manifest
|
|
90
|
+
#
|
|
91
|
+
# Extracts YAML frontmatter between --- delimiters. Requires
|
|
92
|
+
# `name` and `description` fields in the frontmatter.
|
|
93
|
+
#
|
|
94
|
+
# @param path [String] Absolute path to SKILL.md
|
|
95
|
+
# @return [Manifest, nil] Parsed manifest or nil on failure
|
|
96
|
+
def parse_skill_md(path)
|
|
97
|
+
content = File.read(path)
|
|
98
|
+
frontmatter = extract_frontmatter(content)
|
|
99
|
+
return unless frontmatter
|
|
100
|
+
|
|
101
|
+
data = YAML.safe_load(frontmatter)
|
|
102
|
+
return unless data.is_a?(Hash) && data["name"] && data["description"]
|
|
103
|
+
|
|
104
|
+
Manifest.new(
|
|
105
|
+
name: data["name"].to_s,
|
|
106
|
+
description: data["description"].to_s,
|
|
107
|
+
location: path,
|
|
108
|
+
)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
DebugLog.log("skills", "Failed to parse #{path}: #{e.message}")
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Extract YAML frontmatter from markdown content
|
|
115
|
+
#
|
|
116
|
+
# @param content [String] File content
|
|
117
|
+
# @return [String, nil] Frontmatter text or nil if not found
|
|
118
|
+
def extract_frontmatter(content)
|
|
119
|
+
return unless content.start_with?("---")
|
|
120
|
+
|
|
121
|
+
parts = content.split("---", 3)
|
|
122
|
+
return if parts.length < 3
|
|
123
|
+
|
|
124
|
+
frontmatter = parts[1].strip
|
|
125
|
+
return if frontmatter.empty?
|
|
126
|
+
|
|
127
|
+
frontmatter
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Format a single skill manifest as an XML entry
|
|
131
|
+
#
|
|
132
|
+
# @param manifest [Manifest] Skill manifest
|
|
133
|
+
# @return [String] Indented XML entry
|
|
134
|
+
def format_skill_entry(manifest)
|
|
135
|
+
<<~XML.chomp
|
|
136
|
+
#{" " * 2}<skill>
|
|
137
|
+
#{" " * 4}<name>#{CGI.escapeHTML(manifest.name)}</name>
|
|
138
|
+
#{" " * 4}<description>#{CGI.escapeHTML(manifest.description)}</description>
|
|
139
|
+
#{" " * 4}<location>#{CGI.escapeHTML(manifest.location)}</location>
|
|
140
|
+
#{" " * 2}</skill>
|
|
141
|
+
XML
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
module V3
|
|
5
|
+
module Skills
|
|
6
|
+
# Immutable value object for parsed SKILL.md frontmatter
|
|
7
|
+
#
|
|
8
|
+
# Represents a single discovered skill with its metadata.
|
|
9
|
+
# Created by {Loader.scan} from SKILL.md files found on disk.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# manifest = Manifest.new(
|
|
13
|
+
# name: "pdf-processing",
|
|
14
|
+
# description: "Extract text and tables from PDF files",
|
|
15
|
+
# location: "/path/to/pdf-processing/SKILL.md",
|
|
16
|
+
# )
|
|
17
|
+
# manifest.name #=> "pdf-processing"
|
|
18
|
+
# manifest.description #=> "Extract text and tables from PDF files"
|
|
19
|
+
# manifest.location #=> "/path/to/pdf-processing/SKILL.md"
|
|
20
|
+
# manifest.frozen? #=> true
|
|
21
|
+
class Manifest
|
|
22
|
+
# @return [String] Skill name from frontmatter
|
|
23
|
+
attr_reader :name
|
|
24
|
+
|
|
25
|
+
# @return [String] Skill description from frontmatter
|
|
26
|
+
attr_reader :description
|
|
27
|
+
|
|
28
|
+
# @return [String] Absolute path to the SKILL.md file
|
|
29
|
+
attr_reader :location
|
|
30
|
+
|
|
31
|
+
# Create a new skill manifest
|
|
32
|
+
#
|
|
33
|
+
# @param name [String] Skill name
|
|
34
|
+
# @param description [String] Skill description
|
|
35
|
+
# @param location [String] Absolute path to SKILL.md
|
|
36
|
+
def initialize(name:, description:, location:)
|
|
37
|
+
@name = name
|
|
38
|
+
@description = description
|
|
39
|
+
@location = location
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|