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