swarm_sdk 2.7.14 → 3.0.0.alpha2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/document_converters/base.rb +84 -0
- data/lib/swarm_sdk/v3/tools/document_converters/docx_converter.rb +120 -0
- data/lib/swarm_sdk/v3/tools/document_converters/pdf_converter.rb +111 -0
- data/lib/swarm_sdk/v3/tools/document_converters/xlsx_converter.rb +128 -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 +213 -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 +88 -149
- 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,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
|