swarm_sdk 2.7.13 → 3.0.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. checksums.yaml +4 -4
  2. data/lib/swarm_sdk/ruby_llm_patches/chat_callbacks_patch.rb +43 -22
  3. data/lib/swarm_sdk/ruby_llm_patches/init.rb +6 -0
  4. data/lib/swarm_sdk/ruby_llm_patches/mcp_ssl_patch.rb +144 -0
  5. data/lib/swarm_sdk/ruby_llm_patches/tool_concurrency_patch.rb +3 -4
  6. data/lib/swarm_sdk/v3/agent.rb +1165 -0
  7. data/lib/swarm_sdk/v3/agent_builder.rb +533 -0
  8. data/lib/swarm_sdk/v3/agent_definition.rb +330 -0
  9. data/lib/swarm_sdk/v3/configuration.rb +490 -0
  10. data/lib/swarm_sdk/v3/debug_log.rb +86 -0
  11. data/lib/swarm_sdk/v3/event_stream.rb +130 -0
  12. data/lib/swarm_sdk/v3/hooks/context.rb +112 -0
  13. data/lib/swarm_sdk/v3/hooks/result.rb +115 -0
  14. data/lib/swarm_sdk/v3/hooks/runner.rb +128 -0
  15. data/lib/swarm_sdk/v3/mcp/connector.rb +183 -0
  16. data/lib/swarm_sdk/v3/mcp/mcp_error.rb +15 -0
  17. data/lib/swarm_sdk/v3/mcp/server_definition.rb +125 -0
  18. data/lib/swarm_sdk/v3/mcp/ssl_http_transport.rb +103 -0
  19. data/lib/swarm_sdk/v3/mcp/stdio_transport.rb +135 -0
  20. data/lib/swarm_sdk/v3/mcp/tool_proxy.rb +53 -0
  21. data/lib/swarm_sdk/v3/memory/adapters/base.rb +297 -0
  22. data/lib/swarm_sdk/v3/memory/adapters/faiss_support.rb +194 -0
  23. data/lib/swarm_sdk/v3/memory/adapters/filesystem_adapter.rb +212 -0
  24. data/lib/swarm_sdk/v3/memory/adapters/sqlite_adapter.rb +507 -0
  25. data/lib/swarm_sdk/v3/memory/adapters/vector_utils.rb +88 -0
  26. data/lib/swarm_sdk/v3/memory/card.rb +206 -0
  27. data/lib/swarm_sdk/v3/memory/cluster.rb +146 -0
  28. data/lib/swarm_sdk/v3/memory/compressor.rb +496 -0
  29. data/lib/swarm_sdk/v3/memory/consolidator.rb +427 -0
  30. data/lib/swarm_sdk/v3/memory/context_builder.rb +339 -0
  31. data/lib/swarm_sdk/v3/memory/edge.rb +105 -0
  32. data/lib/swarm_sdk/v3/memory/embedder.rb +185 -0
  33. data/lib/swarm_sdk/v3/memory/exposure_tracker.rb +104 -0
  34. data/lib/swarm_sdk/v3/memory/ingestion_pipeline.rb +394 -0
  35. data/lib/swarm_sdk/v3/memory/retriever.rb +289 -0
  36. data/lib/swarm_sdk/v3/memory/store.rb +489 -0
  37. data/lib/swarm_sdk/v3/skills/loader.rb +147 -0
  38. data/lib/swarm_sdk/v3/skills/manifest.rb +45 -0
  39. data/lib/swarm_sdk/v3/sub_task_agent.rb +248 -0
  40. data/lib/swarm_sdk/v3/tools/base.rb +80 -0
  41. data/lib/swarm_sdk/v3/tools/bash.rb +174 -0
  42. data/lib/swarm_sdk/v3/tools/clock.rb +32 -0
  43. data/lib/swarm_sdk/v3/tools/edit.rb +111 -0
  44. data/lib/swarm_sdk/v3/tools/glob.rb +96 -0
  45. data/lib/swarm_sdk/v3/tools/grep.rb +200 -0
  46. data/lib/swarm_sdk/v3/tools/message_teammate.rb +15 -0
  47. data/lib/swarm_sdk/v3/tools/message_user.rb +15 -0
  48. data/lib/swarm_sdk/v3/tools/read.rb +181 -0
  49. data/lib/swarm_sdk/v3/tools/read_tracker.rb +40 -0
  50. data/lib/swarm_sdk/v3/tools/registry.rb +208 -0
  51. data/lib/swarm_sdk/v3/tools/sub_task.rb +183 -0
  52. data/lib/swarm_sdk/v3/tools/think.rb +88 -0
  53. data/lib/swarm_sdk/v3/tools/write.rb +87 -0
  54. data/lib/swarm_sdk/v3.rb +145 -0
  55. metadata +84 -148
  56. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +0 -175
  57. data/lib/swarm_sdk/agent/builder.rb +0 -680
  58. data/lib/swarm_sdk/agent/chat.rb +0 -1432
  59. data/lib/swarm_sdk/agent/chat_helpers/context_tracker.rb +0 -375
  60. data/lib/swarm_sdk/agent/chat_helpers/event_emitter.rb +0 -204
  61. data/lib/swarm_sdk/agent/chat_helpers/hook_integration.rb +0 -480
  62. data/lib/swarm_sdk/agent/chat_helpers/instrumentation.rb +0 -85
  63. data/lib/swarm_sdk/agent/chat_helpers/llm_configuration.rb +0 -290
  64. data/lib/swarm_sdk/agent/chat_helpers/logging_helpers.rb +0 -116
  65. data/lib/swarm_sdk/agent/chat_helpers/serialization.rb +0 -83
  66. data/lib/swarm_sdk/agent/chat_helpers/system_reminder_injector.rb +0 -134
  67. data/lib/swarm_sdk/agent/chat_helpers/system_reminders.rb +0 -79
  68. data/lib/swarm_sdk/agent/chat_helpers/token_tracking.rb +0 -146
  69. data/lib/swarm_sdk/agent/context.rb +0 -115
  70. data/lib/swarm_sdk/agent/context_manager.rb +0 -315
  71. data/lib/swarm_sdk/agent/definition.rb +0 -581
  72. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +0 -226
  73. data/lib/swarm_sdk/agent/system_prompt_builder.rb +0 -161
  74. data/lib/swarm_sdk/agent/tool_registry.rb +0 -189
  75. data/lib/swarm_sdk/agent_registry.rb +0 -146
  76. data/lib/swarm_sdk/builders/base_builder.rb +0 -553
  77. data/lib/swarm_sdk/claude_code_agent_adapter.rb +0 -205
  78. data/lib/swarm_sdk/concerns/cleanupable.rb +0 -39
  79. data/lib/swarm_sdk/concerns/snapshotable.rb +0 -67
  80. data/lib/swarm_sdk/concerns/validatable.rb +0 -55
  81. data/lib/swarm_sdk/config.rb +0 -367
  82. data/lib/swarm_sdk/configuration/parser.rb +0 -397
  83. data/lib/swarm_sdk/configuration/translator.rb +0 -283
  84. data/lib/swarm_sdk/configuration.rb +0 -165
  85. data/lib/swarm_sdk/context_compactor/metrics.rb +0 -147
  86. data/lib/swarm_sdk/context_compactor/token_counter.rb +0 -102
  87. data/lib/swarm_sdk/context_compactor.rb +0 -335
  88. data/lib/swarm_sdk/context_management/builder.rb +0 -128
  89. data/lib/swarm_sdk/context_management/context.rb +0 -328
  90. data/lib/swarm_sdk/custom_tool_registry.rb +0 -226
  91. data/lib/swarm_sdk/defaults.rb +0 -251
  92. data/lib/swarm_sdk/events_to_messages.rb +0 -199
  93. data/lib/swarm_sdk/hooks/adapter.rb +0 -359
  94. data/lib/swarm_sdk/hooks/context.rb +0 -197
  95. data/lib/swarm_sdk/hooks/definition.rb +0 -80
  96. data/lib/swarm_sdk/hooks/error.rb +0 -29
  97. data/lib/swarm_sdk/hooks/executor.rb +0 -146
  98. data/lib/swarm_sdk/hooks/registry.rb +0 -147
  99. data/lib/swarm_sdk/hooks/result.rb +0 -150
  100. data/lib/swarm_sdk/hooks/shell_executor.rb +0 -256
  101. data/lib/swarm_sdk/hooks/tool_call.rb +0 -35
  102. data/lib/swarm_sdk/hooks/tool_result.rb +0 -62
  103. data/lib/swarm_sdk/log_collector.rb +0 -227
  104. data/lib/swarm_sdk/log_stream.rb +0 -127
  105. data/lib/swarm_sdk/markdown_parser.rb +0 -75
  106. data/lib/swarm_sdk/model_aliases.json +0 -8
  107. data/lib/swarm_sdk/models.json +0 -44002
  108. data/lib/swarm_sdk/models.rb +0 -161
  109. data/lib/swarm_sdk/node_context.rb +0 -245
  110. data/lib/swarm_sdk/observer/builder.rb +0 -81
  111. data/lib/swarm_sdk/observer/config.rb +0 -45
  112. data/lib/swarm_sdk/observer/manager.rb +0 -236
  113. data/lib/swarm_sdk/patterns/agent_observer.rb +0 -160
  114. data/lib/swarm_sdk/permissions/config.rb +0 -239
  115. data/lib/swarm_sdk/permissions/error_formatter.rb +0 -121
  116. data/lib/swarm_sdk/permissions/path_matcher.rb +0 -35
  117. data/lib/swarm_sdk/permissions/validator.rb +0 -173
  118. data/lib/swarm_sdk/permissions_builder.rb +0 -122
  119. data/lib/swarm_sdk/plugin.rb +0 -309
  120. data/lib/swarm_sdk/plugin_registry.rb +0 -101
  121. data/lib/swarm_sdk/proc_helpers.rb +0 -53
  122. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +0 -117
  123. data/lib/swarm_sdk/restore_result.rb +0 -65
  124. data/lib/swarm_sdk/result.rb +0 -212
  125. data/lib/swarm_sdk/snapshot.rb +0 -156
  126. data/lib/swarm_sdk/snapshot_from_events.rb +0 -397
  127. data/lib/swarm_sdk/state_restorer.rb +0 -476
  128. data/lib/swarm_sdk/state_snapshot.rb +0 -334
  129. data/lib/swarm_sdk/swarm/agent_initializer.rb +0 -648
  130. data/lib/swarm_sdk/swarm/all_agents_builder.rb +0 -195
  131. data/lib/swarm_sdk/swarm/builder.rb +0 -256
  132. data/lib/swarm_sdk/swarm/executor.rb +0 -290
  133. data/lib/swarm_sdk/swarm/hook_triggers.rb +0 -151
  134. data/lib/swarm_sdk/swarm/lazy_delegate_chat.rb +0 -372
  135. data/lib/swarm_sdk/swarm/logging_callbacks.rb +0 -360
  136. data/lib/swarm_sdk/swarm/mcp_configurator.rb +0 -270
  137. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +0 -67
  138. data/lib/swarm_sdk/swarm/tool_configurator.rb +0 -392
  139. data/lib/swarm_sdk/swarm.rb +0 -843
  140. data/lib/swarm_sdk/swarm_loader.rb +0 -145
  141. data/lib/swarm_sdk/swarm_registry.rb +0 -136
  142. data/lib/swarm_sdk/tools/base.rb +0 -63
  143. data/lib/swarm_sdk/tools/bash.rb +0 -280
  144. data/lib/swarm_sdk/tools/clock.rb +0 -46
  145. data/lib/swarm_sdk/tools/delegate.rb +0 -389
  146. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +0 -83
  147. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +0 -99
  148. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +0 -101
  149. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +0 -78
  150. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +0 -194
  151. data/lib/swarm_sdk/tools/edit.rb +0 -145
  152. data/lib/swarm_sdk/tools/glob.rb +0 -166
  153. data/lib/swarm_sdk/tools/grep.rb +0 -235
  154. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +0 -43
  155. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +0 -167
  156. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +0 -65
  157. data/lib/swarm_sdk/tools/mcp_tool_stub.rb +0 -198
  158. data/lib/swarm_sdk/tools/multi_edit.rb +0 -236
  159. data/lib/swarm_sdk/tools/path_resolver.rb +0 -92
  160. data/lib/swarm_sdk/tools/read.rb +0 -261
  161. data/lib/swarm_sdk/tools/registry.rb +0 -205
  162. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +0 -117
  163. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +0 -97
  164. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +0 -108
  165. data/lib/swarm_sdk/tools/stores/read_tracker.rb +0 -96
  166. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +0 -273
  167. data/lib/swarm_sdk/tools/stores/storage.rb +0 -142
  168. data/lib/swarm_sdk/tools/stores/todo_manager.rb +0 -65
  169. data/lib/swarm_sdk/tools/think.rb +0 -100
  170. data/lib/swarm_sdk/tools/todo_write.rb +0 -237
  171. data/lib/swarm_sdk/tools/web_fetch.rb +0 -264
  172. data/lib/swarm_sdk/tools/write.rb +0 -112
  173. data/lib/swarm_sdk/transcript_builder.rb +0 -278
  174. data/lib/swarm_sdk/utils.rb +0 -68
  175. data/lib/swarm_sdk/validation_result.rb +0 -33
  176. data/lib/swarm_sdk/version.rb +0 -5
  177. data/lib/swarm_sdk/workflow/agent_config.rb +0 -95
  178. data/lib/swarm_sdk/workflow/builder.rb +0 -227
  179. data/lib/swarm_sdk/workflow/executor.rb +0 -497
  180. data/lib/swarm_sdk/workflow/node_builder.rb +0 -593
  181. data/lib/swarm_sdk/workflow/transformer_executor.rb +0 -250
  182. data/lib/swarm_sdk/workflow.rb +0 -589
  183. data/lib/swarm_sdk.rb +0 -718
