synapse-memory-engine 1.3.0__py3-none-any.whl
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.
- synapse_memory/__init__.py +31 -0
- synapse_memory/api/fastapi_server.py +1334 -0
- synapse_memory/cli/synapse_cli.py +112 -0
- synapse_memory/client.py +127 -0
- synapse_memory/core/__init__.py +50 -0
- synapse_memory/core/auth.py +1273 -0
- synapse_memory/core/decay_engine.py +123 -0
- synapse_memory/core/deduplicator.py +98 -0
- synapse_memory/core/embedder.py +119 -0
- synapse_memory/core/embedding_provider.py +241 -0
- synapse_memory/core/encryption.py +226 -0
- synapse_memory/core/hybrid_search.py +199 -0
- synapse_memory/core/knapsack_packer.py +103 -0
- synapse_memory/core/memory_manager.py +233 -0
- synapse_memory/core/migrations.py +241 -0
- synapse_memory/core/observability.py +48 -0
- synapse_memory/core/pgvector_db.py +1276 -0
- synapse_memory/core/pruner.py +68 -0
- synapse_memory/core/rate_limiter.py +180 -0
- synapse_memory/core/sqlite_db.py +564 -0
- synapse_memory/examples/langchain_integration.py +12 -0
- synapse_memory/examples/llamaindex_integration.py +12 -0
- synapse_memory/integrations/__init__.py +9 -0
- synapse_memory/integrations/langchain.py +209 -0
- synapse_memory/integrations/llamaindex.py +295 -0
- synapse_memory/main.py +112 -0
- synapse_memory/py.typed +0 -0
- synapse_memory_engine-1.3.0.dist-info/METADATA +75 -0
- synapse_memory_engine-1.3.0.dist-info/RECORD +32 -0
- synapse_memory_engine-1.3.0.dist-info/WHEEL +5 -0
- synapse_memory_engine-1.3.0.dist-info/entry_points.txt +4 -0
- synapse_memory_engine-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from synapse_memory.core.encryption import (
|
|
7
|
+
FernetEncryptionProvider,
|
|
8
|
+
KeyManagementProvider,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _AutoClosingConnection(sqlite3.Connection):
|
|
13
|
+
"""SQLite connection whose context manager COMMITS/ROLLS BACK and CLOSES.
|
|
14
|
+
|
|
15
|
+
Stock sqlite3 ``with conn:`` only commits or rolls back but leaves the file
|
|
16
|
+
handle open — on Windows that locks test .db files (PermissionError on
|
|
17
|
+
cleanup) and leaks WAL handles under FastAPI workers. This subclass closes
|
|
18
|
+
the handle on ``__exit__`` unless the instance is flagged
|
|
19
|
+
``_sqlite_keep_open`` (used by SQLiteMemoryStore for its long-lived
|
|
20
|
+
``:memory:`` connection).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore
|
|
24
|
+
# Commit (or roll back) on context exit, then close the handle UNLESS
|
|
25
|
+
# this is the store's cached ':memory:' connection. SQLite ':memory:'
|
|
26
|
+
# databases are per-connection, so that one must stay open for the
|
|
27
|
+
# lifetime of the store (closing it would discard the schema and every
|
|
28
|
+
# migration). File-based connections always close here so Windows test
|
|
29
|
+
# teardown can delete the .db / -wal / -shm files without PermissionError.
|
|
30
|
+
try:
|
|
31
|
+
if exc_type is None:
|
|
32
|
+
try:
|
|
33
|
+
self.commit()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
self.rollback()
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
finally:
|
|
42
|
+
# Skip closing the flagged long-lived ':memory:' connection so its
|
|
43
|
+
# schema and migrations survive this `with`; close everything else.
|
|
44
|
+
if not getattr(self, '_sqlite_keep_open', False):
|
|
45
|
+
try:
|
|
46
|
+
self.close()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SQLiteMemoryStore:
|
|
53
|
+
"""
|
|
54
|
+
Sovereign SQLite persistence backend with mandatory authenticated encryption at rest
|
|
55
|
+
and database-level multi-tenant isolation.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
db_path: str = "synapse_memory.db",
|
|
61
|
+
encryption_provider: Optional[Any] = None,
|
|
62
|
+
fail_closed: bool = False
|
|
63
|
+
):
|
|
64
|
+
self.db_path = db_path
|
|
65
|
+
# Parity with PGVectorMemoryStore: strict callers (tests, provisioning)
|
|
66
|
+
# may demand that schema bootstrap errors raise instead of warn.
|
|
67
|
+
self.fail_closed = fail_closed
|
|
68
|
+
if encryption_provider is not None:
|
|
69
|
+
self.encryption_provider = encryption_provider
|
|
70
|
+
else:
|
|
71
|
+
key = KeyManagementProvider.get_encryption_key()
|
|
72
|
+
self.encryption_provider = FernetEncryptionProvider(key)
|
|
73
|
+
self._conn: Optional[sqlite3.Connection] = None # cached shared connection for ':memory:'
|
|
74
|
+
self._initialize_database()
|
|
75
|
+
|
|
76
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
77
|
+
# SQLite ':memory:' is per-connection, so the schema created during
|
|
78
|
+
# _initialize_database must live on ONE shared connection. Cache a
|
|
79
|
+
# single connection only for ':memory:' stores; file-based stores use a
|
|
80
|
+
# fresh connection per query so that test teardown can remove the DB
|
|
81
|
+
# file cleanly.
|
|
82
|
+
if self.db_path == ":memory:" and self._conn is not None:
|
|
83
|
+
return self._conn
|
|
84
|
+
conn = sqlite3.connect(
|
|
85
|
+
self.db_path,
|
|
86
|
+
timeout=10.0,
|
|
87
|
+
check_same_thread=False,
|
|
88
|
+
factory=_AutoClosingConnection,
|
|
89
|
+
)
|
|
90
|
+
conn.row_factory = sqlite3.Row
|
|
91
|
+
try:
|
|
92
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
93
|
+
conn.execute("PRAGMA busy_timeout=5000;")
|
|
94
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
if self.db_path == ":memory:":
|
|
98
|
+
self._conn = conn
|
|
99
|
+
setattr(conn, "_sqlite_keep_open", True)
|
|
100
|
+
return conn
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
"""Release the cached connection and checkpoint WAL sidecars.
|
|
104
|
+
|
|
105
|
+
File-based stores open a short-lived connection to run a WAL
|
|
106
|
+
checkpoint so no ``-wal``/``-shm`` files linger after tests or
|
|
107
|
+
shutdown; ':memory:' stores skip that (there is nothing on disk).
|
|
108
|
+
Either way the cached ':memory:' connection is then torn down.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
if self.db_path != ":memory:":
|
|
112
|
+
conn = sqlite3.connect(self.db_path, timeout=5.0)
|
|
113
|
+
try:
|
|
114
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
|
115
|
+
conn.commit()
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
finally:
|
|
121
|
+
self._close_conn()
|
|
122
|
+
|
|
123
|
+
def _close_conn(self) -> None:
|
|
124
|
+
"""Close the store's cached ':memory:' connection, if one exists."""
|
|
125
|
+
conn = self._conn
|
|
126
|
+
if conn is not None:
|
|
127
|
+
try:
|
|
128
|
+
conn.close()
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
self._conn = None
|
|
132
|
+
|
|
133
|
+
def _return_connection(self, conn: Any) -> None:
|
|
134
|
+
"""Pool-compatible no-op.
|
|
135
|
+
|
|
136
|
+
SQLiteMemoryStore owns a single cached ':memory:' connection instead of
|
|
137
|
+
an external pool, so there is nothing to return. Exposing this method
|
|
138
|
+
lets ``run_migrations(..., owns_connection=False)`` hand the connection
|
|
139
|
+
back without closing it, keeping the ':memory:' schema alive.
|
|
140
|
+
"""
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def _initialize_database(self) -> None:
|
|
144
|
+
"""Creates the memory table with tenant isolation and index constraints on creation."""
|
|
145
|
+
conn = self._get_connection()
|
|
146
|
+
cursor = conn.cursor()
|
|
147
|
+
cursor.execute("""
|
|
148
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
149
|
+
id TEXT PRIMARY KEY,
|
|
150
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
151
|
+
content TEXT NOT NULL,
|
|
152
|
+
category TEXT NOT NULL,
|
|
153
|
+
confidence REAL NOT NULL,
|
|
154
|
+
created_at REAL NOT NULL,
|
|
155
|
+
last_accessed_at REAL NOT NULL,
|
|
156
|
+
access_count INTEGER NOT NULL,
|
|
157
|
+
token_cost INTEGER NOT NULL,
|
|
158
|
+
embedding TEXT,
|
|
159
|
+
feedback_multiplier REAL NOT NULL,
|
|
160
|
+
supersedes VARCHAR(128),
|
|
161
|
+
superseded_by VARCHAR(128)
|
|
162
|
+
)
|
|
163
|
+
""")
|
|
164
|
+
|
|
165
|
+
# Migration check: Ensure tenant_id column exists if the table was
|
|
166
|
+
# created by an older schema version.
|
|
167
|
+
cursor.execute("PRAGMA table_info(memories)")
|
|
168
|
+
columns = [info[1] for info in cursor.fetchall()]
|
|
169
|
+
if "tenant_id" not in columns:
|
|
170
|
+
cursor.execute(
|
|
171
|
+
"ALTER TABLE memories ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
cursor.execute(
|
|
175
|
+
"CREATE INDEX IF NOT EXISTS idx_tenant ON memories(tenant_id)"
|
|
176
|
+
)
|
|
177
|
+
cursor.execute(
|
|
178
|
+
"CREATE INDEX IF NOT EXISTS idx_tenant_category ON memories(tenant_id, category)"
|
|
179
|
+
)
|
|
180
|
+
cursor.execute(
|
|
181
|
+
"CREATE INDEX IF NOT EXISTS idx_category ON memories(category)"
|
|
182
|
+
)
|
|
183
|
+
cursor.execute(
|
|
184
|
+
"CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)"
|
|
185
|
+
)
|
|
186
|
+
cursor.execute(
|
|
187
|
+
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON memories(last_accessed_at)"
|
|
188
|
+
)
|
|
189
|
+
conn.commit()
|
|
190
|
+
|
|
191
|
+
# Apply forward-only schema migrations (adds embedding_dimension /
|
|
192
|
+
# model_version + migration ledger). Idempotent + versioned.
|
|
193
|
+
# ':memory:' is per-connection in SQLite, so the schema created during
|
|
194
|
+
# _initialize_database lives on our cached connection and must survive
|
|
195
|
+
# this run of migrations. Pass owns_connection=False so the cached
|
|
196
|
+
# connection is NOT closed here.
|
|
197
|
+
from synapse_memory.core.migrations import run_migrations
|
|
198
|
+
if self.db_path == ":memory:":
|
|
199
|
+
run_migrations(
|
|
200
|
+
self._get_connection,
|
|
201
|
+
is_postgres=False,
|
|
202
|
+
fail_closed=self.fail_closed,
|
|
203
|
+
owns_connection=False,
|
|
204
|
+
close_on_exit=False,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
# File-based stores hand run_migrations a callable that returns the
|
|
208
|
+
# schema-init connection and let it own+close it. This also lets
|
|
209
|
+
# test teardown remove the DB file cleanly.
|
|
210
|
+
run_migrations(
|
|
211
|
+
lambda: conn,
|
|
212
|
+
is_postgres=False,
|
|
213
|
+
fail_closed=self.fail_closed,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def insert_memory(self, memory: Dict[str, Any]) -> None:
|
|
217
|
+
"""Inserts a structured memory node into the SQLite table with tenant isolation."""
|
|
218
|
+
content = self.encryption_provider.encrypt(memory["content"])
|
|
219
|
+
embedding = self.encryption_provider.encrypt(
|
|
220
|
+
json.dumps(memory.get("embedding", []))
|
|
221
|
+
)
|
|
222
|
+
tenant_id = memory.get("tenant_id") or memory.get("tenantId") or "default"
|
|
223
|
+
|
|
224
|
+
with self._get_connection() as conn:
|
|
225
|
+
cursor = conn.cursor()
|
|
226
|
+
cursor.execute("""
|
|
227
|
+
INSERT OR REPLACE INTO memories (
|
|
228
|
+
id, tenant_id, content, category, confidence, created_at,
|
|
229
|
+
last_accessed_at, access_count, token_cost,
|
|
230
|
+
embedding, feedback_multiplier, embedding_dimension, model_version,
|
|
231
|
+
supersedes, superseded_by
|
|
232
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
233
|
+
""", (
|
|
234
|
+
memory["id"],
|
|
235
|
+
tenant_id,
|
|
236
|
+
content,
|
|
237
|
+
memory["category"],
|
|
238
|
+
memory["confidence"],
|
|
239
|
+
memory["created_at"],
|
|
240
|
+
memory.get("last_accessed_at", memory["created_at"]),
|
|
241
|
+
memory.get("access_count", 0),
|
|
242
|
+
memory["token_cost"],
|
|
243
|
+
embedding,
|
|
244
|
+
memory.get("feedback_multiplier", 1.0),
|
|
245
|
+
memory.get("embedding_dimension", len(memory.get("embedding", []))),
|
|
246
|
+
memory.get("model_version", "unknown"),
|
|
247
|
+
memory.get("supersedes"),
|
|
248
|
+
memory.get("superseded_by"),
|
|
249
|
+
))
|
|
250
|
+
conn.commit()
|
|
251
|
+
|
|
252
|
+
def mark_superseded(self, memory_id: str, superseded_by: str, tenant_id: Optional[str] = None) -> None:
|
|
253
|
+
"""Stamp `superseded_by` on an old memory that a dedup MERGE replaced.
|
|
254
|
+
|
|
255
|
+
Scoped to ``tenant_id`` when supplied: unlike PostgreSQL (where RLS
|
|
256
|
+
enforces the boundary), SQLite has no row-level security, so the tenant
|
|
257
|
+
predicate must be part of the statement itself. Without it a supersede
|
|
258
|
+
issued in one tenant could rewrite another tenant's row.
|
|
259
|
+
"""
|
|
260
|
+
with self._get_connection() as conn:
|
|
261
|
+
cursor = conn.cursor()
|
|
262
|
+
if tenant_id is not None:
|
|
263
|
+
cursor.execute(
|
|
264
|
+
"UPDATE memories SET superseded_by = ? WHERE id = ? AND tenant_id = ?",
|
|
265
|
+
(superseded_by, memory_id, tenant_id),
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
cursor.execute(
|
|
269
|
+
"UPDATE memories SET superseded_by = ? WHERE id = ?",
|
|
270
|
+
(superseded_by, memory_id),
|
|
271
|
+
)
|
|
272
|
+
conn.commit()
|
|
273
|
+
|
|
274
|
+
def get_all_memories(
|
|
275
|
+
self, tenant_id: Optional[str] = None, include_superseded: bool = False
|
|
276
|
+
) -> List[Dict[str, Any]]:
|
|
277
|
+
"""Retrieves memories with deserialized embeddings, optionally scoped to tenant_id.
|
|
278
|
+
|
|
279
|
+
Superseded rows (dedup MERGE lineage) are hidden by default: they remain
|
|
280
|
+
for audit/lineage only and must never be served to retrieval. Capacity
|
|
281
|
+
pruning passes include_superseded=True so stale rows stay evictable.
|
|
282
|
+
"""
|
|
283
|
+
query = "SELECT * FROM memories"
|
|
284
|
+
clauses: List[str] = []
|
|
285
|
+
params: List[Any] = []
|
|
286
|
+
if tenant_id is not None:
|
|
287
|
+
clauses.append("tenant_id = ?")
|
|
288
|
+
params.append(tenant_id)
|
|
289
|
+
if not include_superseded:
|
|
290
|
+
clauses.append("superseded_by IS NULL")
|
|
291
|
+
if clauses:
|
|
292
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
293
|
+
with self._get_connection() as conn:
|
|
294
|
+
cursor = conn.cursor()
|
|
295
|
+
cursor.execute(query, tuple(params))
|
|
296
|
+
rows = cursor.fetchall()
|
|
297
|
+
|
|
298
|
+
memories = []
|
|
299
|
+
for row in rows:
|
|
300
|
+
mem = dict(row)
|
|
301
|
+
mem["content"] = self.encryption_provider.decrypt(mem["content"])
|
|
302
|
+
mem["embedding"] = json.loads(
|
|
303
|
+
self.encryption_provider.decrypt(mem["embedding"])
|
|
304
|
+
)
|
|
305
|
+
memories.append(mem)
|
|
306
|
+
return memories
|
|
307
|
+
|
|
308
|
+
def get_memory_by_id(self, memory_id: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
309
|
+
"""Retrieves a single memory node by its unique primary key ID and optional tenant scope."""
|
|
310
|
+
with self._get_connection() as conn:
|
|
311
|
+
cursor = conn.cursor()
|
|
312
|
+
if tenant_id is not None:
|
|
313
|
+
cursor.execute(
|
|
314
|
+
"SELECT * FROM memories WHERE id = ? AND tenant_id = ?",
|
|
315
|
+
(memory_id, tenant_id)
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
cursor.execute("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
319
|
+
row = cursor.fetchone()
|
|
320
|
+
if row:
|
|
321
|
+
mem = dict(row)
|
|
322
|
+
mem["content"] = self.encryption_provider.decrypt(mem["content"])
|
|
323
|
+
mem["embedding"] = json.loads(
|
|
324
|
+
self.encryption_provider.decrypt(mem["embedding"])
|
|
325
|
+
)
|
|
326
|
+
return mem
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def update_access_count(
|
|
330
|
+
self, memory_id: str, count_increment: int = 1, tenant_id: Optional[str] = None
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Increases the usage metrics for Ebbinghaus stabilization weight tracking."""
|
|
333
|
+
with self._get_connection() as conn:
|
|
334
|
+
cursor = conn.cursor()
|
|
335
|
+
if tenant_id is not None:
|
|
336
|
+
cursor.execute("""
|
|
337
|
+
UPDATE memories
|
|
338
|
+
SET access_count = access_count + ?, last_accessed_at = ?
|
|
339
|
+
WHERE id = ? AND tenant_id = ?
|
|
340
|
+
""", (count_increment, time.time(), memory_id, tenant_id))
|
|
341
|
+
else:
|
|
342
|
+
cursor.execute("""
|
|
343
|
+
UPDATE memories
|
|
344
|
+
SET access_count = access_count + ?, last_accessed_at = ?
|
|
345
|
+
WHERE id = ?
|
|
346
|
+
""", (count_increment, time.time(), memory_id))
|
|
347
|
+
conn.commit()
|
|
348
|
+
|
|
349
|
+
def update_confidence(
|
|
350
|
+
self, memory_id: str, new_confidence: float, tenant_id: Optional[str] = None
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Saves mutated confidence values resulting from active RLAIF feedback."""
|
|
353
|
+
with self._get_connection() as conn:
|
|
354
|
+
cursor = conn.cursor()
|
|
355
|
+
if tenant_id is not None:
|
|
356
|
+
cursor.execute("""
|
|
357
|
+
UPDATE memories
|
|
358
|
+
SET confidence = ?
|
|
359
|
+
WHERE id = ? AND tenant_id = ?
|
|
360
|
+
""", (new_confidence, memory_id, tenant_id))
|
|
361
|
+
else:
|
|
362
|
+
cursor.execute("""
|
|
363
|
+
UPDATE memories
|
|
364
|
+
SET confidence = ?
|
|
365
|
+
WHERE id = ?
|
|
366
|
+
""", (new_confidence, memory_id))
|
|
367
|
+
conn.commit()
|
|
368
|
+
|
|
369
|
+
def delete_memory(self, memory_id: str, tenant_id: Optional[str] = None) -> None:
|
|
370
|
+
with self._get_connection() as conn:
|
|
371
|
+
cursor = conn.cursor()
|
|
372
|
+
if tenant_id is not None:
|
|
373
|
+
cursor.execute(
|
|
374
|
+
"DELETE FROM memories WHERE id = ? AND tenant_id = ?",
|
|
375
|
+
(memory_id, tenant_id)
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
cursor.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
|
|
379
|
+
conn.commit()
|
|
380
|
+
|
|
381
|
+
def clear_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
382
|
+
"""Clears memories for a given tenant or the entire store and returns count of deleted rows."""
|
|
383
|
+
with self._get_connection() as conn:
|
|
384
|
+
cursor = conn.cursor()
|
|
385
|
+
if tenant_id is not None:
|
|
386
|
+
cursor.execute("DELETE FROM memories WHERE tenant_id = ?", (tenant_id,))
|
|
387
|
+
else:
|
|
388
|
+
cursor.execute("DELETE FROM memories")
|
|
389
|
+
deleted_count = cursor.rowcount
|
|
390
|
+
conn.commit()
|
|
391
|
+
return deleted_count
|
|
392
|
+
|
|
393
|
+
def get_memories_by_category(
|
|
394
|
+
self, category: str, tenant_id: Optional[str] = None, include_superseded: bool = False
|
|
395
|
+
) -> List[Dict[str, Any]]:
|
|
396
|
+
"""Retrieves memories filtered by category, utilizing the category index.
|
|
397
|
+
|
|
398
|
+
Superseded rows are hidden by default (see get_all_memories).
|
|
399
|
+
"""
|
|
400
|
+
query = "SELECT * FROM memories WHERE category = ?"
|
|
401
|
+
params: List[Any] = [category]
|
|
402
|
+
if tenant_id is not None:
|
|
403
|
+
query += " AND tenant_id = ?"
|
|
404
|
+
params.append(tenant_id)
|
|
405
|
+
if not include_superseded:
|
|
406
|
+
query += " AND superseded_by IS NULL"
|
|
407
|
+
with self._get_connection() as conn:
|
|
408
|
+
cursor = conn.cursor()
|
|
409
|
+
cursor.execute(query, tuple(params))
|
|
410
|
+
rows = cursor.fetchall()
|
|
411
|
+
|
|
412
|
+
memories = []
|
|
413
|
+
for row in rows:
|
|
414
|
+
mem = dict(row)
|
|
415
|
+
mem["content"] = self.encryption_provider.decrypt(mem["content"])
|
|
416
|
+
mem["embedding"] = json.loads(
|
|
417
|
+
self.encryption_provider.decrypt(mem["embedding"])
|
|
418
|
+
)
|
|
419
|
+
memories.append(mem)
|
|
420
|
+
return memories
|
|
421
|
+
|
|
422
|
+
def vector_search(
|
|
423
|
+
self,
|
|
424
|
+
query_vec: List[float],
|
|
425
|
+
top_k: int = 10,
|
|
426
|
+
category: Optional[str] = None,
|
|
427
|
+
tenant_id: Optional[str] = None
|
|
428
|
+
) -> List[Dict[str, Any]]:
|
|
429
|
+
"""
|
|
430
|
+
Dense retrieval over the SQLite corpus using cosine similarity.
|
|
431
|
+
|
|
432
|
+
NOTE: SQLite is a single-node dev/local backend. This is an exact O(N)
|
|
433
|
+
in-memory scan, not an approximate HNSW index. For team/production scale
|
|
434
|
+
use the pgvector backend which pushes `embedding <=> query` down to SQL.
|
|
435
|
+
Results match the PGVectorMemoryStore.vector_search contract:
|
|
436
|
+
each node carries `cosine_similarity` and `relevance_score`.
|
|
437
|
+
"""
|
|
438
|
+
memories = (
|
|
439
|
+
self.get_memories_by_category(category, tenant_id=tenant_id)
|
|
440
|
+
if category
|
|
441
|
+
else self.get_all_memories(tenant_id=tenant_id)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
mag_q = sum(a * a for a in query_vec) ** 0.5
|
|
445
|
+
results: List[Dict[str, Any]] = []
|
|
446
|
+
for mem in memories:
|
|
447
|
+
vec = mem.get("embedding", [])
|
|
448
|
+
if vec and len(vec) == len(query_vec):
|
|
449
|
+
dot = sum(a * b for a, b in zip(query_vec, vec))
|
|
450
|
+
mag_v = sum(b * b for b in vec) ** 0.5
|
|
451
|
+
sim = dot / (mag_q * mag_v) if mag_q * mag_v > 0 else 0.0
|
|
452
|
+
else:
|
|
453
|
+
sim = 0.0
|
|
454
|
+
mem_copy = mem.copy()
|
|
455
|
+
mem_copy["cosine_similarity"] = sim
|
|
456
|
+
mem_copy["relevance_score"] = sim
|
|
457
|
+
results.append(mem_copy)
|
|
458
|
+
|
|
459
|
+
results.sort(key=lambda x: x["cosine_similarity"], reverse=True)
|
|
460
|
+
return results[:top_k]
|
|
461
|
+
|
|
462
|
+
def lexical_search(
|
|
463
|
+
self,
|
|
464
|
+
query: str,
|
|
465
|
+
top_k: int = 100,
|
|
466
|
+
category: Optional[str] = None,
|
|
467
|
+
tenant_id: Optional[str] = None
|
|
468
|
+
) -> List[Dict[str, Any]]:
|
|
469
|
+
"""
|
|
470
|
+
Sparse (lexical) retrieval over the SQLite corpus using token-overlap
|
|
471
|
+
ranking — the exact-scan analogue of PostgreSQL tsvector GIN ranking.
|
|
472
|
+
Matches the PGVectorMemoryStore.lexical_search contract: each node
|
|
473
|
+
carries `lexical_rank` and `relevance_score`.
|
|
474
|
+
"""
|
|
475
|
+
memories = (
|
|
476
|
+
self.get_memories_by_category(category, tenant_id=tenant_id)
|
|
477
|
+
if category
|
|
478
|
+
else self.get_all_memories(tenant_id=tenant_id)
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
query_terms = set(query.lower().split())
|
|
482
|
+
if not query_terms:
|
|
483
|
+
return []
|
|
484
|
+
|
|
485
|
+
results: List[Dict[str, Any]] = []
|
|
486
|
+
for mem in memories:
|
|
487
|
+
words = set(str(mem.get("content", "")).lower().split())
|
|
488
|
+
overlap = len(query_terms.intersection(words))
|
|
489
|
+
if overlap > 0:
|
|
490
|
+
mem_copy = mem.copy()
|
|
491
|
+
mem_copy["lexical_rank"] = float(overlap) / float(len(query_terms))
|
|
492
|
+
mem_copy["relevance_score"] = mem_copy["lexical_rank"]
|
|
493
|
+
results.append(mem_copy)
|
|
494
|
+
|
|
495
|
+
results.sort(key=lambda x: x.get("lexical_rank", 0.0), reverse=True)
|
|
496
|
+
return results[:top_k]
|
|
497
|
+
|
|
498
|
+
def count_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
499
|
+
with self._get_connection() as conn:
|
|
500
|
+
cursor = conn.cursor()
|
|
501
|
+
if tenant_id is not None:
|
|
502
|
+
cursor.execute(
|
|
503
|
+
"SELECT count(*) FROM memories WHERE tenant_id = ?", (tenant_id,)
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
cursor.execute("SELECT count(*) FROM memories")
|
|
507
|
+
return int(cursor.fetchone()[0])
|
|
508
|
+
|
|
509
|
+
def count_active_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
510
|
+
"""Count of retrieval-visible (non-superseded) memories."""
|
|
511
|
+
query = "SELECT count(*) FROM memories WHERE superseded_by IS NULL"
|
|
512
|
+
params: List[Any] = []
|
|
513
|
+
if tenant_id is not None:
|
|
514
|
+
query += " AND tenant_id = ?"
|
|
515
|
+
params.append(tenant_id)
|
|
516
|
+
with self._get_connection() as conn:
|
|
517
|
+
cursor = conn.cursor()
|
|
518
|
+
cursor.execute(query, tuple(params))
|
|
519
|
+
return int(cursor.fetchone()[0])
|
|
520
|
+
|
|
521
|
+
def query_candidates(
|
|
522
|
+
self,
|
|
523
|
+
category: Optional[str] = None,
|
|
524
|
+
tenant_id: Optional[str] = None,
|
|
525
|
+
min_confidence: float = 0.0,
|
|
526
|
+
limit: int = 100,
|
|
527
|
+
order_by: str = "last_accessed_at DESC"
|
|
528
|
+
) -> List[Dict[str, Any]]:
|
|
529
|
+
"""
|
|
530
|
+
Retrieves a bounded candidate set directly using SQL indexes
|
|
531
|
+
instead of loading the entire corpus into application memory.
|
|
532
|
+
"""
|
|
533
|
+
query = "SELECT * FROM memories WHERE confidence >= ? AND superseded_by IS NULL"
|
|
534
|
+
params: List[Any] = [min_confidence]
|
|
535
|
+
if tenant_id is not None:
|
|
536
|
+
query += " AND tenant_id = ?"
|
|
537
|
+
params.append(tenant_id)
|
|
538
|
+
if category:
|
|
539
|
+
query += " AND category = ?"
|
|
540
|
+
params.append(category)
|
|
541
|
+
|
|
542
|
+
safe_orders = {
|
|
543
|
+
"last_accessed_at DESC": "last_accessed_at DESC",
|
|
544
|
+
"created_at DESC": "created_at DESC",
|
|
545
|
+
"access_count DESC": "access_count DESC",
|
|
546
|
+
"confidence DESC": "confidence DESC"
|
|
547
|
+
}
|
|
548
|
+
order_clause = safe_orders.get(order_by, "last_accessed_at DESC")
|
|
549
|
+
query += f" ORDER BY {order_clause} LIMIT ?"
|
|
550
|
+
params.append(limit)
|
|
551
|
+
|
|
552
|
+
with self._get_connection() as conn:
|
|
553
|
+
cursor = conn.cursor()
|
|
554
|
+
cursor.execute(query, tuple(params))
|
|
555
|
+
rows = cursor.fetchall()
|
|
556
|
+
memories = []
|
|
557
|
+
for row in rows:
|
|
558
|
+
mem = dict(row)
|
|
559
|
+
mem["content"] = self.encryption_provider.decrypt(mem["content"])
|
|
560
|
+
mem["embedding"] = json.loads(
|
|
561
|
+
self.encryption_provider.decrypt(mem["embedding"])
|
|
562
|
+
)
|
|
563
|
+
memories.append(mem)
|
|
564
|
+
return memories
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangChain Integration Example and backward-compatible adapter.
|
|
3
|
+
Uses the production SynapseLangChainMemory from synapse_memory.integrations.langchain.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from synapse_memory.integrations.langchain import (
|
|
7
|
+
SynapseLangChainMemory,
|
|
8
|
+
HAS_LANGCHAIN,
|
|
9
|
+
BaseMemory,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["SynapseLangChainMemory", "HAS_LANGCHAIN", "BaseMemory"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LlamaIndex Integration Example and backward-compatible adapter.
|
|
3
|
+
Uses the production SynapseLlamaIndexMemory from synapse_memory.integrations.llamaindex.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from synapse_memory.integrations.llamaindex import (
|
|
7
|
+
SynapseLlamaIndexMemory,
|
|
8
|
+
HAS_LLAMAINDEX,
|
|
9
|
+
BaseMemory,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["SynapseLlamaIndexMemory", "HAS_LLAMAINDEX", "BaseMemory"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Production framework integrations for Synapse Memory.
|
|
3
|
+
Provides drop-in memory modules for LangChain and LlamaIndex.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from synapse_memory.integrations.langchain import SynapseLangChainMemory
|
|
7
|
+
from synapse_memory.integrations.llamaindex import SynapseLlamaIndexMemory
|
|
8
|
+
|
|
9
|
+
__all__ = ["SynapseLangChainMemory", "SynapseLlamaIndexMemory"]
|