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,233 @@
1
+ import os
2
+ import time
3
+ import logging
4
+ import asyncio
5
+ import uuid
6
+ from typing import Dict, Any, Tuple, Optional, Union
7
+ from synapse_memory.core.sqlite_db import SQLiteMemoryStore
8
+ from synapse_memory.core.pgvector_db import PGVectorMemoryStore
9
+ from synapse_memory.core.embedder import SynapseEmbedder
10
+ from synapse_memory.core.deduplicator import Deduplicator, Action
11
+ from synapse_memory.core.pruner import PruningEngine, CapacityPruningPolicy
12
+ from synapse_memory.core.decay_engine import DecayEngine
13
+ from synapse_memory.core.observability import log_event, instrument_operation
14
+
15
+ logger = logging.getLogger("SynapseMemoryManager")
16
+
17
+ # Explicit, operator-visible capacity ceiling for the in-memory store.
18
+ # Override with SYNAPSE_MAX_RECORD_LIMIT (must be a positive integer).
19
+ # The historical default was 50 records; change this only with capacity planning.
20
+ try:
21
+ DEFAULT_MAX_RECORD_LIMIT = int(os.environ.get("SYNAPSE_MAX_RECORD_LIMIT", "50"))
22
+ if DEFAULT_MAX_RECORD_LIMIT <= 0:
23
+ raise ValueError
24
+ except (TypeError, ValueError):
25
+ DEFAULT_MAX_RECORD_LIMIT = 50
26
+ logger.warning(
27
+ "Invalid SYNAPSE_MAX_RECORD_LIMIT; using default max_record_limit=50"
28
+ )
29
+
30
+
31
+ def estimate_token_cost(content: str) -> int:
32
+ """Production token estimator: prefers tiktoken when installed, else safe heuristic.
33
+ Heuristic calibrated to ~4 chars/token (OpenAI/Gemini average for English).
34
+ """
35
+ try:
36
+ import tiktoken # type: ignore
37
+
38
+ enc = tiktoken.get_encoding("cl100k_base")
39
+ return max(15, len(enc.encode(content)) + 8)
40
+ except Exception:
41
+ # Fallback: chars/4 + overhead. More accurate than words*1.35 for code/CJK.
42
+ return max(15, int(len(content) / 4) + 10)
43
+
44
+
45
+ class MemoryManager:
46
+ """
47
+ Manages high-level memory operations: Semantic Deduplication,
48
+ Multi-tenant Ingestion, Auto-Eviction, and Cognitive Memory Pruning.
49
+ Compatible with both SQLiteMemoryStore and PGVectorMemoryStore backends.
50
+
51
+ Capacity: ``max_record_limit`` bounds how many memories a tenant may hold.
52
+ After every ingest, :meth:`auto_prune_store` evicts the lowest-relevance
53
+ memories until the count is within the limit. The default comes from the
54
+ ``SYNAPSE_MAX_RECORD_LIMIT`` environment variable and falls back to 50
55
+ records (the historical default). Expose this setting explicitly -- never
56
+ rely on the silent default in production, or older memories are pruned
57
+ without the operator realizing a cap is active.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ store: Union[SQLiteMemoryStore, PGVectorMemoryStore, Any],
63
+ embedder: SynapseEmbedder,
64
+ category_thresholds: Optional[Dict[str, float]] = None,
65
+ max_record_limit: int = DEFAULT_MAX_RECORD_LIMIT
66
+ ):
67
+ self.store = store
68
+ self.embedder = embedder
69
+ self.deduplicator = Deduplicator(category_thresholds or {})
70
+ self.max_record_limit = int(max_record_limit)
71
+ self.decay_engine = DecayEngine({})
72
+ self.pruner = PruningEngine([CapacityPruningPolicy(max_record_limit, self.decay_engine)])
73
+
74
+ @instrument_operation("ingest_memory")
75
+ def ingest_with_deduplication(
76
+ self,
77
+ content: str,
78
+ category: str = "interaction",
79
+ confidence: float = 0.95,
80
+ tenant_id: str = "default"
81
+ ) -> Tuple[str, str, int]:
82
+ """
83
+ Ingests a new memory node.
84
+ Runs semantic similarity comparison against existing indexed nodes within the same tenant.
85
+
86
+ When a near-duplicate is found (Action.MERGE), the incoming content is preserved:
87
+ a new memory row is created and linked to the old one via a supersession chain
88
+ (``superseded_by``). The old row is marked superseded but retained for provenance.
89
+ Confidence is merged conservatively: the higher of the two confidences is kept,
90
+ and a small reinforcement is applied only when the new content is at least as
91
+ confident as the existing one. This ensures updated/corrected facts are never
92
+ silently discarded.
93
+
94
+ When Action.REJECT is returned (direct contradiction), the incoming memory is
95
+ still recorded as a separate row so no information is lost; the caller can
96
+ decide how to resolve the conflict.
97
+ """
98
+ # 1. Generate dense vector representation
99
+ new_vector = self.embedder.embed_query(content)
100
+ existing_memories = self.store.get_memories_by_category(category, tenant_id=tenant_id)
101
+
102
+ # 2. Check for duplicate nodes
103
+ new_node_base = {
104
+ "content": content,
105
+ "category": category,
106
+ "tenant_id": tenant_id,
107
+ "embedding": new_vector,
108
+ "confidence": confidence,
109
+ }
110
+ result = self.deduplicator.check_duplicate(new_node_base, existing_memories)
111
+
112
+ # 3. Handle Deduplication Result
113
+ if result.action == Action.MERGE and result.target_id:
114
+ match_id = result.target_id
115
+ match_mem = next((m for m in existing_memories if m["id"] == match_id), None)
116
+ old_confidence = float(match_mem.get("confidence", 0.5)) if match_mem else 0.5
117
+
118
+ merged_confidence = max(old_confidence, confidence)
119
+ if confidence >= old_confidence:
120
+ merged_confidence = min(1.0, merged_confidence + 0.02)
121
+
122
+ new_memory_id = f"mem_{uuid.uuid4().hex[:16]}"
123
+ token_cost = estimate_token_cost(content)
124
+ new_node = {
125
+ "id": new_memory_id,
126
+ "tenant_id": tenant_id,
127
+ "content": content,
128
+ "category": category,
129
+ "confidence": merged_confidence,
130
+ "created_at": time.time(),
131
+ "access_count": 0,
132
+ "token_cost": token_cost,
133
+ "embedding": new_vector,
134
+ "embedding_dimension": len(new_vector),
135
+ "model_version": getattr(self.embedder, "model_name", "unknown"),
136
+ "supersedes": match_id,
137
+ "superseded_by": None,
138
+ }
139
+ self.store.insert_memory(new_node)
140
+
141
+ try:
142
+ self.store.mark_superseded(match_id, new_memory_id, tenant_id=tenant_id)
143
+ except Exception:
144
+ log_event("memory_deduplication", "MERGE_MARK_FAILED", {"target_id": match_id, "tenant_id": tenant_id})
145
+
146
+ # The supersession row above is a real row: it counts toward
147
+ # max_record_limit like any other write, so enforce the capacity
148
+ # ceiling on this path too. Without this, repeated merges of the same
149
+ # fact grow the table without bound (the superseded lineage rows are
150
+ # never evicted) even though CREATE/REJECT do prune.
151
+ self.auto_prune_store(tenant_id=tenant_id)
152
+
153
+ log_event("memory_deduplication", "MERGED_PRESERVED", {
154
+ "old_id": match_id, "new_id": new_memory_id, "tenant_id": tenant_id,
155
+ "old_confidence": old_confidence, "new_confidence": merged_confidence,
156
+ })
157
+ return new_memory_id, "MERGED", token_cost
158
+
159
+ elif result.action == Action.REJECT:
160
+ log_event("memory_deduplication", "REJECTED_RECORDED", {"tenant_id": tenant_id, "reason": result.reason})
161
+ new_memory_id = f"mem_{uuid.uuid4().hex[:16]}"
162
+ token_cost = estimate_token_cost(content)
163
+ new_node = {
164
+ "id": new_memory_id,
165
+ "tenant_id": tenant_id,
166
+ "content": content,
167
+ "category": category,
168
+ "confidence": confidence,
169
+ "created_at": time.time(),
170
+ "access_count": 0,
171
+ "token_cost": token_cost,
172
+ "embedding": new_vector,
173
+ "embedding_dimension": len(new_vector),
174
+ "model_version": getattr(self.embedder, "model_name", "unknown"),
175
+ "supersedes": None,
176
+ "superseded_by": None,
177
+ }
178
+ self.store.insert_memory(new_node)
179
+ self.auto_prune_store(tenant_id=tenant_id)
180
+ return new_memory_id, "REJECTED", token_cost
181
+
182
+ # 4. Otherwise, save as a clean memory
183
+ log_event("memory_deduplication", "CREATED", {"tenant_id": tenant_id})
184
+ memory_id = f"mem_{uuid.uuid4().hex[:16]}"
185
+ token_cost = estimate_token_cost(content)
186
+ new_node = {
187
+ "id": memory_id,
188
+ "tenant_id": tenant_id,
189
+ "content": content,
190
+ "category": category,
191
+ "confidence": confidence,
192
+ "created_at": time.time(),
193
+ "access_count": 0,
194
+ "token_cost": token_cost,
195
+ "embedding": new_vector,
196
+ # Embedding provenance: stamps which dimension + model wrote this row,
197
+ # so schema/drift checks can detect stale index entries after an
198
+ # embedding-model upgrade (and trigger a re-embed pass).
199
+ "embedding_dimension": len(new_vector),
200
+ "model_version": getattr(self.embedder, "model_name", "unknown"),
201
+ "supersedes": None,
202
+ "superseded_by": None,
203
+ }
204
+ self.store.insert_memory(new_node)
205
+
206
+ # 5. Execute Auto-Pruning if limit is breached
207
+ self.auto_prune_store(tenant_id=tenant_id)
208
+
209
+ return memory_id, "CREATED", token_cost
210
+
211
+ async def ingest_async(self, *args, **kwargs) -> Tuple[str, str, int]:
212
+ return await asyncio.to_thread(self.ingest_with_deduplication, *args, **kwargs)
213
+
214
+ def auto_prune_store(self, tenant_id: Optional[str] = None) -> int:
215
+ """
216
+ Evicts memories based on the configured PruningEngine within a tenant.
217
+
218
+ Returns:
219
+ The number of successfully evicted memory nodes.
220
+ """
221
+ memories = self.store.get_all_memories(tenant_id=tenant_id, include_superseded=True)
222
+ targets = self.pruner.run_pruning(memories)
223
+
224
+ evicted = 0
225
+ for target_id in targets:
226
+ self.store.delete_memory(target_id, tenant_id=tenant_id)
227
+ evicted += 1
228
+ log_event("memory_pruning", "EVICTED", {"target_id": target_id, "tenant_id": tenant_id})
229
+
230
+ return evicted
231
+
232
+ async def prune_async(self, tenant_id: Optional[str] = None) -> int:
233
+ return await asyncio.to_thread(self.auto_prune_store, tenant_id=tenant_id)
@@ -0,0 +1,241 @@
1
+ """
2
+ Lightweight, dependency-free forward-only schema migrations for SynapseMemory.
3
+
4
+ Why not Alembic: the engine must stay importable and runnable with zero external
5
+ services (SQLite) and a minimal dependency footprint. This runner is versioned,
6
+ idempotent, and records applied versions in a `schema_migrations` ledger table so
7
+ a team can audit exactly which schema state a database is in. It supports both
8
+ SQLite (local/dev) and PostgreSQL (team/prod) via a tiny dialect shim.
9
+
10
+ Adding a migration:
11
+ 1. Append a Migration(version=N, name=..., sqlite=..., postgres=...) to MIGRATIONS.
12
+ 2. Bump CURRENT_SCHEMA_VERSION to N.
13
+ Never edit an already-released migration — add a new one.
14
+ """
15
+
16
+ import logging
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Any, Callable, List, Optional
20
+
21
+ logger = logging.getLogger("SynapseMigrations")
22
+
23
+ # Ledger + metadata columns tracked on the memories table so embedding-model
24
+ # upgrades (e.g. 128-d hash -> 384-d MiniLM) are explicit, not silent.
25
+ CURRENT_SCHEMA_VERSION = 5
26
+
27
+
28
+ @dataclass
29
+ class Migration:
30
+ version: int
31
+ name: str
32
+ # Callables receive a cursor + a `p` placeholder ('?' for sqlite, '%s' for pg)
33
+ sqlite: Optional[Callable[[Any], None]] = None
34
+ postgres: Optional[Callable[[Any], None]] = None
35
+
36
+
37
+ def _column_exists(cur: Any, table: str, column: str) -> bool:
38
+ """Dialect-aware column existence probe (PostgreSQL then SQLite)."""
39
+ try:
40
+ cur.execute(
41
+ "SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name=%s",
42
+ (table, column),
43
+ )
44
+ if cur.fetchone():
45
+ return True
46
+ return False
47
+ except Exception:
48
+ # SQLite has no information_schema; fall through to PRAGMA.
49
+ pass
50
+ try:
51
+ cur.execute(f"PRAGMA table_info({table});")
52
+ for row in cur.fetchall():
53
+ if isinstance(row, dict):
54
+ if row.get("name") == column:
55
+ return True
56
+ elif len(row) > 1 and row[1] == column:
57
+ return True
58
+ return False
59
+ except Exception:
60
+ return False
61
+
62
+
63
+ def _add_column_if_missing(cur: Any, table: str, column: str, coltype: str) -> None:
64
+ """Idempotent ADD COLUMN that works on both SQLite and PostgreSQL.
65
+
66
+ Only a real duplicate-column condition counts as success. Any other failure
67
+ — missing privileges, invalid DDL, lock timeout, schema drift — is re-raised
68
+ so ``fail_closed=True`` aborts startup instead of recording a migration
69
+ version for a column that was never created (F-12).
70
+ """
71
+ if _column_exists(cur, table, column):
72
+ return
73
+ try:
74
+ cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coltype};")
75
+ except Exception:
76
+ # Re-probe: a concurrent replica may have added it, in which case this
77
+ # is genuinely done. Otherwise the failure is real and must propagate.
78
+ if _column_exists(cur, table, column):
79
+ return
80
+ raise
81
+
82
+
83
+ def _m1_embedding_metadata(cur: Any) -> None:
84
+ """v1: Track embedding dimension + model version per memory row."""
85
+ _add_column_if_missing(cur, "memories", "embedding_dimension", "INTEGER NOT NULL DEFAULT 0")
86
+ _add_column_if_missing(cur, "memories", "model_version", "VARCHAR(64) NOT NULL DEFAULT 'unknown'")
87
+
88
+
89
+ def _m2_migration_ledger(cur: Any) -> None:
90
+ """v2: Audit ledger of applied schema versions (created here, not via ADD COLUMN)."""
91
+ cur.execute(
92
+ """
93
+ CREATE TABLE IF NOT EXISTS schema_migrations (
94
+ version INTEGER PRIMARY KEY,
95
+ name VARCHAR(128) NOT NULL,
96
+ applied_at DOUBLE PRECISION NOT NULL
97
+ );
98
+ """
99
+ )
100
+
101
+
102
+ def _m3_backfill_dimension(cur: Any) -> None:
103
+ """v3: Backfill embedding_dimension for rows written before tracking existed."""
104
+ # Rows with dimension 0 keep 0 (unknown); a later re-embed pass fills them.
105
+ cur.execute("UPDATE memories SET embedding_dimension = 0 WHERE embedding_dimension IS NULL;")
106
+
107
+
108
+ def _m4_content_plain_search_projection(cur: Any) -> None:
109
+ """v4: Combined-protection search projection for field-encrypted content.
110
+
111
+ Adds `content_plain` (searchable projection feeding the tsvector index).
112
+ Existing rows backfill `content_plain` from `content` ONLY when that
113
+ column still holds plaintext — i.e. rows written before P0-2 field
114
+ encryption. Rows already holding Fernet ciphertext are left untouched
115
+ (re-encrypting ciphertext would destroy recoverability); operators must
116
+ run the documented key-rotation/re-encryption sweep for those.
117
+ Detection heuristic: Fernet tokens are urlsafe-base64 starting with
118
+ 'gAAAAA'; anything else is treated as legacy plaintext.
119
+ """
120
+ _add_column_if_missing(cur, "memories", "content_plain", "TEXT NOT NULL DEFAULT ''")
121
+ cur.execute(
122
+ "UPDATE memories SET content_plain = content "
123
+ "WHERE (content_plain IS NULL OR content_plain = '') "
124
+ "AND content NOT LIKE 'gAAAAA%';"
125
+ )
126
+
127
+
128
+ def _m5_supersession_chain(cur: Any) -> None:
129
+ """v5: Deduplication supersession lineage (MERGE keeps both rows).
130
+
131
+ `supersedes` points from the successor (new) row to the row it replaced;
132
+ `superseded_by` points from the replaced (old) row to its successor.
133
+ """
134
+ _add_column_if_missing(cur, "memories", "supersedes", "VARCHAR(128)")
135
+ _add_column_if_missing(cur, "memories", "superseded_by", "VARCHAR(128)")
136
+
137
+
138
+ MIGRATIONS: List[Migration] = [
139
+ Migration(1, "embedding_metadata", _m1_embedding_metadata, _m1_embedding_metadata),
140
+ Migration(2, "migration_ledger", _m2_migration_ledger, _m2_migration_ledger),
141
+ Migration(3, "backfill_dimension", _m3_backfill_dimension, _m3_backfill_dimension),
142
+ Migration(4, "content_plain_search_projection", _m4_content_plain_search_projection, _m4_content_plain_search_projection),
143
+ Migration(5, "supersession_chain", _m5_supersession_chain, _m5_supersession_chain),
144
+ ]
145
+
146
+
147
+ def _applied_versions(cur: Any) -> set:
148
+ try:
149
+ cur.execute("SELECT version FROM schema_migrations;")
150
+ rows = cur.fetchall()
151
+ return {int(r[0] if isinstance(r, (tuple, list)) else r["version"]) for r in rows}
152
+ except Exception:
153
+ # Ledger table doesn't exist yet -> nothing applied.
154
+ return set()
155
+
156
+
157
+ def run_migrations(
158
+ get_connection: Callable[[], Any],
159
+ is_postgres: bool,
160
+ fail_closed: bool = False,
161
+ close_on_exit: bool = True,
162
+ owns_connection: bool = True,
163
+ ) -> int:
164
+ """Apply all pending forward migrations. Returns count applied. Idempotent.
165
+
166
+ Args:
167
+ get_connection: Callable returning a fresh connection for this run.
168
+ is_postgres: Dialect to apply (SQLite vs PostgreSQL).
169
+ fail_closed: Raise on migration failure instead of skipping.
170
+ close_on_exit: If True (default), the connection is released when the run
171
+ finishes. Set False only when the caller keeps the connection alive
172
+ and reuses it afterwards, e.g. SQLiteMemoryStore caching its
173
+ schema-init ':memory:' connection.
174
+ owns_connection: If True (default), treat the returned connection as
175
+ freshly-owned (non-pooled) and close it in the finally block. Set
176
+ False when the connection belongs to an external pool that must be
177
+ returned, not closed. Existing callers can rely on the default True
178
+ (fresh connection). PGVectorMemoryStore passes a pooled connection
179
+ and sets this False.
180
+
181
+ The connection is closed only when ``owns_connection`` AND
182
+ ``close_on_exit`` are both True. Otherwise it is handed back through the
183
+ factory owner's ``_return_connection`` hook (when present) or closed as
184
+ a last-resort fallback.
185
+ """
186
+ applied_total = 0
187
+ conn = get_connection()
188
+ should_close = owns_connection and close_on_exit
189
+ try:
190
+ cur = conn.cursor()
191
+ # The ledger (v2) must exist before we can read it; bootstrap directly.
192
+ _m2_migration_ledger(cur)
193
+ conn.commit()
194
+
195
+ done = _applied_versions(cur)
196
+ for migration in MIGRATIONS:
197
+ if migration.version in done:
198
+ continue
199
+ fn = migration.postgres if is_postgres else migration.sqlite
200
+ if fn is None:
201
+ continue
202
+ try:
203
+ fn(cur)
204
+ cur.execute(
205
+ "INSERT INTO schema_migrations (version, name, applied_at) VALUES (%s, %s, %s);"
206
+ if is_postgres
207
+ else "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?);",
208
+ (migration.version, migration.name, time.time()),
209
+ )
210
+ conn.commit()
211
+ applied_total += 1
212
+ logger.info("Applied migration v%d: %s", migration.version, migration.name)
213
+ except Exception as e:
214
+ conn.rollback()
215
+ if fail_closed:
216
+ raise RuntimeError(f"Migration v{migration.version} failed: {e}") from e
217
+ logger.warning("Migration v%d (%s) skipped: %s", migration.version, migration.name, e)
218
+ finally:
219
+ try:
220
+ if should_close:
221
+ conn.close()
222
+ else:
223
+ # Return pooled connections to the pool instead of closing.
224
+ try:
225
+ conn.rollback()
226
+ except Exception:
227
+ pass
228
+ try:
229
+ owner = getattr(get_connection, "__self__", None)
230
+ if owner is not None:
231
+ owner._return_connection(conn)
232
+ else:
233
+ conn.close()
234
+ except Exception:
235
+ try:
236
+ conn.close()
237
+ except Exception:
238
+ pass
239
+ except Exception:
240
+ pass
241
+ return applied_total
@@ -0,0 +1,48 @@
1
+ import logging
2
+ import json
3
+ import time
4
+ from typing import Dict, Any
5
+ from functools import wraps
6
+
7
+ # Structured Logger Setup
8
+ logger = logging.getLogger("SynapseObservability")
9
+
10
+
11
+ def log_event(event_name: str, status: str, details: Dict[str, Any], level: int = logging.INFO):
12
+ """Logs structured JSON events for production analysis."""
13
+ log_record = {
14
+ "event": event_name,
15
+ "status": status,
16
+ "timestamp": time.time(),
17
+ "details": details
18
+ }
19
+ logger.log(level, json.dumps(log_record))
20
+
21
+
22
+ def instrument_operation(operation_name: str):
23
+ """Decorator to instrument method execution times and failures."""
24
+ def decorator(func):
25
+ @wraps(func)
26
+ def wrapper(*args, **kwargs):
27
+ start = time.perf_counter()
28
+ try:
29
+ result = func(*args, **kwargs)
30
+ log_event(operation_name, "SUCCESS", {"duration_ms": (time.perf_counter() - start) * 1000})
31
+ return result
32
+ except Exception as e:
33
+ log_event(operation_name, "FAILURE", {"error": str(e), "duration_ms": (time.perf_counter() - start) * 1000}, level=logging.ERROR)
34
+ raise e
35
+ return wrapper
36
+ return decorator
37
+
38
+
39
+ class MetricsCollector:
40
+ """Simple in-memory metrics collector."""
41
+ def __init__(self):
42
+ self.metrics: Dict[str, float] = {}
43
+
44
+ def increment(self, metric_name: str, value: float = 1.0):
45
+ self.metrics[metric_name] = self.metrics.get(metric_name, 0.0) + value
46
+
47
+ def get_metrics(self) -> Dict[str, float]:
48
+ return self.metrics