@@ -0,0 +1,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ module Adapters
7
+ # SQLite-based storage adapter with sqlite-vec vector index
8
+ #
9
+ # Stores cards, edges, clusters, and vector embeddings in a single
10
+ # SQLite database for durable, transactional, multi-process-safe storage.
11
+ # Vector search uses sqlite-vec's vec0 virtual table with cosine
12
+ # distance, eliminating external FAISS files and ID mapping tables.
13
+ #
14
+ # Database structure:
15
+ # .swarm/memory/
16
+ # └── memory.db (SQLite database with vec0 virtual table)
17
+ #
18
+ # ## Why UPSERT instead of INSERT OR REPLACE
19
+ #
20
+ # `INSERT OR REPLACE` deletes the old row then inserts a new one.
21
+ # With `ON DELETE CASCADE` on edges, this silently destroys all
22
+ # edges for the card on every update (including `record_access!`).
23
+ # `ON CONFLICT DO UPDATE` updates in-place without triggering CASCADE.
24
+ #
25
+ # @example
26
+ # adapter = SqliteAdapter.new("/path/to/.swarm/memory")
27
+ # adapter.load
28
+ # adapter.write_card(card)
29
+ # adapter.save
30
+ class SqliteAdapter < Base
31
+ include VectorUtils
32
+
33
+ # Embedding dimensions for MiniLM-L6-v2 model
34
+ EMBEDDING_DIMENSIONS = 384
35
+
36
+ # @return [String] Root directory for storage
37
+ attr_reader :directory
38
+
39
+ # SQL schema for the memory database
40
+ SCHEMA_SQL = <<~SQL
41
+ CREATE TABLE IF NOT EXISTS cards (
42
+ id TEXT PRIMARY KEY,
43
+ text TEXT NOT NULL,
44
+ type TEXT NOT NULL,
45
+ entities TEXT NOT NULL DEFAULT '[]',
46
+ source_turn_ids TEXT NOT NULL DEFAULT '[]',
47
+ embedding TEXT,
48
+ importance REAL NOT NULL DEFAULT 0.5,
49
+ confidence REAL NOT NULL DEFAULT 1.0,
50
+ access_count INTEGER NOT NULL DEFAULT 0,
51
+ last_accessed TEXT,
52
+ dwell REAL NOT NULL DEFAULT 0.0,
53
+ compression_level INTEGER NOT NULL DEFAULT 0,
54
+ canonical_id TEXT,
55
+ created_at TEXT NOT NULL,
56
+ updated_at TEXT NOT NULL
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS edges (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ from_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
62
+ to_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
63
+ type TEXT NOT NULL,
64
+ weight REAL NOT NULL DEFAULT 1.0,
65
+ created_at TEXT NOT NULL
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_edges_from_id ON edges(from_id);
69
+ CREATE INDEX IF NOT EXISTS idx_edges_to_id ON edges(to_id);
70
+
71
+ CREATE TABLE IF NOT EXISTS clusters (
72
+ id TEXT PRIMARY KEY,
73
+ title TEXT NOT NULL,
74
+ embedding TEXT,
75
+ rolling_summary TEXT NOT NULL DEFAULT '',
76
+ decision_log TEXT NOT NULL DEFAULT '[]',
77
+ key_entities TEXT NOT NULL DEFAULT '[]',
78
+ card_ids TEXT NOT NULL DEFAULT '[]',
79
+ created_at TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL
81
+ );
82
+ SQL
83
+
84
+ # @param directory [String] Root directory for memory storage
85
+ def initialize(directory)
86
+ super()
87
+ @directory = File.expand_path(directory)
88
+ @db = nil
89
+ end
90
+
91
+ # --- Card CRUD ---
92
+
93
+ # @param card [Card] Card to write (insert or update)
94
+ # @return [void]
95
+ def write_card(card)
96
+ @db.execute(<<~SQL, card_params(card))
97
+ INSERT INTO cards (id, text, type, entities, source_turn_ids, embedding,
98
+ importance, confidence, access_count, last_accessed,
99
+ dwell, compression_level, canonical_id, created_at, updated_at)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
101
+ ON CONFLICT(id) DO UPDATE SET
102
+ text = excluded.text,
103
+ type = excluded.type,
104
+ entities = excluded.entities,
105
+ source_turn_ids = excluded.source_turn_ids,
106
+ embedding = excluded.embedding,
107
+ importance = excluded.importance,
108
+ confidence = excluded.confidence,
109
+ access_count = excluded.access_count,
110
+ last_accessed = excluded.last_accessed,
111
+ dwell = excluded.dwell,
112
+ compression_level = excluded.compression_level,
113
+ canonical_id = excluded.canonical_id,
114
+ updated_at = excluded.updated_at
115
+ SQL
116
+ upsert_vec_embedding(card.id, card.embedding) if card.embedding
117
+ end
118
+
119
+ # @param id [String] Card ID
120
+ # @return [Card, nil]
121
+ def read_card(id)
122
+ row = @db.execute("SELECT * FROM cards WHERE id = ?", [id]).first
123
+ return unless row
124
+
125
+ card_from_row(row)
126
+ end
127
+
128
+ # @param id [String] Card ID
129
+ # @return [void]
130
+ def delete_card(id)
131
+ @db.execute("DELETE FROM cards WHERE id = ?", [id])
132
+ @db.execute("DELETE FROM vec_cards WHERE card_id = ?", [id])
133
+ end
134
+
135
+ # @param prefix [String, nil] ID prefix filter
136
+ # @return [Array<Card>]
137
+ def list_cards(prefix: nil)
138
+ rows = if prefix
139
+ @db.execute("SELECT * FROM cards WHERE id LIKE ?", ["#{prefix}%"])
140
+ else
141
+ @db.execute("SELECT * FROM cards")
142
+ end
143
+ rows.map { |row| card_from_row(row) }
144
+ end
145
+
146
+ # @param max_level [Integer] Maximum compression level to include
147
+ # @return [Array<Card>] Cards eligible for compression
148
+ def list_cards_for_compression(max_level: 3)
149
+ rows = @db.execute("SELECT * FROM cards WHERE compression_level <= ?", [max_level])
150
+ rows.map { |row| card_from_row(row) }
151
+ end
152
+
153
+ # --- Edge CRUD ---
154
+
155
+ # @param edge [Edge] Edge to write
156
+ # @return [void]
157
+ def write_edge(edge)
158
+ @db.execute(
159
+ "INSERT INTO edges (from_id, to_id, type, weight, created_at) VALUES (?, ?, ?, ?, ?)",
160
+ [edge.from_id, edge.to_id, edge.type.to_s, edge.weight, edge.created_at.iso8601],
161
+ )
162
+ end
163
+
164
+ # @param card_id [String] Card ID
165
+ # @param type [Symbol, nil] Filter by edge type
166
+ # @return [Array<Edge>]
167
+ def edges_for(card_id, type: nil)
168
+ rows = if type
169
+ @db.execute(
170
+ "SELECT * FROM edges WHERE (from_id = ? OR to_id = ?) AND type = ?",
171
+ [card_id, card_id, type.to_s],
172
+ )
173
+ else
174
+ @db.execute(
175
+ "SELECT * FROM edges WHERE from_id = ? OR to_id = ?",
176
+ [card_id, card_id],
177
+ )
178
+ end
179
+ rows.map { |row| edge_from_row(row) }
180
+ end
181
+
182
+ # @param card_id [String] Card ID
183
+ # @return [void]
184
+ def delete_edges_for(card_id)
185
+ @db.execute("DELETE FROM edges WHERE from_id = ? OR to_id = ?", [card_id, card_id])
186
+ end
187
+
188
+ # --- Cluster CRUD ---
189
+
190
+ # @param cluster [Cluster] Cluster to write (insert or update)
191
+ # @return [void]
192
+ def write_cluster(cluster)
193
+ @db.execute(<<~SQL, cluster_params(cluster))
194
+ INSERT INTO clusters (id, title, embedding, rolling_summary, decision_log,
195
+ key_entities, card_ids, created_at, updated_at)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
197
+ ON CONFLICT(id) DO UPDATE SET
198
+ title = excluded.title,
199
+ embedding = excluded.embedding,
200
+ rolling_summary = excluded.rolling_summary,
201
+ decision_log = excluded.decision_log,
202
+ key_entities = excluded.key_entities,
203
+ card_ids = excluded.card_ids,
204
+ updated_at = excluded.updated_at
205
+ SQL
206
+ end
207
+
208
+ # @param id [String] Cluster ID
209
+ # @return [Cluster, nil]
210
+ def read_cluster(id)
211
+ row = @db.execute("SELECT * FROM clusters WHERE id = ?", [id]).first
212
+ return unless row
213
+
214
+ cluster_from_row(row)
215
+ end
216
+
217
+ # @return [Array<Cluster>]
218
+ def list_clusters
219
+ rows = @db.execute("SELECT * FROM clusters")
220
+ rows.map { |row| cluster_from_row(row) }
221
+ end
222
+
223
+ # --- Vector Search ---
224
+
225
+ # Search the vec0 virtual table for similar vectors
226
+ #
227
+ # sqlite-vec's cosine distance returns values where 0 = identical
228
+ # and 2 = opposite. We convert to similarity: `1.0 - distance`.
229
+ #
230
+ # @param embedding [Array<Float>] Query embedding
231
+ # @param top_k [Integer] Maximum number of results
232
+ # @param threshold [Float] Minimum cosine similarity to include
233
+ # @return [Array<Hash>] Array of `{ id: String, similarity: Float }`
234
+ #
235
+ # @example
236
+ # results = adapter.vector_search(query_embedding, top_k: 5, threshold: 0.7)
237
+ # results.each { |r| puts "#{r[:id]}: #{r[:similarity]}" }
238
+ def vector_search(embedding, top_k:, threshold: 0.0)
239
+ rows = @db.execute(
240
+ "SELECT card_id, distance FROM vec_cards WHERE embedding MATCH ? AND k = ?",
241
+ [embedding.pack("f*"), top_k],
242
+ )
243
+
244
+ results = []
245
+ rows.each do |row|
246
+ sim = 1.0 - row["distance"]
247
+ next if sim < threshold
248
+
249
+ results << { id: row["card_id"], similarity: sim }
250
+ end
251
+ results
252
+ end
253
+
254
+ # Rebuild the vec0 index from all stored card embeddings
255
+ #
256
+ # Drops and recreates the vec_cards virtual table, then re-inserts
257
+ # all card embeddings from the cards table.
258
+ #
259
+ # @return [void]
260
+ def rebuild_index
261
+ @db.execute("DROP TABLE IF EXISTS vec_cards")
262
+ create_vec_table!
263
+ @db.execute("SELECT id, embedding FROM cards WHERE embedding IS NOT NULL").each do |row|
264
+ embedding = JSON.parse(row["embedding"])
265
+ @db.execute(
266
+ "INSERT INTO vec_cards(card_id, embedding) VALUES (?, ?)",
267
+ [row["id"], embedding.pack("f*")],
268
+ )
269
+ end
270
+ end
271
+
272
+ # --- Transactions ---
273
+
274
+ # Execute a block within a SQLite transaction
275
+ #
276
+ # Uses IMMEDIATE mode to acquire a write lock at the start,
277
+ # preventing deadlocks in WAL mode.
278
+ #
279
+ # @yield Block to execute within the transaction
280
+ # @return [Object] Return value of the block
281
+ def transaction(&block)
282
+ @db.transaction(:immediate, &block)
283
+ end
284
+
285
+ # --- Persistence ---
286
+
287
+ # Ensure the storage directory exists
288
+ #
289
+ # SQLite with WAL mode is already durable — no external
290
+ # index files need saving.
291
+ #
292
+ # @return [void]
293
+ def save
294
+ FileUtils.mkdir_p(@directory)
295
+ end
296
+
297
+ # Load state from SQLite and initialize vec0 index
298
+ #
299
+ # Opens the database, creates schema if needed, loads the
300
+ # sqlite-vec extension, and creates the vec0 virtual table.
301
+ # If vec_cards is empty but cards have embeddings, auto-rebuilds
302
+ # the index (handles FAISS migration and first-run).
303
+ #
304
+ # @return [void]
305
+ def load
306
+ load_sqlite!
307
+ auto_rebuild_vec_if_needed!
308
+ end
309
+
310
+ private
311
+
312
+ # Open SQLite database, initialize schema, and load sqlite-vec
313
+ #
314
+ # @return [void]
315
+ def load_sqlite!
316
+ begin
317
+ require "sqlite3"
318
+ rescue LoadError
319
+ raise LoadError,
320
+ "sqlite3 gem is required for SqliteAdapter. Add `gem 'sqlite3'` to your Gemfile."
321
+ end
322
+
323
+ begin
324
+ require "sqlite_vec"
325
+ rescue LoadError
326
+ raise LoadError,
327
+ "sqlite_vec gem is required for SqliteAdapter. Add `gem 'sqlite_vec'` to your Gemfile."
328
+ end
329
+
330
+ FileUtils.mkdir_p(@directory)
331
+ db_path = File.join(@directory, "memory.db")
332
+ @db = SQLite3::Database.new(db_path)
333
+ @db.results_as_hash = true
334
+ @db.execute("PRAGMA journal_mode=WAL")
335
+ @db.execute("PRAGMA foreign_keys=ON")
336
+ @db.execute("PRAGMA busy_timeout=5000")
337
+ @db.execute_batch(SCHEMA_SQL)
338
+
339
+ @db.enable_load_extension(true)
340
+ SqliteVec.load(@db)
341
+ @db.enable_load_extension(false)
342
+
343
+ create_vec_table!
344
+ end
345
+
346
+ # Create the vec0 virtual table for vector search
347
+ #
348
+ # @return [void]
349
+ def create_vec_table!
350
+ @db.execute(<<~SQL)
351
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_cards USING vec0(
352
+ card_id TEXT PRIMARY KEY,
353
+ embedding FLOAT[#{EMBEDDING_DIMENSIONS}] DISTANCE_METRIC=cosine
354
+ )
355
+ SQL
356
+ end
357
+
358
+ # Upsert a card's embedding into the vec0 virtual table
359
+ #
360
+ # vec0 does not support ON CONFLICT, so we DELETE then INSERT.
361
+ #
362
+ # @param card_id [String] Card ID
363
+ # @param embedding [Array<Float>] Embedding vector
364
+ # @return [void]
365
+ def upsert_vec_embedding(card_id, embedding)
366
+ @db.execute("DELETE FROM vec_cards WHERE card_id = ?", [card_id])
367
+ @db.execute(
368
+ "INSERT INTO vec_cards(card_id, embedding) VALUES (?, ?)",
369
+ [card_id, embedding.pack("f*")],
370
+ )
371
+ end
372
+
373
+ # Auto-rebuild vec0 index if cards have embeddings but vec_cards is empty
374
+ #
375
+ # Handles migration from FAISS and first-run scenarios.
376
+ #
377
+ # @return [void]
378
+ def auto_rebuild_vec_if_needed!
379
+ vec_count = @db.execute("SELECT COUNT(*) AS cnt FROM vec_cards").first["cnt"]
380
+ return if vec_count > 0
381
+
382
+ has_embeddings = @db.execute(
383
+ "SELECT COUNT(*) AS cnt FROM cards WHERE embedding IS NOT NULL",
384
+ ).first["cnt"] > 0
385
+ rebuild_index if has_embeddings
386
+ end
387
+
388
+ # --- Card Serialization ---
389
+
390
+ # Convert a Card to bind parameter array
391
+ #
392
+ # @param card [Card] Card to serialize
393
+ # @return [Array] Bind values for INSERT/UPSERT
394
+ def card_params(card)
395
+ [
396
+ card.id,
397
+ card.text,
398
+ card.type.to_s,
399
+ JSON.generate(card.entities),
400
+ JSON.generate(card.source_turn_ids),
401
+ card.embedding ? JSON.generate(card.embedding) : nil,
402
+ card.importance,
403
+ card.confidence,
404
+ card.access_count,
405
+ card.last_accessed&.iso8601,
406
+ card.dwell,
407
+ card.compression_level,
408
+ card.canonical_id,
409
+ card.created_at.iso8601,
410
+ card.updated_at.iso8601,
411
+ ]
412
+ end
413
+
414
+ # Convert a SQLite row hash to a Card
415
+ #
416
+ # @param row [Hash] Row from SQLite (results_as_hash mode)
417
+ # @return [Card]
418
+ def card_from_row(row)
419
+ Card.new(
420
+ id: row["id"],
421
+ text: row["text"],
422
+ type: row["type"]&.to_sym || :fact,
423
+ entities: parse_json_array(row["entities"]),
424
+ source_turn_ids: parse_json_array(row["source_turn_ids"]),
425
+ embedding: row["embedding"] ? JSON.parse(row["embedding"]) : nil,
426
+ importance: row["importance"] || 0.5,
427
+ confidence: row["confidence"] || 1.0,
428
+ access_count: row["access_count"] || 0,
429
+ last_accessed: row["last_accessed"] ? Time.parse(row["last_accessed"]) : nil,
430
+ dwell: row["dwell"] || 0.0,
431
+ compression_level: row["compression_level"] || 0,
432
+ canonical_id: row["canonical_id"],
433
+ created_at: row["created_at"] ? Time.parse(row["created_at"]) : nil,
434
+ updated_at: row["updated_at"] ? Time.parse(row["updated_at"]) : nil,
435
+ )
436
+ end
437
+
438
+ # --- Edge Serialization ---
439
+
440
+ # Convert a SQLite row hash to an Edge
441
+ #
442
+ # @param row [Hash] Row from SQLite
443
+ # @return [Edge]
444
+ def edge_from_row(row)
445
+ Edge.new(
446
+ from_id: row["from_id"],
447
+ to_id: row["to_id"],
448
+ type: row["type"]&.to_sym,
449
+ weight: row["weight"] || 1.0,
450
+ created_at: row["created_at"] ? Time.parse(row["created_at"]) : nil,
451
+ )
452
+ end
453
+
454
+ # --- Cluster Serialization ---
455
+
456
+ # Convert a Cluster to bind parameter array
457
+ #
458
+ # @param cluster [Cluster] Cluster to serialize
459
+ # @return [Array] Bind values for INSERT/UPSERT
460
+ def cluster_params(cluster)
461
+ [
462
+ cluster.id,
463
+ cluster.title,
464
+ cluster.embedding ? JSON.generate(cluster.embedding) : nil,
465
+ cluster.rolling_summary,
466
+ JSON.generate(cluster.decision_log),
467
+ JSON.generate(cluster.key_entities),
468
+ JSON.generate(cluster.card_ids),
469
+ cluster.created_at.iso8601,
470
+ cluster.updated_at.iso8601,
471
+ ]
472
+ end
473
+
474
+ # Convert a SQLite row hash to a Cluster
475
+ #
476
+ # @param row [Hash] Row from SQLite
477
+ # @return [Cluster]
478
+ def cluster_from_row(row)
479
+ Cluster.new(
480
+ id: row["id"],
481
+ title: row["title"],
482
+ embedding: row["embedding"] ? JSON.parse(row["embedding"]) : nil,
483
+ rolling_summary: row["rolling_summary"] || "",
484
+ decision_log: parse_json_array(row["decision_log"]),
485
+ key_entities: parse_json_array(row["key_entities"]),
486
+ card_ids: parse_json_array(row["card_ids"]),
487
+ created_at: row["created_at"] ? Time.parse(row["created_at"]) : nil,
488
+ updated_at: row["updated_at"] ? Time.parse(row["updated_at"]) : nil,
489
+ )
490
+ end
491
+
492
+ # Parse a JSON array string, returning empty array on nil/error
493
+ #
494
+ # @param json_str [String, nil] JSON string
495
+ # @return [Array]
496
+ def parse_json_array(json_str)
497
+ return [] if json_str.nil? || json_str.empty?
498
+
499
+ JSON.parse(json_str)
500
+ rescue JSON::ParserError
501
+ []
502
+ end
503
+ end
504
+ end
505
+ end
506
+ end
507
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module V3
5
+ module Memory
6
+ module Adapters
7
+ # Default in-memory vector math for adapters
8
+ #
9
+ # Provides a pure-Ruby cosine similarity implementation that adapters
10
+ # can include when they don't have a native vector engine. The
11
+ # {FilesystemAdapter} includes this module because its FAISS index
12
+ # only handles indexed top-k search — it cannot compute pairwise
13
+ # similarity between two arbitrary vectors.
14
+ #
15
+ # **When to use this module:**
16
+ # Include it if your adapter stores embeddings as Ruby arrays and
17
+ # doesn't have a native way to compute pairwise similarity. This is
18
+ # the common case for file-based, SQLite, or simple database adapters.
19
+ #
20
+ # **When NOT to use this module:**
21
+ # If your storage backend has native vector operations, implement
22
+ # {Base#similarity} directly using the backend's operator. Examples:
23
+ #
24
+ # - **pgvector**: `SELECT 1 - (a <=> b)` (cosine distance operator)
25
+ # - **Qdrant/Pinecone**: Use the client's similarity API
26
+ # - **Redis with RediSearch**: `FT.SEARCH` with vector scoring
27
+ #
28
+ # In those cases you skip this module entirely and write your own
29
+ # `similarity` method that delegates to the backend.
30
+ #
31
+ # @example Including in a custom adapter
32
+ # class SqliteAdapter < SwarmSDK::V3::Memory::Adapters::Base
33
+ # include SwarmSDK::V3::Memory::Adapters::VectorUtils
34
+ #
35
+ # # similarity() is now available via VectorUtils
36
+ # # Override vector_search with your own indexed search
37
+ # end
38
+ #
39
+ # @example NOT including — pgvector adapter
40
+ # class PgvectorAdapter < SwarmSDK::V3::Memory::Adapters::Base
41
+ # def similarity(embedding_a, embedding_b)
42
+ # # Compute server-side using pgvector's cosine distance
43
+ # result = @db.exec_params(
44
+ # "SELECT 1 - ($1::vector <=> $2::vector) AS sim",
45
+ # [embedding_a.to_s, embedding_b.to_s]
46
+ # )
47
+ # result[0]["sim"].to_f
48
+ # end
49
+ # end
50
+ module VectorUtils
51
+ # Compute cosine similarity between two embedding vectors
52
+ #
53
+ # This is a pure-Ruby implementation suitable for in-memory use.
54
+ # It computes: dot(a, b) / (||a|| * ||b||)
55
+ #
56
+ # For production adapters with native vector engines, override
57
+ # this method to delegate to the backend instead.
58
+ #
59
+ # @param embedding_a [Array<Float>] First embedding vector
60
+ # @param embedding_b [Array<Float>] Second embedding vector
61
+ # @return [Float] Cosine similarity (-1.0 to 1.0)
62
+ #
63
+ # @example
64
+ # sim = adapter.similarity(card_a.embedding, card_b.embedding)
65
+ # puts "Cards are duplicates" if sim > 0.92
66
+ def similarity(embedding_a, embedding_b)
67
+ dot = 0.0
68
+ mag_a = 0.0
69
+ mag_b = 0.0
70
+
71
+ embedding_a.each_with_index do |va, i|
72
+ vb = embedding_b[i]
73
+ dot += va * vb
74
+ mag_a += va * va
75
+ mag_b += vb * vb
76
+ end
77
+
78
+ mag_a = Math.sqrt(mag_a)
79
+ mag_b = Math.sqrt(mag_b)
80
+ return 0.0 if mag_a.zero? || mag_b.zero?
81
+
82
+ dot / (mag_a * mag_b)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end