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,68 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
import time
|
|
4
|
+
import logging
|
|
5
|
+
from synapse_memory.core.decay_engine import DecayEngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("Pruner")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PruningPolicy(ABC):
|
|
11
|
+
"""Abstract base class for memory pruning policies."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
|
|
15
|
+
"""Returns list of memory IDs to be pruned based on the policy."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TTLPruningPolicy(PruningPolicy):
|
|
20
|
+
"""Prunes memories older than a specified duration in seconds."""
|
|
21
|
+
def __init__(self, ttl_seconds: float):
|
|
22
|
+
self.ttl_seconds = ttl_seconds
|
|
23
|
+
|
|
24
|
+
def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
|
|
25
|
+
now = time.time()
|
|
26
|
+
return [m["id"] for m in memories if now - m["created_at"] > self.ttl_seconds]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfidencePruningPolicy(PruningPolicy):
|
|
30
|
+
"""Prunes memories below a certain confidence threshold."""
|
|
31
|
+
def __init__(self, confidence_threshold: float):
|
|
32
|
+
self.confidence_threshold = confidence_threshold
|
|
33
|
+
|
|
34
|
+
def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
|
|
35
|
+
return [m["id"] for m in memories if m.get("confidence", 0.0) < self.confidence_threshold]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CapacityPruningPolicy(PruningPolicy):
|
|
39
|
+
"""Prunes lowest-relevance memories to maintain capacity."""
|
|
40
|
+
def __init__(self, max_records: int, decay_engine: DecayEngine):
|
|
41
|
+
self.max_records = max_records
|
|
42
|
+
self.decay_engine = decay_engine
|
|
43
|
+
|
|
44
|
+
def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
|
|
45
|
+
if len(memories) <= self.max_records:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
# Calculate relevance using robust DecayEngine
|
|
49
|
+
scored = []
|
|
50
|
+
for m in memories:
|
|
51
|
+
relevance = self.decay_engine.calculate_relevance(m)
|
|
52
|
+
scored.append((relevance, m["id"]))
|
|
53
|
+
|
|
54
|
+
scored.sort(key=lambda x: x[0])
|
|
55
|
+
excess = len(memories) - self.max_records
|
|
56
|
+
return [m_id for _, m_id in scored[:excess]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PruningEngine:
|
|
60
|
+
"""Engine to orchestrate multiple pruning policies."""
|
|
61
|
+
def __init__(self, policies: List[PruningPolicy]):
|
|
62
|
+
self.policies = policies
|
|
63
|
+
|
|
64
|
+
def run_pruning(self, memories: List[Dict[str, Any]]) -> List[str]:
|
|
65
|
+
targets = set()
|
|
66
|
+
for policy in self.policies:
|
|
67
|
+
targets.update(policy.identify_targets(memories))
|
|
68
|
+
return list(targets)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Distributed-safe request rate limiting.
|
|
2
|
+
|
|
3
|
+
Two interchangeable implementations behind one tiny interface (``check``):
|
|
4
|
+
|
|
5
|
+
* :class:`InProcessRateLimiter` — a thread-safe sliding window with **no
|
|
6
|
+
external dependencies**. Correct for a single process / single replica, and
|
|
7
|
+
used as the automatic fallback everywhere else.
|
|
8
|
+
* :class:`RedisRateLimiter` — a Redis sorted-set sliding window executed as an
|
|
9
|
+
atomic Lua script, so the limit is enforced across **all** engine replicas
|
|
10
|
+
sharing one Redis. This is what multi-replica HA deployments need.
|
|
11
|
+
|
|
12
|
+
:func:`build_rate_limiter` picks Redis when ``REDIS_URL`` is configured *and* the
|
|
13
|
+
optional ``redis`` client is importable and reachable; otherwise it degrades to
|
|
14
|
+
the in-process window and logs why.
|
|
15
|
+
|
|
16
|
+
Design notes (production):
|
|
17
|
+
* Redis is treated as a *hard dependency only when configured*: if it is
|
|
18
|
+
unreachable at call time we fall back to the in-process window instead of
|
|
19
|
+
failing every request. A Redis outage therefore degrades protection from
|
|
20
|
+
cluster-wide to per-replica — it never turns into a full outage.
|
|
21
|
+
* Keys carry the caller identity + path and expire automatically
|
|
22
|
+
(``PEXPIRE``), so an unbounded key space is not possible.
|
|
23
|
+
* The in-process limiter prunes idle buckets so memory cannot grow without
|
|
24
|
+
bound under a high-cardinality key space.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from typing import Any, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("SynapseRateLimit")
|
|
35
|
+
|
|
36
|
+
# Atomic sliding-window check:
|
|
37
|
+
# KEYS[1] = bucket key
|
|
38
|
+
# ARGV[1] = now (epoch seconds) ARGV[2] = window (seconds)
|
|
39
|
+
# ARGV[3] = limit ARGV[4] = unique member id
|
|
40
|
+
# Returns 1 if the request is allowed, 0 if the limit is exceeded.
|
|
41
|
+
_SLIDING_WINDOW_LUA = """
|
|
42
|
+
local key = KEYS[1]
|
|
43
|
+
local now = tonumber(ARGV[1])
|
|
44
|
+
local window = tonumber(ARGV[2])
|
|
45
|
+
local limit = tonumber(ARGV[3])
|
|
46
|
+
local member = ARGV[4]
|
|
47
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
|
|
48
|
+
local count = redis.call('ZCARD', key)
|
|
49
|
+
if count >= limit then
|
|
50
|
+
return 0
|
|
51
|
+
end
|
|
52
|
+
redis.call('ZADD', key, now, member)
|
|
53
|
+
redis.call('PEXPIRE', key, math.ceil(window * 1000))
|
|
54
|
+
return 1
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InProcessRateLimiter:
|
|
59
|
+
"""Thread-safe sliding-window limiter, scoped to this process only."""
|
|
60
|
+
|
|
61
|
+
backend = "in-process"
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._buckets: Dict[str, List[float]] = {}
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
def check(self, key: str, limit: int, window_s: float = 60.0) -> bool:
|
|
68
|
+
"""Return True if the request is allowed, False if the limit is exceeded."""
|
|
69
|
+
now = time.time()
|
|
70
|
+
window_start = now - window_s
|
|
71
|
+
with self._lock:
|
|
72
|
+
bucket = self._buckets.get(key)
|
|
73
|
+
if bucket is None:
|
|
74
|
+
bucket = []
|
|
75
|
+
self._buckets[key] = bucket
|
|
76
|
+
else:
|
|
77
|
+
# Drop timestamps that have fallen out of the window.
|
|
78
|
+
bucket[:] = [t for t in bucket if t > window_start]
|
|
79
|
+
if len(bucket) >= limit:
|
|
80
|
+
# A non-positive limit can never recover; evict the bucket so a
|
|
81
|
+
# hostile key space cannot grow without bound.
|
|
82
|
+
if limit <= 0:
|
|
83
|
+
self._buckets.pop(key, None)
|
|
84
|
+
return False
|
|
85
|
+
bucket.append(now)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def prune_idle(self, max_idle_seconds: float = 300.0) -> int:
|
|
89
|
+
"""Remove buckets whose newest timestamp is older than max_idle_seconds.
|
|
90
|
+
|
|
91
|
+
Bounds memory growth under a high-cardinality key space (e.g. spoofed
|
|
92
|
+
API keys). Call periodically from a background thread or request path.
|
|
93
|
+
Returns the number of buckets removed.
|
|
94
|
+
"""
|
|
95
|
+
cutoff = time.time() - max_idle_seconds
|
|
96
|
+
removed = 0
|
|
97
|
+
with self._lock:
|
|
98
|
+
stale = [
|
|
99
|
+
key for key, bucket in self._buckets.items()
|
|
100
|
+
if not bucket or bucket[-1] < cutoff
|
|
101
|
+
]
|
|
102
|
+
for key in stale:
|
|
103
|
+
self._buckets.pop(key, None)
|
|
104
|
+
removed += 1
|
|
105
|
+
return removed
|
|
106
|
+
|
|
107
|
+
def reset(self) -> None:
|
|
108
|
+
"""Clear all windows (used by tests and on config reload)."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._buckets.clear()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RedisRateLimiter:
|
|
114
|
+
"""Redis sorted-set sliding-window limiter shared across replicas."""
|
|
115
|
+
|
|
116
|
+
backend = "redis"
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
client: Any,
|
|
121
|
+
prefix: str = "synapse:rl:",
|
|
122
|
+
fallback: Optional[InProcessRateLimiter] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._client = client
|
|
125
|
+
self._prefix = prefix
|
|
126
|
+
# Local safety net used when Redis is temporarily unreachable.
|
|
127
|
+
self._fallback = fallback if fallback is not None else InProcessRateLimiter()
|
|
128
|
+
try:
|
|
129
|
+
self._script = client.register_script(_SLIDING_WINDOW_LUA)
|
|
130
|
+
except Exception as exc: # pragma: no cover - client-specific
|
|
131
|
+
logger.warning("Redis EVAL unavailable (%s); falling back per-call.", exc)
|
|
132
|
+
self._script = None
|
|
133
|
+
|
|
134
|
+
def check(self, key: str, limit: int, window_s: float = 60.0) -> bool:
|
|
135
|
+
if self._script is None:
|
|
136
|
+
return self._fallback.check(key, limit, window_s)
|
|
137
|
+
redis_key = f"{self._prefix}{key}"
|
|
138
|
+
member = f"{time.time()}-{uuid.uuid4().hex}"
|
|
139
|
+
try:
|
|
140
|
+
allowed = self._script(
|
|
141
|
+
keys=[redis_key],
|
|
142
|
+
args=[time.time(), window_s, limit, member],
|
|
143
|
+
)
|
|
144
|
+
return bool(int(allowed))
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("Redis rate-limit check failed (%s); using in-process fallback.", exc)
|
|
147
|
+
return self._fallback.check(key, limit, window_s)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_rate_limiter(redis_url: Optional[str] = None) -> Any:
|
|
151
|
+
"""Construct the best available limiter.
|
|
152
|
+
|
|
153
|
+
Prefers a Redis-backed limiter when ``REDIS_URL`` (or the explicit argument)
|
|
154
|
+
is set and Redis is reachable; otherwise returns an
|
|
155
|
+
:class:`InProcessRateLimiter`. Never raises.
|
|
156
|
+
"""
|
|
157
|
+
url = redis_url if redis_url is not None else os.environ.get("REDIS_URL", "")
|
|
158
|
+
if not url:
|
|
159
|
+
logger.info("REDIS_URL not set; using in-process rate limiter (single-replica only).")
|
|
160
|
+
return InProcessRateLimiter()
|
|
161
|
+
try:
|
|
162
|
+
import redis # type: ignore
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.warning(
|
|
165
|
+
"REDIS_URL set but the 'redis' package is not installed; using in-process rate limiter."
|
|
166
|
+
)
|
|
167
|
+
return InProcessRateLimiter()
|
|
168
|
+
try:
|
|
169
|
+
client = redis.Redis.from_url(
|
|
170
|
+
url,
|
|
171
|
+
socket_connect_timeout=2.0,
|
|
172
|
+
socket_timeout=2.0,
|
|
173
|
+
decode_responses=False,
|
|
174
|
+
)
|
|
175
|
+
client.ping()
|
|
176
|
+
logger.info("Distributed rate limiting enabled via Redis.")
|
|
177
|
+
return RedisRateLimiter(client)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("Redis unreachable (%s); using in-process rate limiter.", exc)
|
|
180
|
+
return InProcessRateLimiter()
|