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.
@@ -0,0 +1,1276 @@
1
+ """
2
+ Production PostgreSQL + pgvector + HNSW Memory Store with Database-Level Multi-Tenancy
3
+ and PostgreSQL Row-Level Security (RLS).
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from synapse_memory.core.encryption import (
14
+ FernetEncryptionProvider,
15
+ KeyManagementProvider,
16
+ )
17
+
18
+ logger = logging.getLogger("PGVectorMemoryStore")
19
+
20
+
21
+ try:
22
+ import psycopg2
23
+ from psycopg2 import pool
24
+ from psycopg2.extras import RealDictCursor
25
+ HAS_PSYCOPG2 = True
26
+ except ImportError:
27
+ try:
28
+ import psycopg as psycopg2 # type: ignore
29
+ from psycopg.rows import dict_row as RealDictCursor # type: ignore
30
+ pool = None # type: ignore
31
+ HAS_PSYCOPG2 = True
32
+ except ImportError:
33
+ HAS_PSYCOPG2 = False
34
+ psycopg2 = None # type: ignore
35
+ pool = None # type: ignore
36
+ RealDictCursor = None # type: ignore
37
+
38
+
39
+ def searchable_projection(content: str) -> str:
40
+ """Value to store in ``content_plain`` — the lexical/FTS search projection.
41
+
42
+ ``content`` is Fernet-encrypted before storage, but lexical and full-text
43
+ search need an unencrypted, indexable copy, so ``content_plain`` (and the
44
+ tsvector built from it) is deliberately PLAINTEXT (F-05). That is a
45
+ searchability trade-off, not encryption: a database dump or backup can
46
+ recover memory text from this column.
47
+
48
+ Set ``SYNAPSE_STORE_SEARCHABLE_PLAINTEXT=false`` for data that must never be
49
+ persisted in the clear: the projection is then stored empty, ``content``
50
+ remains encrypted, and lexical/FTS search returns no matches (vector search
51
+ is unaffected).
52
+ """
53
+ if os.environ.get("SYNAPSE_STORE_SEARCHABLE_PLAINTEXT", "true").strip().lower() == "true":
54
+ return content
55
+ return ""
56
+
57
+
58
+ # F-15: the in-process fallback cache is an offline/dev convenience, not a second
59
+ # copy of production data. Bound it so it cannot grow without limit when the
60
+ # database is unavailable.
61
+ _FALLBACK_CACHE_MAX = 1000
62
+
63
+
64
+ class PGVectorMemoryStore:
65
+ """
66
+ Enterprise-grade Postgres + pgvector + HNSW storage engine.
67
+ Executes vector similarity search directly in SQL using HNSW graph indexes
68
+ with database-level multi-tenancy, Row-Level Security (RLS), and payload encryption.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ connection_string: Optional[str] = None,
74
+ dimension: int = 128,
75
+ m: int = 16,
76
+ ef_construction: int = 64,
77
+ encryption_provider: Optional[Any] = None,
78
+ auto_init: bool = True,
79
+ fail_closed: bool = False,
80
+ apply_schema: bool = True,
81
+ admin_role: Optional[str] = None,
82
+ runtime_role: Optional[str] = None,
83
+ ):
84
+ # SECURITY: never ship a default password. Require explicit DATABASE_URL in prod.
85
+ # Local dev convenience default uses an obviously-insecure placeholder and logs a warning.
86
+ self.connection_string = connection_string or os.environ.get(
87
+ "DATABASE_URL", "postgresql://synapse_admin:CHANGEME_dev_only@127.0.0.1:5432/synapse_cognitive"
88
+ )
89
+ if "CHANGEME" in self.connection_string:
90
+ logger.warning(
91
+ "Using insecure dev-only Postgres credentials. Set DATABASE_URL with a strong password for any shared deployment."
92
+ )
93
+ self.dimension = dimension
94
+ self.m = m
95
+ self.ef_construction = ef_construction
96
+ self.fail_closed = fail_closed
97
+ # apply_schema=False = "migrate-then-run" production topology: DDL and the
98
+ # migration ledger are owned by a separate migration role/step, and the
99
+ # engine connects with a least-privilege runtime role that has DML only.
100
+ # See scripts/provision_db_roles.py. Default True preserves the
101
+ # single-role local/dev behaviour.
102
+ self.apply_schema = apply_schema
103
+
104
+ resolved_admin = admin_role or os.environ.get("SYNAPSE_DB_ADMIN_ROLE", "synapse_admin")
105
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$", resolved_admin):
106
+ raise ValueError(f"admin_role must be a safe identifier, got: {resolved_admin!r}")
107
+ self.admin_role = resolved_admin
108
+
109
+ resolved_runtime = runtime_role or os.environ.get("SYNAPSE_DB_RUNTIME_ROLE", "synapse_app")
110
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$", resolved_runtime):
111
+ raise ValueError(f"runtime_role must be a safe identifier, got: {resolved_runtime!r}")
112
+ self.runtime_role = resolved_runtime
113
+
114
+ self._pool: Optional[Any] = None
115
+ self._connected = False
116
+
117
+ if encryption_provider is not None:
118
+ self.encryption_provider = encryption_provider
119
+ else:
120
+ key = KeyManagementProvider.get_encryption_key()
121
+ self.encryption_provider = FernetEncryptionProvider(key)
122
+
123
+ # In-memory shadow cache for local development/testing fallback
124
+ self._fallback_cache: Dict[str, Dict[str, Any]] = {}
125
+
126
+ if HAS_PSYCOPG2 and auto_init:
127
+ self._connect()
128
+
129
+ @property
130
+ def is_connected(self) -> bool:
131
+ return self._connected
132
+
133
+ def pool_stats(self) -> Dict[str, int]:
134
+ """Live pool utilization for /metrics (no secrets)."""
135
+ if self._pool is None:
136
+ return {"pool_size": 0, "in_use": 0}
137
+ try:
138
+ # psycopg2 pools track min/max; used = checked-out count via internal queue.
139
+ maxconn = getattr(self._pool, "maxconn", 10)
140
+ used = maxconn - len(getattr(self._pool, "_pool", []))
141
+ return {"pool_size": int(maxconn), "in_use": max(0, int(used))}
142
+ except Exception:
143
+ return {"pool_size": 0, "in_use": 0}
144
+
145
+ def _connect(self) -> None:
146
+ try:
147
+ if pool is not None:
148
+ # Thread-safe pool: FastAPI handles requests on worker threads.
149
+ pool_cls = getattr(pool, "ThreadedConnectionPool", None) or pool.SimpleConnectionPool
150
+ self._pool = pool_cls(
151
+ 1, 10, self.connection_string, connect_timeout=5
152
+ )
153
+ self._connected = True
154
+ if self.apply_schema:
155
+ self._initialize_database()
156
+ else:
157
+ # Runtime-role topology: the operator already ran DDL + migrations.
158
+ # Verify that, so a missing table fails loudly instead of silently
159
+ # serving from the in-memory fallback cache.
160
+ self._verify_schema_present()
161
+ logger.info("PGVectorMemoryStore successfully connected to PostgreSQL.")
162
+ except Exception as e:
163
+ self._connected = False
164
+ if self.fail_closed:
165
+ logger.error(f"PostgreSQL/pgvector connection failed in fail-closed mode: {e}")
166
+ raise RuntimeError(f"PostgreSQL connection failed: {e}")
167
+ logger.warning(
168
+ f"PostgreSQL/pgvector connection unviable ({e}); operating in fallback mode."
169
+ )
170
+
171
+ def _verify_schema_present(self) -> None:
172
+ """Read-only guard for the ``apply_schema=False`` (runtime-role) topology.
173
+
174
+ When the engine runs as a least-privilege DML-only role it must not attempt
175
+ DDL, so we cannot create the schema on start-up. Instead we prove the
176
+ schema was provisioned by the migration step; if ``memories`` is absent we
177
+ raise rather than silently degrading to the in-memory fallback cache
178
+ (which would fake successful writes and lose data on restart).
179
+ Also verifies the connecting runtime database role is not a superuser,
180
+ does not have BYPASSRLS, does not own table 'memories', and is not the
181
+ privileged admin role.
182
+ """
183
+ conn = self._get_connection()
184
+ try:
185
+ with conn.cursor() as cur:
186
+ cur.execute("SELECT to_regclass('public.memories') IS NOT NULL;")
187
+ row = cur.fetchone()
188
+ present = bool(row[0]) if row else False
189
+ if not present:
190
+ raise RuntimeError(
191
+ "Schema not provisioned: table 'memories' is missing. Run the "
192
+ "migration step (SQLite/Postgres DDL + migrations) before starting "
193
+ "the engine with apply_schema=False."
194
+ )
195
+ cur.execute(
196
+ """
197
+ SELECT r.rolsuper, r.rolbypassrls, t.tableowner, current_user
198
+ FROM pg_roles r
199
+ CROSS JOIN (
200
+ SELECT tableowner FROM pg_tables WHERE schemaname = 'public' AND tablename = 'memories'
201
+ ) t
202
+ WHERE r.rolname = current_user;
203
+ """
204
+ )
205
+ res = cur.fetchone()
206
+ if res:
207
+ is_super = res[0] if isinstance(res, (tuple, list)) else res.get("rolsuper")
208
+ has_bypassrls = res[1] if isinstance(res, (tuple, list)) else res.get("rolbypassrls")
209
+ owner = res[2] if isinstance(res, (tuple, list)) else res.get("tableowner")
210
+ curr_user = res[3] if isinstance(res, (tuple, list)) else res.get("current_user")
211
+ if is_super:
212
+ raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' is a SUPERUSER.")
213
+ if has_bypassrls:
214
+ raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' has BYPASSRLS privilege.")
215
+ if owner == curr_user:
216
+ raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' owns table 'memories'.")
217
+ if curr_user == self.admin_role:
218
+ raise RuntimeError(f"Security violation: runtime connections must not use admin role '{curr_user}'.")
219
+ finally:
220
+ self._return_connection(conn)
221
+
222
+ def _apply_rls_policy(self, cur: Any) -> None:
223
+ """Applies or updates the row-level security policy idempotently.
224
+
225
+ Enforces tenant isolation for normal operations:
226
+ tenant_id = NULLIF(current_setting('app.current_tenant', true), '') AND tenant_id IS NOT NULL
227
+ Matching USING and WITH CHECK clauses ensure cross-tenant writes/updates are rejected (SQLSTATE 42501).
228
+ Intentional cross-tenant administrative operations require the admin role and explicit bypass flag.
229
+ """
230
+ cur.execute("ALTER TABLE memories ENABLE ROW LEVEL SECURITY;")
231
+ cur.execute("ALTER TABLE memories FORCE ROW LEVEL SECURITY;")
232
+ admin_quoted = self.admin_role.replace("'", "''")
233
+ rls_sql = f"""
234
+ DO $$
235
+ BEGIN
236
+ DROP POLICY IF EXISTS tenant_isolation_policy ON memories;
237
+ CREATE POLICY tenant_isolation_policy ON memories
238
+ FOR ALL
239
+ USING (
240
+ (
241
+ tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
242
+ AND tenant_id IS NOT NULL
243
+ )
244
+ OR (
245
+ current_user = '{admin_quoted}'
246
+ AND current_setting('app.admin_bypass_rls', true) = 'on'
247
+ )
248
+ )
249
+ WITH CHECK (
250
+ (
251
+ tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
252
+ AND tenant_id IS NOT NULL
253
+ )
254
+ OR (
255
+ current_user = '{admin_quoted}'
256
+ AND current_setting('app.admin_bypass_rls', true) = 'on'
257
+ )
258
+ );
259
+ END $$;
260
+ """
261
+ cur.execute(rls_sql)
262
+
263
+ def _initialize_database(self) -> None:
264
+ if not self._connected or not HAS_PSYCOPG2:
265
+ return
266
+ conn = self._get_connection()
267
+ try:
268
+ with conn.cursor() as cur:
269
+ # 1. Enable extensions. On a least-privilege migration role these
270
+ # may already be installed by a DBA; CREATE EXTENSION needs
271
+ # superuser for non-trusted extensions, so treat it as deferrable
272
+ # rather than fatal (mirrors the FTS block below).
273
+ try:
274
+ cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
275
+ cur.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
276
+ except Exception as ext_err:
277
+ logger.warning(
278
+ "Extension initialization deferred (install `vector` and "
279
+ "`pg_trgm` as a DBA if this role lacks CREATE EXTENSION): %s",
280
+ ext_err,
281
+ )
282
+ conn.rollback()
283
+
284
+ # 2. Main cognitive memories table with tenant_id
285
+ cur.execute(f"""
286
+ CREATE TABLE IF NOT EXISTS memories (
287
+ id VARCHAR(128) PRIMARY KEY,
288
+ tenant_id VARCHAR(128) NOT NULL DEFAULT 'default',
289
+ content TEXT NOT NULL,
290
+ category VARCHAR(64) NOT NULL,
291
+ confidence REAL NOT NULL,
292
+ created_at DOUBLE PRECISION NOT NULL,
293
+ last_accessed_at DOUBLE PRECISION NOT NULL,
294
+ access_count INTEGER NOT NULL DEFAULT 0,
295
+ token_cost INTEGER NOT NULL DEFAULT 0,
296
+ feedback_multiplier REAL NOT NULL DEFAULT 1.0,
297
+ supersedes VARCHAR(128),
298
+ superseded_by VARCHAR(128),
299
+ embedding vector({self.dimension})
300
+ );
301
+ """)
302
+
303
+ # 3. Column migration if table already exists
304
+ cur.execute("""
305
+ DO $$
306
+ BEGIN
307
+ IF NOT EXISTS (
308
+ SELECT 1 FROM information_schema.columns
309
+ WHERE table_name='memories' AND column_name='tenant_id'
310
+ ) THEN
311
+ ALTER TABLE memories ADD COLUMN tenant_id VARCHAR(128) NOT NULL DEFAULT 'default';
312
+ END IF;
313
+ END $$;
314
+ """)
315
+
316
+ # 3a. Supersession chain columns (deduplication lineage)
317
+ cur.execute("""
318
+ DO $$
319
+ BEGIN
320
+ IF NOT EXISTS (
321
+ SELECT 1 FROM information_schema.columns
322
+ WHERE table_name='memories' AND column_name='supersedes'
323
+ ) THEN
324
+ ALTER TABLE memories ADD COLUMN supersedes VARCHAR(128);
325
+ END IF;
326
+ IF NOT EXISTS (
327
+ SELECT 1 FROM information_schema.columns
328
+ WHERE table_name='memories' AND column_name='superseded_by'
329
+ ) THEN
330
+ ALTER TABLE memories ADD COLUMN superseded_by VARCHAR(128);
331
+ END IF;
332
+ END $$;
333
+ """)
334
+
335
+ # 3b. Vector dimension migration if column dimension differs from configured target
336
+ try:
337
+ cur.execute("""
338
+ SELECT atttypmod FROM pg_attribute
339
+ WHERE attrelid = 'memories'::regclass AND attname = 'embedding';
340
+ """)
341
+ row = cur.fetchone()
342
+ if row:
343
+ current_dim = row[0] if isinstance(row, (tuple, list)) else row.get("atttypmod")
344
+ if current_dim and current_dim > 0 and current_dim != self.dimension:
345
+ logger.warning(
346
+ f"Migrating PostgreSQL 'memories.embedding' column dimension from {current_dim} to {self.dimension}..."
347
+ )
348
+ cur.execute("DROP INDEX IF EXISTS idx_memories_hnsw;")
349
+ cur.execute(f"ALTER TABLE memories ALTER COLUMN embedding TYPE vector({self.dimension});")
350
+ except Exception as mig_err:
351
+ logger.warning(f"Vector dimension check/migration skipped: {mig_err}")
352
+
353
+ # 4. HNSW vector index
354
+ cur.execute(f"""
355
+ CREATE INDEX IF NOT EXISTS idx_memories_hnsw
356
+ ON memories USING hnsw (embedding vector_cosine_ops)
357
+ WITH (m = {self.m}, ef_construction = {self.ef_construction});
358
+ """)
359
+
360
+ # 5. Relational and tenant indexes
361
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_tenant ON memories(tenant_id);")
362
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_tenant_cat ON memories(tenant_id, category);")
363
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_cat ON memories(category);")
364
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);")
365
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_access ON memories(last_accessed_at DESC);")
366
+
367
+ # 5b. Combined protection: `content` holds Fernet ciphertext;
368
+ # `content_plain` is the searchable projection feeding the
369
+ # tsvector index. Threat model: volume/database encryption
370
+ # protects everything at rest; field encryption protects the
371
+ # authoritative content column. Embeddings + tsvector remain
372
+ # readable derivatives by design (required for HNSW + BM25).
373
+ try:
374
+ cur.execute("""
375
+ DO $$
376
+ BEGIN
377
+ IF NOT EXISTS (
378
+ SELECT 1 FROM information_schema.columns
379
+ WHERE table_name='memories' AND column_name='content_plain'
380
+ ) THEN
381
+ ALTER TABLE memories ADD COLUMN content_plain TEXT NOT NULL DEFAULT '';
382
+ END IF;
383
+ END $$;
384
+ """)
385
+ cur.execute("""
386
+ DO $$
387
+ BEGIN
388
+ IF NOT EXISTS (
389
+ SELECT 1 FROM information_schema.columns
390
+ WHERE table_name='memories' AND column_name='search_vector'
391
+ ) THEN
392
+ ALTER TABLE memories
393
+ ADD COLUMN search_vector tsvector
394
+ GENERATED ALWAYS AS (to_tsvector('english', content_plain)) STORED;
395
+ END IF;
396
+ END $$;
397
+ """)
398
+ cur.execute("""
399
+ CREATE INDEX IF NOT EXISTS idx_memories_search_vector
400
+ ON memories USING GIN(search_vector);
401
+ """)
402
+ except Exception as fts_err:
403
+ logger.warning(f"Full-Text Search column initialization deferred: {fts_err}")
404
+
405
+ # 6. Database Row-Level Security (RLS)
406
+ self._apply_rls_policy(cur)
407
+ conn.commit()
408
+ except Exception as e:
409
+ conn.rollback()
410
+ if self.fail_closed:
411
+ raise RuntimeError(
412
+ f"PostgreSQL/pgvector initialization failed: {e}"
413
+ ) from e
414
+ logger.warning(
415
+ f"PostgreSQL initialization failed; using fallback mode: {e}"
416
+ )
417
+ finally:
418
+ self._return_connection(conn)
419
+
420
+ # Apply forward-only schema migrations (embedding_dimension / model_version
421
+ # + migration ledger). Versioned + idempotent across restarts.
422
+ try:
423
+ from synapse_memory.core.migrations import run_migrations
424
+ run_migrations(
425
+ self._get_connection,
426
+ is_postgres=True,
427
+ fail_closed=self.fail_closed,
428
+ owns_connection=False,
429
+ )
430
+ except Exception as mig_err:
431
+ if self.fail_closed:
432
+ # Fail-closed deployments must NEVER boot with a half-migrated
433
+ # schema: defer-and-warn turns a migration bug into silent
434
+ # data-shape corruption at query time.
435
+ raise RuntimeError(f"Schema migrations failed (fail_closed): {mig_err}") from mig_err
436
+ logger.warning(f"Schema migrations deferred: {mig_err}")
437
+
438
+ def _handle_db_error(self, operation: str, error: Exception) -> None:
439
+ if self.fail_closed:
440
+ raise RuntimeError(
441
+ f"PostgreSQL operation failed: {operation}: {error}"
442
+ ) from error
443
+ logger.error(
444
+ "PostgreSQL operation failed: %s: %s",
445
+ operation,
446
+ error
447
+ )
448
+
449
+ def _encrypt_field(self, plaintext: str) -> str:
450
+ try:
451
+ return self.encryption_provider.encrypt(plaintext)
452
+ except Exception as e:
453
+ logger.error("Field encryption failed: %s", e)
454
+ raise RuntimeError(f"Field encryption failed: {e}") from e
455
+
456
+ def _decrypt_field(self, ciphertext: str) -> str:
457
+ try:
458
+ return self.encryption_provider.decrypt(ciphertext)
459
+ except Exception as e:
460
+ logger.error("Field decryption failed (wrong key or corrupt payload).")
461
+ raise RuntimeError("Field decryption failed: wrong key or corrupt payload.") from e
462
+
463
+ def _decrypt_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
464
+ mem = dict(row)
465
+ content = mem.get("content")
466
+ if isinstance(content, str) and content:
467
+ try:
468
+ mem["content"] = self._decrypt_field(content)
469
+ except Exception:
470
+ # Fail safe: do not return ciphertext as if it were plaintext.
471
+ raise
472
+ if isinstance(mem.get("embedding"), str):
473
+ try:
474
+ mem["embedding"] = json.loads(mem["embedding"])
475
+ except Exception:
476
+ pass
477
+ mem.pop("search_vector", None)
478
+ return mem
479
+
480
+ def _get_connection(self) -> Any:
481
+ if self._pool is not None:
482
+ return self._pool.getconn()
483
+ if psycopg2 is not None:
484
+ return psycopg2.connect(self.connection_string)
485
+ raise RuntimeError("psycopg2 driver not installed.")
486
+
487
+ def _return_connection(self, conn: Any) -> None:
488
+ if self._pool is not None:
489
+ try:
490
+ # Roll back any open transaction so SET LOCAL tenant context
491
+ # and failed statements never leak into the next checkout.
492
+ conn.rollback()
493
+ except Exception:
494
+ pass
495
+ try:
496
+ self._pool.putconn(conn)
497
+ except Exception:
498
+ try:
499
+ conn.close()
500
+ except Exception:
501
+ pass
502
+ else:
503
+ try:
504
+ conn.close()
505
+ except Exception:
506
+ pass
507
+
508
+ def _set_tenant_context(self, cur: Any, tenant_id: Optional[str]) -> None:
509
+ # None / "*" means admin (unscoped) access: the RLS policy gates the
510
+ # admin bypass on current_user = <admin_role>, not on a settable GUC
511
+ # sentinel. Empty string means "no tenant" and must see zero rows
512
+ # (fail-closed isolation).
513
+ if tenant_id is None or tenant_id == "*":
514
+ ctx = ""
515
+ else:
516
+ ctx = tenant_id
517
+ cur.execute("SET LOCAL app.current_tenant = %s;", (ctx,))
518
+
519
+ def _cache_fallback(self, memory: Dict[str, Any]) -> None:
520
+ """Bounded insert into the offline fallback cache (F-15)."""
521
+ if len(self._fallback_cache) >= _FALLBACK_CACHE_MAX:
522
+ overflow = len(self._fallback_cache) - _FALLBACK_CACHE_MAX + 1
523
+ for key in list(self._fallback_cache.keys())[:overflow]:
524
+ del self._fallback_cache[key]
525
+ self._fallback_cache[memory["id"]] = memory
526
+
527
+ def insert_memory(self, memory: Dict[str, Any]) -> None:
528
+ """Inserts memory with embedding vector into pgvector table with tenant isolation.
529
+
530
+ Combined protection: `content` stores Fernet ciphertext;
531
+ `content_plain` stores the searchable projection for tsvector/BM25.
532
+ """
533
+ tenant_id = memory.get("tenant_id") or memory.get("tenantId") or "default"
534
+
535
+ if not self._connected or not HAS_PSYCOPG2:
536
+ # Offline/dev mode only: with no database the in-process cache IS the
537
+ # store. Previously every insert was mirrored here even when
538
+ # PostgreSQL was connected, keeping a second plaintext copy of the
539
+ # content and embedding per row (F-15).
540
+ mem_copy = memory.copy()
541
+ mem_copy["tenant_id"] = tenant_id
542
+ self._cache_fallback(mem_copy)
543
+ return
544
+
545
+ conn = self._get_connection()
546
+ try:
547
+ with conn.cursor() as cur:
548
+ self._set_tenant_context(cur, tenant_id)
549
+ emb = memory.get("embedding", [])
550
+ if len(emb) != self.dimension:
551
+ raise RuntimeError(
552
+ f"Embedding dimension mismatch: storage is configured for "
553
+ f"{self.dimension}-d vectors, received {len(emb)}-d."
554
+ )
555
+ emb_str = f"[{','.join(str(float(x)) for x in emb)}]" if emb else None
556
+ plaintext = str(memory["content"])
557
+ ciphertext = self._encrypt_field(plaintext)
558
+ # F-05: content_plain is a PLAINTEXT search projection, not
559
+ # encrypted data. Operators who cannot persist plaintext at rest
560
+ # can opt out; the column is then empty and lexical/FTS search
561
+ # finds nothing (vector search is unaffected).
562
+ projection = searchable_projection(plaintext)
563
+ cur.execute("""
564
+ INSERT INTO memories (
565
+ id, tenant_id, content, content_plain, category, confidence, created_at,
566
+ last_accessed_at, access_count, token_cost,
567
+ feedback_multiplier, embedding, embedding_dimension, model_version,
568
+ supersedes, superseded_by
569
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
570
+ ON CONFLICT (id) DO UPDATE SET
571
+ tenant_id = EXCLUDED.tenant_id,
572
+ content = EXCLUDED.content,
573
+ content_plain = EXCLUDED.content_plain,
574
+ category = EXCLUDED.category,
575
+ confidence = EXCLUDED.confidence,
576
+ last_accessed_at = EXCLUDED.last_accessed_at,
577
+ access_count = EXCLUDED.access_count,
578
+ token_cost = EXCLUDED.token_cost,
579
+ feedback_multiplier = EXCLUDED.feedback_multiplier,
580
+ embedding = EXCLUDED.embedding,
581
+ embedding_dimension = EXCLUDED.embedding_dimension,
582
+ model_version = EXCLUDED.model_version,
583
+ supersedes = EXCLUDED.supersedes,
584
+ superseded_by = EXCLUDED.superseded_by;
585
+ """, (
586
+ memory["id"],
587
+ tenant_id,
588
+ ciphertext,
589
+ projection,
590
+ memory["category"],
591
+ memory["confidence"],
592
+ memory["created_at"],
593
+ memory.get("last_accessed_at", time.time()),
594
+ memory.get("access_count", 0),
595
+ memory.get("token_cost", 0),
596
+ memory.get("feedback_multiplier", 1.0),
597
+ emb_str,
598
+ memory.get("embedding_dimension", len(emb or [])),
599
+ memory.get("model_version", "unknown"),
600
+ memory.get("supersedes"),
601
+ memory.get("superseded_by")
602
+ ))
603
+ conn.commit()
604
+ except Exception as e:
605
+ conn.rollback()
606
+ self._handle_db_error("insert_memory", e)
607
+ finally:
608
+ self._return_connection(conn)
609
+
610
+ def mark_superseded(self, memory_id: str, superseded_by: str, tenant_id: Optional[str] = None) -> None:
611
+ """Stamp `superseded_by` on an old memory that a dedup MERGE replaced.
612
+
613
+ Failure is non-fatal for ingestion (the caller guards with try/except),
614
+ but DB errors still flow through _handle_db_error for observability.
615
+ """
616
+ cached = self._fallback_cache.get(memory_id)
617
+ if cached is not None:
618
+ cached["superseded_by"] = superseded_by
619
+
620
+ if not self._connected or not HAS_PSYCOPG2:
621
+ return
622
+
623
+ conn = self._get_connection()
624
+ try:
625
+ with conn.cursor() as cur:
626
+ self._set_tenant_context(cur, tenant_id)
627
+ cur.execute(
628
+ "UPDATE memories SET superseded_by = %s WHERE id = %s;",
629
+ (superseded_by, memory_id),
630
+ )
631
+ conn.commit()
632
+ except Exception as e:
633
+ conn.rollback()
634
+ self._handle_db_error("mark_superseded", e)
635
+ finally:
636
+ self._return_connection(conn)
637
+
638
+ def get_memory_by_id(self, memory_id: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
639
+ if not self._connected or not HAS_PSYCOPG2:
640
+ mem = self._fallback_cache.get(memory_id)
641
+ if mem and tenant_id and mem.get("tenant_id") != tenant_id:
642
+ return None
643
+ return mem
644
+
645
+ conn = self._get_connection()
646
+ try:
647
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
648
+ self._set_tenant_context(cur, tenant_id)
649
+ if tenant_id:
650
+ cur.execute("SELECT * FROM memories WHERE id = %s AND tenant_id = %s", (memory_id, tenant_id))
651
+ else:
652
+ cur.execute("SELECT * FROM memories WHERE id = %s", (memory_id,))
653
+ row = cur.fetchone()
654
+ if row:
655
+ return self._decrypt_row(dict(row))
656
+ return None
657
+ except Exception as e:
658
+ self._handle_db_error("get_memory", e)
659
+ return None
660
+ finally:
661
+ self._return_connection(conn)
662
+
663
+ def get_memories_by_category(
664
+ self, category: str, tenant_id: Optional[str] = None, include_superseded: bool = False
665
+ ) -> List[Dict[str, Any]]:
666
+ """Active-only by default: superseded rows are lineage/audit records."""
667
+ if not self._connected or not HAS_PSYCOPG2:
668
+ return [
669
+ m for m in self._fallback_cache.values()
670
+ if m.get("category") == category
671
+ and (tenant_id is None or m.get("tenant_id") == tenant_id)
672
+ and (include_superseded or not m.get("superseded_by"))
673
+ ]
674
+
675
+ conn = self._get_connection()
676
+ try:
677
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
678
+ self._set_tenant_context(cur, tenant_id)
679
+ supersession = "" if include_superseded else " AND superseded_by IS NULL"
680
+ if tenant_id:
681
+ cur.execute(
682
+ "SELECT * FROM memories WHERE category = %s AND tenant_id = %s" + supersession,
683
+ (category, tenant_id),
684
+ )
685
+ else:
686
+ cur.execute(
687
+ "SELECT * FROM memories WHERE category = %s" + supersession,
688
+ (category,),
689
+ )
690
+ rows = cur.fetchall()
691
+ results = []
692
+ for r in rows:
693
+ results.append(self._decrypt_row(dict(r)))
694
+ return results
695
+ except Exception as e:
696
+ self._handle_db_error("get_memories_by_category", e)
697
+ return []
698
+ finally:
699
+ self._return_connection(conn)
700
+
701
+ def update_access_count(self, memory_id: str, count_increment: int = 1, tenant_id: Optional[str] = None) -> None:
702
+ if memory_id in self._fallback_cache:
703
+ self._fallback_cache[memory_id]["access_count"] = (
704
+ self._fallback_cache[memory_id].get("access_count", 0) + count_increment
705
+ )
706
+ self._fallback_cache[memory_id]["last_accessed_at"] = time.time()
707
+
708
+ if not self._connected or not HAS_PSYCOPG2:
709
+ return
710
+
711
+ conn = self._get_connection()
712
+ try:
713
+ with conn.cursor() as cur:
714
+ self._set_tenant_context(cur, tenant_id)
715
+ if tenant_id:
716
+ cur.execute("""
717
+ UPDATE memories
718
+ SET access_count = access_count + %s, last_accessed_at = %s
719
+ WHERE id = %s AND tenant_id = %s
720
+ """, (count_increment, time.time(), memory_id, tenant_id))
721
+ else:
722
+ cur.execute("""
723
+ UPDATE memories
724
+ SET access_count = access_count + %s, last_accessed_at = %s
725
+ WHERE id = %s
726
+ """, (count_increment, time.time(), memory_id))
727
+ conn.commit()
728
+ except Exception as e:
729
+ conn.rollback()
730
+ self._handle_db_error("update_access_count", e)
731
+ finally:
732
+ self._return_connection(conn)
733
+
734
+ def update_confidence(self, memory_id: str, new_confidence: float, tenant_id: Optional[str] = None) -> None:
735
+ if memory_id in self._fallback_cache:
736
+ self._fallback_cache[memory_id]["confidence"] = new_confidence
737
+
738
+ if not self._connected or not HAS_PSYCOPG2:
739
+ return
740
+
741
+ conn = self._get_connection()
742
+ try:
743
+ with conn.cursor() as cur:
744
+ self._set_tenant_context(cur, tenant_id)
745
+ if tenant_id:
746
+ cur.execute("UPDATE memories SET confidence = %s WHERE id = %s AND tenant_id = %s", (new_confidence, memory_id, tenant_id))
747
+ else:
748
+ cur.execute("UPDATE memories SET confidence = %s WHERE id = %s", (new_confidence, memory_id))
749
+ conn.commit()
750
+ except Exception as e:
751
+ conn.rollback()
752
+ self._handle_db_error("update_confidence", e)
753
+ finally:
754
+ self._return_connection(conn)
755
+
756
+ def vector_search(
757
+ self,
758
+ query_vec: List[float],
759
+ top_k: int = 10,
760
+ category: Optional[str] = None,
761
+ tenant_id: Optional[str] = None
762
+ ) -> List[Dict[str, Any]]:
763
+ """
764
+ Executes native pgvector cosine distance search using HNSW index:
765
+ ORDER BY embedding <=> query_vec::vector LIMIT top_k
766
+ """
767
+ if not self._connected or not HAS_PSYCOPG2:
768
+ results = []
769
+ for mem in self._fallback_cache.values():
770
+ if tenant_id and mem.get("tenant_id") != tenant_id:
771
+ continue
772
+ if category and mem.get("category") != category:
773
+ continue
774
+ v = mem.get("embedding", [])
775
+ if v and len(v) == len(query_vec):
776
+ dot = sum(a * b for a, b in zip(query_vec, v))
777
+ mag_a = sum(a * a for a in query_vec) ** 0.5
778
+ mag_b = sum(b * b for b in v) ** 0.5
779
+ sim = dot / (mag_a * mag_b) if mag_a * mag_b > 0 else 0.0
780
+ else:
781
+ sim = 0.0
782
+ mem_copy = mem.copy()
783
+ mem_copy["cosine_similarity"] = sim
784
+ mem_copy["relevance_score"] = sim
785
+ results.append(mem_copy)
786
+ results.sort(key=lambda x: x["cosine_similarity"], reverse=True)
787
+ return results[:top_k]
788
+
789
+ conn = self._get_connection()
790
+ try:
791
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
792
+ self._set_tenant_context(cur, tenant_id)
793
+ emb_str = f"[{','.join(str(float(x)) for x in query_vec)}]"
794
+
795
+ conditions = []
796
+ params: List[Any] = [emb_str]
797
+ conditions.append("superseded_by IS NULL")
798
+ if tenant_id:
799
+ conditions.append("tenant_id = %s")
800
+ params.append(tenant_id)
801
+ if category:
802
+ conditions.append("category = %s")
803
+ params.append(category)
804
+
805
+ where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
806
+ params.extend([emb_str, top_k])
807
+
808
+ query = f"""
809
+ SELECT id, tenant_id, content, category, confidence, created_at,
810
+ last_accessed_at, access_count, token_cost, feedback_multiplier,
811
+ 1 - (embedding <=> %s::vector) AS cosine_similarity
812
+ FROM memories
813
+ {where_clause}
814
+ ORDER BY embedding <=> %s::vector
815
+ LIMIT %s
816
+ """
817
+ cur.execute(query, tuple(params))
818
+ rows = cur.fetchall()
819
+ results = []
820
+ for r in rows:
821
+ mem = self._decrypt_row(dict(r))
822
+ mem["relevance_score"] = mem.get("cosine_similarity", 0.0)
823
+ results.append(mem)
824
+ return results
825
+ except Exception as e:
826
+ self._handle_db_error("vector_search", e)
827
+ return []
828
+ finally:
829
+ self._return_connection(conn)
830
+
831
+ def lexical_search(
832
+ self,
833
+ query: str,
834
+ top_k: int = 100,
835
+ category: Optional[str] = None,
836
+ tenant_id: Optional[str] = None
837
+ ) -> List[Dict[str, Any]]:
838
+ """
839
+ Executes native PostgreSQL Full-Text Search ranking using GIN tsvector index:
840
+ ORDER BY ts_rank(search_vector, plainto_tsquery('english', query)) DESC
841
+ """
842
+ if not self._connected or not HAS_PSYCOPG2:
843
+ query_terms = set(query.lower().split())
844
+ results = []
845
+ for mem in self._fallback_cache.values():
846
+ if mem.get("superseded_by"):
847
+ continue
848
+ if tenant_id and mem.get("tenant_id") != tenant_id:
849
+ continue
850
+ if category and mem.get("category") != category:
851
+ continue
852
+ words = set(mem.get("content", "").lower().split())
853
+ overlap = len(query_terms.intersection(words))
854
+ if overlap > 0:
855
+ mem_copy = mem.copy()
856
+ mem_copy["lexical_rank"] = float(overlap) / max(1.0, float(len(query_terms)))
857
+ results.append(mem_copy)
858
+ results.sort(key=lambda x: x.get("lexical_rank", 0.0), reverse=True)
859
+ return results[:top_k]
860
+
861
+ conn = self._get_connection()
862
+ try:
863
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
864
+ self._set_tenant_context(cur, tenant_id)
865
+ # SQL text order of %s placeholders:
866
+ # 1. SELECT ts_rank's plainto_tsquery -> query
867
+ # 2. WHERE @@ plainto_tsquery -> query
868
+ # 3. WHERE tenant_id -> tenant_id (if present)
869
+ # 4. WHERE category -> category (if present)
870
+ # 5. LIMIT -> top_k
871
+ conditions = ["search_vector @@ plainto_tsquery('english', %s)", "superseded_by IS NULL"]
872
+ params: List[Any] = [query, query]
873
+ if tenant_id:
874
+ conditions.append("tenant_id = %s")
875
+ params.append(tenant_id)
876
+ if category:
877
+ conditions.append("category = %s")
878
+ params.append(category)
879
+
880
+ where_clause = "WHERE " + " AND ".join(conditions)
881
+
882
+ sql = f"""
883
+ SELECT id, tenant_id, content, category, confidence, created_at,
884
+ last_accessed_at, access_count, token_cost, feedback_multiplier,
885
+ ts_rank(search_vector, plainto_tsquery('english', %s)) AS lexical_rank
886
+ FROM memories
887
+ {where_clause}
888
+ ORDER BY lexical_rank DESC
889
+ LIMIT %s
890
+ """
891
+ params.append(top_k)
892
+ cur.execute(sql, tuple(params))
893
+ rows = cur.fetchall()
894
+ out = []
895
+ for r in rows:
896
+ mem = self._decrypt_row(dict(r))
897
+ mem["relevance_score"] = float(mem.get("lexical_rank", 0.0))
898
+ out.append(mem)
899
+ return out
900
+ except Exception as e:
901
+ self._handle_db_error("lexical_search", e)
902
+ return []
903
+ finally:
904
+ self._return_connection(conn)
905
+
906
+ def hybrid_search_sql(
907
+ self,
908
+ query: str,
909
+ query_vec: List[float],
910
+ top_k: int = 25,
911
+ category: Optional[str] = None,
912
+ tenant_id: Optional[str] = None,
913
+ candidate_pool: int = 100,
914
+ ) -> List[Dict[str, Any]]:
915
+ """Single-query hybrid retrieval pushed down into PostgreSQL.
916
+
917
+ Fuses dense HNSW (vector_cosine) + lexical ts_rank(tsvector) using
918
+ Reciprocal Rank Fusion computed in SQL. This is the scale path: instead
919
+ of loading the whole corpus into Python (O(N)), Postgres returns only the
920
+ top `candidate_pool` per channel and we RRF-fuse the union. Drops p95
921
+ from ~150ms@5k (Python scan) to tens of ms at 100k+.
922
+
923
+ Returns rows with `relevance_score` (RRF) + `explanation` breakdown.
924
+ Falls back to [] when offline so callers can use the Python engine.
925
+ """
926
+ if not self._connected or not HAS_PSYCOPG2:
927
+ return []
928
+
929
+ emb_str = f"[{','.join(str(float(x)) for x in query_vec)}]"
930
+ conn = self._get_connection()
931
+ try:
932
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
933
+ self._set_tenant_context(cur, tenant_id)
934
+ # NOTE: placeholders are positional — build params in exact SQL
935
+ # text order: dense rank (emb), dense cat?, dense order (emb),
936
+ # dense limit, lexical rank (q), lexical where (q), lexical
937
+ # cat?, lexical order (q), lexical limit, final limit.
938
+ params: List[Any] = [emb_str]
939
+ cat_clause = ""
940
+ if category:
941
+ cat_clause = "AND category = %s"
942
+ params.append(category)
943
+ params.extend([emb_str, candidate_pool, query, query])
944
+ if category:
945
+ params.append(category)
946
+ params.extend([query, candidate_pool, top_k])
947
+
948
+ sql = f"""
949
+ WITH dense AS (
950
+ SELECT id,
951
+ ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS rank
952
+ FROM memories
953
+ WHERE tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
954
+ AND superseded_by IS NULL
955
+ {cat_clause}
956
+ ORDER BY embedding <=> %s::vector
957
+ LIMIT %s
958
+ ),
959
+ lexical AS (
960
+ SELECT id,
961
+ ROW_NUMBER() OVER (ORDER BY ts_rank(search_vector, plainto_tsquery('english', %s)) DESC) AS rank
962
+ FROM memories
963
+ WHERE search_vector @@ plainto_tsquery('english', %s)
964
+ AND tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
965
+ AND superseded_by IS NULL
966
+ {cat_clause}
967
+ ORDER BY ts_rank(search_vector, plainto_tsquery('english', %s)) DESC
968
+ LIMIT %s
969
+ ),
970
+ fused AS (
971
+ SELECT COALESCE(d.id, l.id) AS id,
972
+ COALESCE(1.0 / (60 + d.rank), 0.0) + COALESCE(1.0 / (60 + l.rank), 0.0) AS rrf
973
+ FROM dense d
974
+ FULL OUTER JOIN lexical l ON d.id = l.id
975
+ )
976
+ SELECT m.*, f.rrf AS relevance_score,
977
+ json_build_object(
978
+ 'sparse_rank', l.rank,
979
+ 'dense_rank', d.rank,
980
+ 'fused_score', f.rrf
981
+ ) AS explanation
982
+ FROM fused f
983
+ JOIN memories m ON m.id = f.id
984
+ LEFT JOIN dense d ON d.id = f.id
985
+ LEFT JOIN lexical l ON l.id = f.id
986
+ ORDER BY f.rrf DESC
987
+ LIMIT %s;
988
+ """
989
+ cur.execute(sql, tuple(params))
990
+ rows = cur.fetchall()
991
+ fused_out = []
992
+ for r in rows:
993
+ fused_out.append(self._decrypt_row(dict(r)))
994
+ return fused_out
995
+ except Exception as e:
996
+ self._handle_db_error("hybrid_search_sql", e)
997
+ return []
998
+ finally:
999
+ self._return_connection(conn)
1000
+
1001
+ def count_memories(self, tenant_id: Optional[str] = None) -> int:
1002
+ if not self._connected or not HAS_PSYCOPG2:
1003
+ if tenant_id:
1004
+ return len([m for m in self._fallback_cache.values() if m.get("tenant_id") == tenant_id])
1005
+ return len(self._fallback_cache)
1006
+ conn = self._get_connection()
1007
+ try:
1008
+ with conn.cursor() as cur:
1009
+ if tenant_id:
1010
+ self._set_tenant_context(cur, tenant_id)
1011
+ cur.execute("SELECT count(*) FROM memories WHERE tenant_id = %s;", (tenant_id,))
1012
+ else:
1013
+ cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
1014
+ cur.execute("SELECT count(*) FROM memories;")
1015
+ return int(cur.fetchone()[0])
1016
+ except Exception as e:
1017
+ self._handle_db_error("count_memories", e)
1018
+ return 0
1019
+ finally:
1020
+ self._return_connection(conn)
1021
+
1022
+ def count_active_memories(self, tenant_id: Optional[str] = None) -> int:
1023
+ """Count of retrieval-visible (non-superseded) memories."""
1024
+ if not self._connected or not HAS_PSYCOPG2:
1025
+ values = list(self._fallback_cache.values())
1026
+ if tenant_id:
1027
+ values = [m for m in values if m.get("tenant_id") == tenant_id]
1028
+ return sum(1 for m in values if not m.get("superseded_by"))
1029
+ conn = self._get_connection()
1030
+ try:
1031
+ with conn.cursor() as cur:
1032
+ self._set_tenant_context(cur, tenant_id)
1033
+ if tenant_id:
1034
+ cur.execute(
1035
+ "SELECT count(*) FROM memories WHERE tenant_id = %s AND superseded_by IS NULL;",
1036
+ (tenant_id,),
1037
+ )
1038
+ else:
1039
+ cur.execute("SELECT count(*) FROM memories WHERE superseded_by IS NULL;")
1040
+ return int(cur.fetchone()[0])
1041
+ except Exception as e:
1042
+ self._handle_db_error("count_active_memories", e)
1043
+ return 0
1044
+ finally:
1045
+ self._return_connection(conn)
1046
+
1047
+ def get_all_memories(
1048
+ self, tenant_id: Optional[str] = None, include_superseded: bool = False
1049
+ ) -> List[Dict[str, Any]]:
1050
+ """Active-only by default: superseded rows are lineage/audit records."""
1051
+ if not self._connected or not HAS_PSYCOPG2:
1052
+ values = list(self._fallback_cache.values())
1053
+ if tenant_id:
1054
+ values = [m for m in values if m.get("tenant_id") == tenant_id]
1055
+ if not include_superseded:
1056
+ values = [m for m in values if not m.get("superseded_by")]
1057
+ return list(values)
1058
+ conn = self._get_connection()
1059
+ try:
1060
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
1061
+ supersession = "" if include_superseded else " AND superseded_by IS NULL"
1062
+ if tenant_id:
1063
+ self._set_tenant_context(cur, tenant_id)
1064
+ cur.execute(
1065
+ "SELECT * FROM memories WHERE tenant_id = %s" + supersession + " ORDER BY created_at DESC;",
1066
+ (tenant_id,),
1067
+ )
1068
+ else:
1069
+ cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
1070
+ cur.execute("SELECT * FROM memories WHERE TRUE" + supersession + " ORDER BY created_at DESC;")
1071
+ rows = cur.fetchall()
1072
+ return [self._decrypt_row(dict(r)) for r in rows]
1073
+ except Exception as e:
1074
+ self._handle_db_error("get_all_memories", e)
1075
+ return []
1076
+ finally:
1077
+ self._return_connection(conn)
1078
+
1079
+ def query_candidates(
1080
+ self,
1081
+ category: Optional[str] = None,
1082
+ tenant_id: Optional[str] = None,
1083
+ min_confidence: float = 0.0,
1084
+ limit: int = 100,
1085
+ order_by: str = "last_accessed_at DESC",
1086
+ ) -> List[Dict[str, Any]]:
1087
+ """
1088
+ Retrieves a bounded candidate set directly using SQL indexes
1089
+ instead of loading the entire corpus into application memory.
1090
+ """
1091
+ if not self._connected or not HAS_PSYCOPG2:
1092
+ values = list(self._fallback_cache.values())
1093
+ if tenant_id:
1094
+ values = [m for m in values if m.get("tenant_id") == tenant_id]
1095
+ return [m for m in values if not m.get("superseded_by")][:limit]
1096
+
1097
+ conn = self._get_connection()
1098
+ try:
1099
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
1100
+ self._set_tenant_context(cur, tenant_id)
1101
+
1102
+ conditions = ["confidence >= %s", "superseded_by IS NULL"]
1103
+ params: List[Any] = [min_confidence]
1104
+ if tenant_id:
1105
+ conditions.append("tenant_id = %s")
1106
+ params.append(tenant_id)
1107
+ if category:
1108
+ conditions.append("category = %s")
1109
+ params.append(category)
1110
+
1111
+ safe_orders = {
1112
+ "last_accessed_at DESC": "last_accessed_at DESC",
1113
+ "created_at DESC": "created_at DESC",
1114
+ "access_count DESC": "access_count DESC",
1115
+ "confidence DESC": "confidence DESC",
1116
+ }
1117
+ order_clause = safe_orders.get(order_by, "last_accessed_at DESC")
1118
+
1119
+ where_clause = "WHERE " + " AND ".join(conditions)
1120
+ sql = f"""
1121
+ SELECT * FROM memories
1122
+ {where_clause}
1123
+ ORDER BY {order_clause}
1124
+ LIMIT %s
1125
+ """
1126
+ params.append(limit)
1127
+ cur.execute(sql, tuple(params))
1128
+ rows = cur.fetchall()
1129
+ return [self._decrypt_row(dict(r)) for r in rows]
1130
+ except Exception as e:
1131
+ self._handle_db_error("query_candidates", e)
1132
+ return []
1133
+ finally:
1134
+ self._return_connection(conn)
1135
+
1136
+ def delete_memory(self, memory_id: str, tenant_id: Optional[str] = None) -> None:
1137
+ self._fallback_cache.pop(memory_id, None)
1138
+ if not self._connected or not HAS_PSYCOPG2:
1139
+ return
1140
+ conn = self._get_connection()
1141
+ try:
1142
+ with conn.cursor() as cur:
1143
+ if tenant_id:
1144
+ self._set_tenant_context(cur, tenant_id)
1145
+ cur.execute("DELETE FROM memories WHERE id = %s AND tenant_id = %s;", (memory_id, tenant_id))
1146
+ else:
1147
+ cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
1148
+ cur.execute("DELETE FROM memories WHERE id = %s;", (memory_id,))
1149
+ conn.commit()
1150
+ except Exception as e:
1151
+ conn.rollback()
1152
+ self._handle_db_error("delete_memory", e)
1153
+ finally:
1154
+ self._return_connection(conn)
1155
+
1156
+ def clear_memories(self, tenant_id: Optional[str] = None) -> int:
1157
+ deleted_count = 0
1158
+ if tenant_id:
1159
+ before_len = len(self._fallback_cache)
1160
+ self._fallback_cache = {k: v for k, v in self._fallback_cache.items() if v.get("tenant_id") != tenant_id}
1161
+ deleted_count = before_len - len(self._fallback_cache)
1162
+ else:
1163
+ deleted_count = len(self._fallback_cache)
1164
+ self._fallback_cache.clear()
1165
+
1166
+ if not self._connected or not HAS_PSYCOPG2:
1167
+ return deleted_count
1168
+
1169
+ conn = self._get_connection()
1170
+ try:
1171
+ with conn.cursor() as cur:
1172
+ if tenant_id:
1173
+ self._set_tenant_context(cur, tenant_id)
1174
+ cur.execute("DELETE FROM memories WHERE tenant_id = %s;", (tenant_id,))
1175
+ else:
1176
+ cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
1177
+ cur.execute("DELETE FROM memories;")
1178
+ deleted_count = cur.rowcount
1179
+ conn.commit()
1180
+ return deleted_count
1181
+ except Exception as e:
1182
+ conn.rollback()
1183
+ self._handle_db_error("clear_memories", e)
1184
+ return deleted_count
1185
+ finally:
1186
+ self._return_connection(conn)
1187
+
1188
+ def reembed_all_memories(self, embedder: Any, batch_size: int = 500) -> int:
1189
+ """Regenerates vector embeddings for all stored memory records.
1190
+
1191
+ Production-safe: streams rows in batches via a named (server-side) cursor
1192
+ and commits per batch, so a 100k+ row corpus re-embed does not load the
1193
+ whole table into memory or hold one giant transaction open. Also records
1194
+ the new embedding dimension + model version on each row so future schema
1195
+ checks can detect drift instead of silently corrupting the HNSW index.
1196
+ """
1197
+ if not self._connected or not HAS_PSYCOPG2:
1198
+ return 0
1199
+
1200
+ target_dim = getattr(embedder, "dimension", self.dimension)
1201
+ model_version = getattr(embedder, "model_name", None) or getattr(embedder, "provider_name", "unknown")
1202
+
1203
+ conn = self._get_connection()
1204
+ updated_count = 0
1205
+ try:
1206
+ # Keyset pagination instead of a named cursor: committing per batch
1207
+ # would invalidate a transaction-bound named cursor after batch #1
1208
+ # (psycopg2 closes it on commit), silently re-embedding only the
1209
+ # first batch while reporting full progress. Each batch is its own
1210
+ # transaction, so the admin (unscoped) context and the explicit
1211
+ # RLS bypass GUC are re-applied there (SET LOCAL is transaction-
1212
+ # scoped) — without them the RLS policy hides cross-tenant rows
1213
+ # from this maintenance scan.
1214
+ write_cur = conn.cursor()
1215
+ last_created: Any = None
1216
+ last_id = ""
1217
+ batch = 0
1218
+ while True:
1219
+ read_cur = conn.cursor()
1220
+ try:
1221
+ # Admin (unscoped) context + bypass GUC so the RLS policy
1222
+ # does not hide cross-tenant rows from this maintenance
1223
+ # scan (both are SET LOCAL, hence re-applied per batch).
1224
+ self._set_tenant_context(read_cur, None)
1225
+ read_cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
1226
+ if last_created is None:
1227
+ read_cur.execute(
1228
+ "SELECT id, content, created_at FROM memories "
1229
+ "ORDER BY created_at, id LIMIT %s;",
1230
+ (batch_size,),
1231
+ )
1232
+ else:
1233
+ read_cur.execute(
1234
+ "SELECT id, content, created_at FROM memories "
1235
+ "WHERE (created_at, id) > (%s, %s) "
1236
+ "ORDER BY created_at, id LIMIT %s;",
1237
+ (last_created, last_id, batch_size),
1238
+ )
1239
+ rows = read_cur.fetchall()
1240
+ finally:
1241
+ try:
1242
+ read_cur.close()
1243
+ except Exception:
1244
+ pass
1245
+ if not rows:
1246
+ break
1247
+ for row in rows:
1248
+ mem_id = row[0] if isinstance(row, (tuple, list)) else row["id"]
1249
+ raw_content = row[1] if isinstance(row, (tuple, list)) else row["content"]
1250
+ row_created = row[2] if isinstance(row, (tuple, list)) else row["created_at"]
1251
+ try:
1252
+ content = self._decrypt_field(raw_content)
1253
+ except Exception:
1254
+ content = raw_content
1255
+ new_vector = embedder.embed_query(content)
1256
+ vec_str = f"[{','.join(str(x) for x in new_vector)}]"
1257
+ write_cur.execute(
1258
+ "UPDATE memories SET embedding = %s, embedding_dimension = %s, model_version = %s, content_plain = %s WHERE id = %s;",
1259
+ (vec_str, len(new_vector), model_version, searchable_projection(content), mem_id),
1260
+ )
1261
+ updated_count += 1
1262
+ last_created = row_created
1263
+ last_id = mem_id
1264
+ conn.commit()
1265
+ batch += 1
1266
+ logger.info("Re-embed batch %d complete (running total: %d rows).", batch, updated_count)
1267
+ if len(rows) < batch_size:
1268
+ break
1269
+
1270
+ logger.info(f"Re-embedded {updated_count} memories to dimension {target_dim} (model={model_version}).")
1271
+ except Exception as e:
1272
+ conn.rollback()
1273
+ self._handle_db_error("reembed_all_memories", e)
1274
+ finally:
1275
+ self._return_connection(conn)
1276
+ return updated_count