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