agent-magnet 0.1.0__tar.gz
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.
- agent_magnet-0.1.0/PKG-INFO +53 -0
- agent_magnet-0.1.0/README.md +34 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/PKG-INFO +53 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/SOURCES.txt +27 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/dependency_links.txt +1 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/entry_points.txt +2 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/requires.txt +11 -0
- agent_magnet-0.1.0/agent_magnet.egg-info/top_level.txt +1 -0
- agent_magnet-0.1.0/magnet/__init__.py +22 -0
- agent_magnet-0.1.0/magnet/aggregate_store.py +164 -0
- agent_magnet-0.1.0/magnet/buffer.py +78 -0
- agent_magnet-0.1.0/magnet/classifier.py +355 -0
- agent_magnet-0.1.0/magnet/client.py +665 -0
- agent_magnet-0.1.0/magnet/consolidation.py +322 -0
- agent_magnet-0.1.0/magnet/episodic_store.py +260 -0
- agent_magnet-0.1.0/magnet/knowledge_store.py +409 -0
- agent_magnet-0.1.0/magnet/mcp_server.py +332 -0
- agent_magnet-0.1.0/magnet/memory_orchestrator.py +215 -0
- agent_magnet-0.1.0/magnet/reflector.py +528 -0
- agent_magnet-0.1.0/magnet/router.py +118 -0
- agent_magnet-0.1.0/magnet/signals.py +140 -0
- agent_magnet-0.1.0/magnet/store.py +122 -0
- agent_magnet-0.1.0/pyproject.toml +34 -0
- agent_magnet-0.1.0/setup.cfg +4 -0
- agent_magnet-0.1.0/tests/test_buffer.py +44 -0
- agent_magnet-0.1.0/tests/test_classifier.py +42 -0
- agent_magnet-0.1.0/tests/test_episodic_store.py +179 -0
- agent_magnet-0.1.0/tests/test_memory_orchestrator.py +245 -0
- agent_magnet-0.1.0/tests/test_router.py +29 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-magnet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-learning memory infrastructure for AI products
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ai,memory,llm,agents,mcp
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi
|
|
10
|
+
Requires-Dist: redis
|
|
11
|
+
Requires-Dist: litellm
|
|
12
|
+
Requires-Dist: mcp
|
|
13
|
+
Requires-Dist: qdrant-client
|
|
14
|
+
Requires-Dist: neo4j
|
|
15
|
+
Requires-Dist: python-dotenv
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Agent Magnet
|
|
21
|
+
|
|
22
|
+
Self-learning memory infrastructure for AI products.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
```
|
|
26
|
+
pip install agent-magnet
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## MCP Setup (Claude Desktop / Cursor)
|
|
30
|
+
Add to your MCP config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"agent-magnet": {
|
|
36
|
+
"command": "agent-magnet-mcp",
|
|
37
|
+
"env": {
|
|
38
|
+
"MAGNET_REDIS_URL": "your_redis_url",
|
|
39
|
+
"MAGNET_OPENAI_KEY": "your_openai_key"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Proxy Setup (any OpenAI-compatible client)
|
|
47
|
+
```python
|
|
48
|
+
client = OpenAI(
|
|
49
|
+
base_url="https://magnet-gateway.onrender.com/v1",
|
|
50
|
+
api_key="mg_sk_...",
|
|
51
|
+
default_headers={"x-session-id": "user_123"}
|
|
52
|
+
)
|
|
53
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Agent Magnet
|
|
2
|
+
|
|
3
|
+
Self-learning memory infrastructure for AI products.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
```
|
|
7
|
+
pip install agent-magnet
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## MCP Setup (Claude Desktop / Cursor)
|
|
11
|
+
Add to your MCP config:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"agent-magnet": {
|
|
17
|
+
"command": "agent-magnet-mcp",
|
|
18
|
+
"env": {
|
|
19
|
+
"MAGNET_REDIS_URL": "your_redis_url",
|
|
20
|
+
"MAGNET_OPENAI_KEY": "your_openai_key"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Proxy Setup (any OpenAI-compatible client)
|
|
28
|
+
```python
|
|
29
|
+
client = OpenAI(
|
|
30
|
+
base_url="https://magnet-gateway.onrender.com/v1",
|
|
31
|
+
api_key="mg_sk_...",
|
|
32
|
+
default_headers={"x-session-id": "user_123"}
|
|
33
|
+
)
|
|
34
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-magnet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-learning memory infrastructure for AI products
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ai,memory,llm,agents,mcp
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi
|
|
10
|
+
Requires-Dist: redis
|
|
11
|
+
Requires-Dist: litellm
|
|
12
|
+
Requires-Dist: mcp
|
|
13
|
+
Requires-Dist: qdrant-client
|
|
14
|
+
Requires-Dist: neo4j
|
|
15
|
+
Requires-Dist: python-dotenv
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Agent Magnet
|
|
21
|
+
|
|
22
|
+
Self-learning memory infrastructure for AI products.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
```
|
|
26
|
+
pip install agent-magnet
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## MCP Setup (Claude Desktop / Cursor)
|
|
30
|
+
Add to your MCP config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"agent-magnet": {
|
|
36
|
+
"command": "agent-magnet-mcp",
|
|
37
|
+
"env": {
|
|
38
|
+
"MAGNET_REDIS_URL": "your_redis_url",
|
|
39
|
+
"MAGNET_OPENAI_KEY": "your_openai_key"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Proxy Setup (any OpenAI-compatible client)
|
|
47
|
+
```python
|
|
48
|
+
client = OpenAI(
|
|
49
|
+
base_url="https://magnet-gateway.onrender.com/v1",
|
|
50
|
+
api_key="mg_sk_...",
|
|
51
|
+
default_headers={"x-session-id": "user_123"}
|
|
52
|
+
)
|
|
53
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
agent_magnet.egg-info/PKG-INFO
|
|
4
|
+
agent_magnet.egg-info/SOURCES.txt
|
|
5
|
+
agent_magnet.egg-info/dependency_links.txt
|
|
6
|
+
agent_magnet.egg-info/entry_points.txt
|
|
7
|
+
agent_magnet.egg-info/requires.txt
|
|
8
|
+
agent_magnet.egg-info/top_level.txt
|
|
9
|
+
magnet/__init__.py
|
|
10
|
+
magnet/aggregate_store.py
|
|
11
|
+
magnet/buffer.py
|
|
12
|
+
magnet/classifier.py
|
|
13
|
+
magnet/client.py
|
|
14
|
+
magnet/consolidation.py
|
|
15
|
+
magnet/episodic_store.py
|
|
16
|
+
magnet/knowledge_store.py
|
|
17
|
+
magnet/mcp_server.py
|
|
18
|
+
magnet/memory_orchestrator.py
|
|
19
|
+
magnet/reflector.py
|
|
20
|
+
magnet/router.py
|
|
21
|
+
magnet/signals.py
|
|
22
|
+
magnet/store.py
|
|
23
|
+
tests/test_buffer.py
|
|
24
|
+
tests/test_classifier.py
|
|
25
|
+
tests/test_episodic_store.py
|
|
26
|
+
tests/test_memory_orchestrator.py
|
|
27
|
+
tests/test_router.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
magnet
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import BehavioralMemory
|
|
2
|
+
from .signals import SignalDetector
|
|
3
|
+
from .reflector import Reflector
|
|
4
|
+
from .classifier import IntelligentClassifier, ClassificationResult
|
|
5
|
+
from .router import ModelRouter, RouterDecision
|
|
6
|
+
from .episodic_store import EpisodicStore
|
|
7
|
+
from .knowledge_store import KnowledgeStore
|
|
8
|
+
from .memory_orchestrator import MemoryOrchestrator
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BehavioralMemory",
|
|
13
|
+
"SignalDetector",
|
|
14
|
+
"Reflector",
|
|
15
|
+
"IntelligentClassifier",
|
|
16
|
+
"ClassificationResult",
|
|
17
|
+
"ModelRouter",
|
|
18
|
+
"RouterDecision",
|
|
19
|
+
"EpisodicStore",
|
|
20
|
+
"KnowledgeStore",
|
|
21
|
+
"MemoryOrchestrator",
|
|
22
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════
|
|
2
|
+
# GDPR/CCPA COMPLIANCE DOCUMENTATION
|
|
3
|
+
# ═══════════════════════════════════════════════════
|
|
4
|
+
# This module processes anonymous data in compliance with GDPR
|
|
5
|
+
# and CCPA standards. It does not contain personal data (PII).
|
|
6
|
+
#
|
|
7
|
+
# Legal Basis: Anonymous Data Processing
|
|
8
|
+
# "Data rendered anonymous in such a way that the data subject
|
|
9
|
+
# is not or no longer identifiable is excluded from GDPR scope."
|
|
10
|
+
#
|
|
11
|
+
# Techniques Applied:
|
|
12
|
+
# 1. K-Anonymity (min_k=5): Patterns unique to a single user
|
|
13
|
+
# are not added to the aggregate pool.
|
|
14
|
+
# 2. Differential Privacy (Laplace, ε=1.0):
|
|
15
|
+
# Mathematical noise is added to query results.
|
|
16
|
+
# 3. Data Minimization: Only signal type, category,
|
|
17
|
+
# dimension, and value are stored.
|
|
18
|
+
# 4. TTL: 90 days — automatic destruction.
|
|
19
|
+
#
|
|
20
|
+
# NEVER enters the Aggregate store:
|
|
21
|
+
# - user_id, session_id, project_id
|
|
22
|
+
# - Message content
|
|
23
|
+
# - Exact timestamps
|
|
24
|
+
# - IP addresses
|
|
25
|
+
# ═══════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import json
|
|
29
|
+
import numpy as np
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
class AggregateSignalStore:
|
|
33
|
+
def __init__(self, redis_client: Any, min_k: int = 5, epsilon: float = 1.0):
|
|
34
|
+
self._redis = redis_client
|
|
35
|
+
self._min_k = min_k # k-anonymity: min 5 distinct records
|
|
36
|
+
self._epsilon = epsilon # differential privacy noise
|
|
37
|
+
|
|
38
|
+
def record(self, signal_type: str, query_category: str, dimension: str, dimension_value: str) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Records an anonymous signal in a GDPR-compliant manner.
|
|
41
|
+
Contains no PII — statistical counter only.
|
|
42
|
+
"""
|
|
43
|
+
if not self._redis:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
allowed_signal_types = {
|
|
47
|
+
"correction", "rejection", "preference", "clarification", "positive"
|
|
48
|
+
}
|
|
49
|
+
if signal_type not in allowed_signal_types:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
allowed_dimensions = {
|
|
53
|
+
"response_length", "detail_level", "tone", "format", "language", "unknown", "heuristic", "llm_extracted"
|
|
54
|
+
}
|
|
55
|
+
if dimension not in allowed_dimensions:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Time bucket (rounded to the hour for privacy, no exact timestamps)
|
|
59
|
+
hour_bucket = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H")
|
|
60
|
+
|
|
61
|
+
# Redis key — NO PII included
|
|
62
|
+
key = f"magnet:agg:{signal_type}:{query_category}:{dimension}:{dimension_value}"
|
|
63
|
+
counter_key = f"magnet:agg:count:{signal_type}:{query_category}"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
pipe = self._redis.pipeline()
|
|
67
|
+
pipe.incr(key)
|
|
68
|
+
pipe.incr(counter_key)
|
|
69
|
+
pipe.expire(key, 60 * 60 * 24 * 90) # 90-day TTL
|
|
70
|
+
pipe.expire(counter_key, 60 * 60 * 24 * 90)
|
|
71
|
+
pipe.execute()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def get_prior(self, query_category: str, dimension: str) -> Any:
|
|
76
|
+
"""
|
|
77
|
+
Returns the aggregate prior probability for a new user.
|
|
78
|
+
K-anonymity: Returns None if total records < min_k.
|
|
79
|
+
Differential Privacy: Adds Laplace noise to the results.
|
|
80
|
+
"""
|
|
81
|
+
if not self._redis:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
pattern = f"magnet:agg:*:{query_category}:{dimension}:*"
|
|
85
|
+
try:
|
|
86
|
+
keys = list(self._redis.scan_iter(pattern))
|
|
87
|
+
if not keys:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
counts = {}
|
|
91
|
+
total = 0
|
|
92
|
+
for key in keys:
|
|
93
|
+
value = key.split(":")[-1]
|
|
94
|
+
count = int(self._redis.get(key) or 0)
|
|
95
|
+
counts[value] = count
|
|
96
|
+
total += count
|
|
97
|
+
|
|
98
|
+
if total < self._min_k:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
noisy_counts = {}
|
|
102
|
+
for value, count in counts.items():
|
|
103
|
+
noise = np.random.laplace(0, 1.0 / self._epsilon)
|
|
104
|
+
noisy_counts[value] = max(0, count + noise)
|
|
105
|
+
|
|
106
|
+
noisy_total = sum(noisy_counts.values())
|
|
107
|
+
if noisy_total == 0:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
return {v: round(c / noisy_total, 3) for v, c in noisy_counts.items()}
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def get_cold_start_injection(self, query_category: str) -> str:
|
|
115
|
+
"""Generates the cold start injection context for a new user."""
|
|
116
|
+
if not self._redis:
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
lines = []
|
|
120
|
+
for dimension in ["response_length", "detail_level", "tone", "language", "heuristic", "llm_extracted"]:
|
|
121
|
+
prior = self.get_prior(query_category, dimension)
|
|
122
|
+
if prior:
|
|
123
|
+
top_value = max(prior, key=prior.get)
|
|
124
|
+
top_pct = int(prior[top_value] * 100)
|
|
125
|
+
if top_pct >= 55: # Add if there is a strong aggregate signal
|
|
126
|
+
lines.append(f" - {dimension}: {top_value} ({top_pct}% of users)")
|
|
127
|
+
|
|
128
|
+
if not lines:
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
"[Aggregate Prior]\n"
|
|
133
|
+
"Based on anonymized patterns from similar users:\n" +
|
|
134
|
+
"\n".join(lines) + "\n\n"
|
|
135
|
+
"Note: These are statistical suggestions. "
|
|
136
|
+
"User's own behavior takes priority."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def store_consolidated_pattern(self, pattern: dict) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Stores a cross-user consolidated pattern from ConsolidationEngine.
|
|
142
|
+
Never overwrites an existing entry that has equal or higher confidence.
|
|
143
|
+
Returns True if written, False if skipped.
|
|
144
|
+
"""
|
|
145
|
+
if not self._redis:
|
|
146
|
+
return False
|
|
147
|
+
context = pattern.get("context", "general")
|
|
148
|
+
relation = pattern.get("relation", "unknown")
|
|
149
|
+
subject = pattern.get("subject", "").lower().replace(" ", "_")
|
|
150
|
+
key = f"magnet:consolidated:{context}:{relation}:{subject}"
|
|
151
|
+
try:
|
|
152
|
+
existing_raw = self._redis.get(key)
|
|
153
|
+
if existing_raw:
|
|
154
|
+
existing = json.loads(existing_raw)
|
|
155
|
+
if existing.get("confidence", 0.0) >= pattern.get("confidence", 0.0):
|
|
156
|
+
return False
|
|
157
|
+
self._redis.setex(
|
|
158
|
+
key,
|
|
159
|
+
60 * 60 * 24 * 90,
|
|
160
|
+
json.dumps(pattern, ensure_ascii=False),
|
|
161
|
+
)
|
|
162
|
+
return True
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signal Buffer
|
|
3
|
+
-------------
|
|
4
|
+
Accumulates signals in Redis.
|
|
5
|
+
Triggers Reflector when threshold is met.
|
|
6
|
+
|
|
7
|
+
If no Redis, in-memory fallback works (for development).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_BUFFER_PREFIX = "vmm:signals:"
|
|
19
|
+
_DEFAULT_TTL = 60 * 60 * 24 * 7 # 7 days
|
|
20
|
+
_ATOMIC_FLUSH_SCRIPT = """
|
|
21
|
+
local vals = redis.call('LRANGE', KEYS[1], 0, -1)
|
|
22
|
+
redis.call('DEL', KEYS[1])
|
|
23
|
+
return vals
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SignalBuffer:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
redis_client: Any | None = None,
|
|
31
|
+
threshold: int = 5,
|
|
32
|
+
ttl: int = _DEFAULT_TTL,
|
|
33
|
+
):
|
|
34
|
+
self._redis = redis_client
|
|
35
|
+
self.threshold = threshold
|
|
36
|
+
self.ttl = ttl
|
|
37
|
+
self._memory: dict[str, list[dict]] = {} # fallback
|
|
38
|
+
|
|
39
|
+
def push(self, user_id: str, signals: list[dict]) -> int:
|
|
40
|
+
if not signals:
|
|
41
|
+
return self._count(user_id)
|
|
42
|
+
|
|
43
|
+
key = _BUFFER_PREFIX + user_id
|
|
44
|
+
enriched = [{"ts": time.time(), **s} for s in signals]
|
|
45
|
+
|
|
46
|
+
if self._redis:
|
|
47
|
+
pipe = self._redis.pipeline()
|
|
48
|
+
for sig in enriched:
|
|
49
|
+
pipe.rpush(key, json.dumps(sig))
|
|
50
|
+
pipe.expire(key, self.ttl)
|
|
51
|
+
pipe.execute()
|
|
52
|
+
return self._redis.llen(key)
|
|
53
|
+
|
|
54
|
+
self._memory.setdefault(key, []).extend(enriched)
|
|
55
|
+
return len(self._memory[key])
|
|
56
|
+
|
|
57
|
+
def should_reflect(self, user_id: str) -> bool:
|
|
58
|
+
return self._count(user_id) >= self.threshold
|
|
59
|
+
|
|
60
|
+
def flush(self, user_id: str) -> list[dict]:
|
|
61
|
+
key = _BUFFER_PREFIX + user_id
|
|
62
|
+
if self._redis:
|
|
63
|
+
results = self._redis.eval(_ATOMIC_FLUSH_SCRIPT, 1, key)
|
|
64
|
+
return [json.loads(r) for r in results]
|
|
65
|
+
|
|
66
|
+
return self._memory.pop(key, [])
|
|
67
|
+
|
|
68
|
+
def peek(self, user_id: str) -> list[dict]:
|
|
69
|
+
key = _BUFFER_PREFIX + user_id
|
|
70
|
+
if self._redis:
|
|
71
|
+
return [json.loads(r) for r in self._redis.lrange(key, 0, -1)]
|
|
72
|
+
return list(self._memory.get(key, []))
|
|
73
|
+
|
|
74
|
+
def _count(self, user_id: str) -> int:
|
|
75
|
+
key = _BUFFER_PREFIX + user_id
|
|
76
|
+
if self._redis:
|
|
77
|
+
return self._redis.llen(key)
|
|
78
|
+
return len(self._memory.get(key, []))
|