mnemoverse 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.
- mnemoverse-0.1.0/.gitignore +12 -0
- mnemoverse-0.1.0/LICENSE +21 -0
- mnemoverse-0.1.0/PKG-INFO +104 -0
- mnemoverse-0.1.0/README.md +71 -0
- mnemoverse-0.1.0/mnemoverse/__init__.py +34 -0
- mnemoverse-0.1.0/mnemoverse/_async_client.py +237 -0
- mnemoverse-0.1.0/mnemoverse/_retry.py +74 -0
- mnemoverse-0.1.0/mnemoverse/client.py +128 -0
- mnemoverse-0.1.0/mnemoverse/errors.py +35 -0
- mnemoverse-0.1.0/mnemoverse/types.py +92 -0
- mnemoverse-0.1.0/pyproject.toml +57 -0
- mnemoverse-0.1.0/tests/__init__.py +0 -0
- mnemoverse-0.1.0/tests/test_client.py +143 -0
mnemoverse-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mnemoverse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnemoverse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Mnemoverse Memory API — persistent memory for AI agents
|
|
5
|
+
Project-URL: Homepage, https://mnemoverse.com
|
|
6
|
+
Project-URL: Documentation, https://mnemoverse.com/docs/api/python-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/mnemoverse/mnemoverse-sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/mnemoverse/mnemoverse-sdk-python/issues
|
|
9
|
+
Author-email: Edward Izgorodin <helloworld@uinside.org>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,cognitive,hebbian,llm,mcp,memory
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Mnemoverse Python SDK
|
|
35
|
+
|
|
36
|
+
Persistent memory for AI agents. Not vector search — statistical learning.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install mnemoverse
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from mnemoverse import MnemoClient
|
|
48
|
+
|
|
49
|
+
client = MnemoClient(api_key="mk_live_YOUR_KEY")
|
|
50
|
+
|
|
51
|
+
# Store a memory
|
|
52
|
+
result = client.write(
|
|
53
|
+
"Retry with exponential backoff fixed the timeout issue",
|
|
54
|
+
concepts=["retry", "backoff", "timeout"]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Query — Hebbian associations expand "timeout" → "retry", "backoff"
|
|
58
|
+
memories = client.read("how to handle timeouts?")
|
|
59
|
+
|
|
60
|
+
# Report outcome — the system learns what works
|
|
61
|
+
client.feedback(
|
|
62
|
+
atom_ids=[item.atom_id for item in memories.items],
|
|
63
|
+
outcome=1.0,
|
|
64
|
+
query_concepts=memories.query_concepts
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Async Client
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from mnemoverse import AsyncMnemoClient
|
|
72
|
+
|
|
73
|
+
async with AsyncMnemoClient(api_key="mk_live_YOUR_KEY") as client:
|
|
74
|
+
result = await client.write("async memory", concepts=["async"])
|
|
75
|
+
memories = await client.read("what about async?")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Features
|
|
79
|
+
|
|
80
|
+
- **Circuit breaker** — 5 failures → open → 30s half-open → probe
|
|
81
|
+
- **Retry with backoff** — 3 attempts, rate-limit-aware
|
|
82
|
+
- **Sync + async** — `MnemoClient` for scripts, `AsyncMnemoClient` for FastAPI
|
|
83
|
+
- **Type-safe** — Pydantic models, full type hints
|
|
84
|
+
|
|
85
|
+
## Methods
|
|
86
|
+
|
|
87
|
+
| Method | Description |
|
|
88
|
+
|--------|-------------|
|
|
89
|
+
| `write(content, concepts, domain, metadata)` | Store a memory |
|
|
90
|
+
| `write_batch(items)` | Store up to 500 memories |
|
|
91
|
+
| `read(query, top_k, domain)` | Query with Hebbian expansion |
|
|
92
|
+
| `feedback(atom_ids, outcome)` | Report success/failure |
|
|
93
|
+
| `stats()` | Memory statistics |
|
|
94
|
+
| `health()` | API health check |
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
- [Getting Started](https://mnemoverse.com/docs/api/getting-started)
|
|
99
|
+
- [API Reference](https://mnemoverse.com/docs/api/reference)
|
|
100
|
+
- [Python SDK Docs](https://mnemoverse.com/docs/api/python-sdk)
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Mnemoverse Python SDK
|
|
2
|
+
|
|
3
|
+
Persistent memory for AI agents. Not vector search — statistical learning.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mnemoverse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from mnemoverse import MnemoClient
|
|
15
|
+
|
|
16
|
+
client = MnemoClient(api_key="mk_live_YOUR_KEY")
|
|
17
|
+
|
|
18
|
+
# Store a memory
|
|
19
|
+
result = client.write(
|
|
20
|
+
"Retry with exponential backoff fixed the timeout issue",
|
|
21
|
+
concepts=["retry", "backoff", "timeout"]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Query — Hebbian associations expand "timeout" → "retry", "backoff"
|
|
25
|
+
memories = client.read("how to handle timeouts?")
|
|
26
|
+
|
|
27
|
+
# Report outcome — the system learns what works
|
|
28
|
+
client.feedback(
|
|
29
|
+
atom_ids=[item.atom_id for item in memories.items],
|
|
30
|
+
outcome=1.0,
|
|
31
|
+
query_concepts=memories.query_concepts
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Async Client
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from mnemoverse import AsyncMnemoClient
|
|
39
|
+
|
|
40
|
+
async with AsyncMnemoClient(api_key="mk_live_YOUR_KEY") as client:
|
|
41
|
+
result = await client.write("async memory", concepts=["async"])
|
|
42
|
+
memories = await client.read("what about async?")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Circuit breaker** — 5 failures → open → 30s half-open → probe
|
|
48
|
+
- **Retry with backoff** — 3 attempts, rate-limit-aware
|
|
49
|
+
- **Sync + async** — `MnemoClient` for scripts, `AsyncMnemoClient` for FastAPI
|
|
50
|
+
- **Type-safe** — Pydantic models, full type hints
|
|
51
|
+
|
|
52
|
+
## Methods
|
|
53
|
+
|
|
54
|
+
| Method | Description |
|
|
55
|
+
|--------|-------------|
|
|
56
|
+
| `write(content, concepts, domain, metadata)` | Store a memory |
|
|
57
|
+
| `write_batch(items)` | Store up to 500 memories |
|
|
58
|
+
| `read(query, top_k, domain)` | Query with Hebbian expansion |
|
|
59
|
+
| `feedback(atom_ids, outcome)` | Report success/failure |
|
|
60
|
+
| `stats()` | Memory statistics |
|
|
61
|
+
| `health()` | API health check |
|
|
62
|
+
|
|
63
|
+
## Documentation
|
|
64
|
+
|
|
65
|
+
- [Getting Started](https://mnemoverse.com/docs/api/getting-started)
|
|
66
|
+
- [API Reference](https://mnemoverse.com/docs/api/reference)
|
|
67
|
+
- [Python SDK Docs](https://mnemoverse.com/docs/api/python-sdk)
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Mnemoverse Python SDK — persistent memory for AI agents."""
|
|
2
|
+
|
|
3
|
+
from mnemoverse.client import MnemoClient
|
|
4
|
+
from mnemoverse._async_client import AsyncMnemoClient
|
|
5
|
+
from mnemoverse.errors import MnemoError, MnemoAuthError, MnemoRateLimitError, MnemoUnavailableError
|
|
6
|
+
from mnemoverse.types import (
|
|
7
|
+
WriteResponse,
|
|
8
|
+
WriteBatchResponse,
|
|
9
|
+
WriteBatchItemResult,
|
|
10
|
+
ReadResponse,
|
|
11
|
+
MemoryItem,
|
|
12
|
+
FeedbackResponse,
|
|
13
|
+
StatsResponse,
|
|
14
|
+
HealthResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MnemoClient",
|
|
21
|
+
"AsyncMnemoClient",
|
|
22
|
+
"MnemoError",
|
|
23
|
+
"MnemoAuthError",
|
|
24
|
+
"MnemoRateLimitError",
|
|
25
|
+
"MnemoUnavailableError",
|
|
26
|
+
"WriteResponse",
|
|
27
|
+
"WriteBatchResponse",
|
|
28
|
+
"WriteBatchItemResult",
|
|
29
|
+
"ReadResponse",
|
|
30
|
+
"MemoryItem",
|
|
31
|
+
"FeedbackResponse",
|
|
32
|
+
"StatsResponse",
|
|
33
|
+
"HealthResponse",
|
|
34
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Async Mnemoverse client using httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mnemoverse._retry import CircuitBreaker, retry_with_backoff
|
|
11
|
+
from mnemoverse.errors import (
|
|
12
|
+
MnemoAuthError,
|
|
13
|
+
MnemoError,
|
|
14
|
+
MnemoRateLimitError,
|
|
15
|
+
MnemoUnavailableError,
|
|
16
|
+
)
|
|
17
|
+
from mnemoverse.types import (
|
|
18
|
+
FeedbackResponse,
|
|
19
|
+
HealthResponse,
|
|
20
|
+
ReadResponse,
|
|
21
|
+
StatsResponse,
|
|
22
|
+
WriteBatchResponse,
|
|
23
|
+
WriteResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_DEFAULT_BASE_URL = "https://api.mnemoverse.com"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncMnemoClient:
|
|
30
|
+
"""Async client for the Mnemoverse Memory API.
|
|
31
|
+
|
|
32
|
+
Features:
|
|
33
|
+
- Circuit breaker (5 failures → open → 30s half-open)
|
|
34
|
+
- Timeout (10s default)
|
|
35
|
+
- Retry with exponential backoff (3 attempts, rate-limit-aware)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
42
|
+
timeout: float = 10.0,
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._api_key = api_key
|
|
46
|
+
self._base_url = base_url.rstrip("/")
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._max_retries = max_retries
|
|
49
|
+
self._cb = CircuitBreaker(failure_threshold=5, reset_timeout=30.0)
|
|
50
|
+
self._client: httpx.AsyncClient | None = None
|
|
51
|
+
|
|
52
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
53
|
+
if self._client is None or self._client.is_closed:
|
|
54
|
+
self._client = httpx.AsyncClient(
|
|
55
|
+
base_url=self._base_url,
|
|
56
|
+
headers={
|
|
57
|
+
"X-Api-Key": self._api_key,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Accept": "application/json",
|
|
60
|
+
},
|
|
61
|
+
timeout=self._timeout,
|
|
62
|
+
)
|
|
63
|
+
return self._client
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
if self._client and not self._client.is_closed:
|
|
67
|
+
await self._client.aclose()
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> AsyncMnemoClient:
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
73
|
+
await self.close()
|
|
74
|
+
|
|
75
|
+
# --- Public API ---
|
|
76
|
+
|
|
77
|
+
async def write(
|
|
78
|
+
self,
|
|
79
|
+
content: str,
|
|
80
|
+
*,
|
|
81
|
+
concepts: list[str] | None = None,
|
|
82
|
+
domain: str = "general",
|
|
83
|
+
metadata: dict[str, Any] | None = None,
|
|
84
|
+
external_ref: str | None = None,
|
|
85
|
+
) -> WriteResponse:
|
|
86
|
+
"""Store a single memory atom."""
|
|
87
|
+
body: dict[str, Any] = {"content": content, "domain": domain}
|
|
88
|
+
if concepts:
|
|
89
|
+
body["concepts"] = concepts
|
|
90
|
+
if metadata:
|
|
91
|
+
body["metadata"] = metadata
|
|
92
|
+
if external_ref:
|
|
93
|
+
body["external_ref"] = external_ref
|
|
94
|
+
data = await self._request("POST", "/api/v1/memory/write", json=body)
|
|
95
|
+
return WriteResponse.model_validate(data)
|
|
96
|
+
|
|
97
|
+
async def write_batch(
|
|
98
|
+
self,
|
|
99
|
+
items: list[dict[str, Any]],
|
|
100
|
+
) -> WriteBatchResponse:
|
|
101
|
+
"""Store up to 500 atoms in one request."""
|
|
102
|
+
data = await self._request("POST", "/api/v1/memory/write-batch", json={"items": items})
|
|
103
|
+
return WriteBatchResponse.model_validate(data)
|
|
104
|
+
|
|
105
|
+
async def read(
|
|
106
|
+
self,
|
|
107
|
+
query: str,
|
|
108
|
+
*,
|
|
109
|
+
top_k: int = 10,
|
|
110
|
+
domain: str | None = None,
|
|
111
|
+
min_relevance: float = 0.3,
|
|
112
|
+
include_associations: bool = True,
|
|
113
|
+
concepts: list[str] | None = None,
|
|
114
|
+
) -> ReadResponse:
|
|
115
|
+
"""Query memory with semantic search + Hebbian expansion."""
|
|
116
|
+
body: dict[str, Any] = {
|
|
117
|
+
"query": query,
|
|
118
|
+
"top_k": top_k,
|
|
119
|
+
"min_relevance": min_relevance,
|
|
120
|
+
"include_associations": include_associations,
|
|
121
|
+
}
|
|
122
|
+
if domain:
|
|
123
|
+
body["domain"] = domain
|
|
124
|
+
if concepts:
|
|
125
|
+
body["concepts"] = concepts
|
|
126
|
+
data = await self._request("POST", "/api/v1/memory/read", json=body)
|
|
127
|
+
return ReadResponse.model_validate(data)
|
|
128
|
+
|
|
129
|
+
async def feedback(
|
|
130
|
+
self,
|
|
131
|
+
atom_ids: list[UUID | str],
|
|
132
|
+
outcome: float,
|
|
133
|
+
*,
|
|
134
|
+
concepts: list[str] | None = None,
|
|
135
|
+
query_concepts: list[str] | None = None,
|
|
136
|
+
domain: str = "general",
|
|
137
|
+
) -> FeedbackResponse:
|
|
138
|
+
"""Report outcome (success/failure) for memories."""
|
|
139
|
+
body: dict[str, Any] = {
|
|
140
|
+
"atom_ids": [str(aid) for aid in atom_ids],
|
|
141
|
+
"outcome": outcome,
|
|
142
|
+
"domain": domain,
|
|
143
|
+
}
|
|
144
|
+
if concepts:
|
|
145
|
+
body["concepts"] = concepts
|
|
146
|
+
if query_concepts:
|
|
147
|
+
body["query_concepts"] = query_concepts
|
|
148
|
+
data = await self._request("POST", "/api/v1/memory/feedback", json=body)
|
|
149
|
+
return FeedbackResponse.model_validate(data)
|
|
150
|
+
|
|
151
|
+
async def stats(self) -> StatsResponse:
|
|
152
|
+
"""Get memory statistics."""
|
|
153
|
+
data = await self._request("GET", "/api/v1/memory/stats")
|
|
154
|
+
return StatsResponse.model_validate(data)
|
|
155
|
+
|
|
156
|
+
async def health(self) -> HealthResponse:
|
|
157
|
+
"""Check API health."""
|
|
158
|
+
data = await self._request("GET", "/api/v1/health")
|
|
159
|
+
return HealthResponse.model_validate(data)
|
|
160
|
+
|
|
161
|
+
# --- Internal ---
|
|
162
|
+
|
|
163
|
+
async def _request(
|
|
164
|
+
self,
|
|
165
|
+
method: str,
|
|
166
|
+
path: str,
|
|
167
|
+
json: dict[str, Any] | None = None,
|
|
168
|
+
) -> Any:
|
|
169
|
+
if not self._cb.can_execute():
|
|
170
|
+
raise MnemoUnavailableError(
|
|
171
|
+
f"Circuit breaker open (state: {self._cb.state})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def is_retryable(e: Exception) -> bool:
|
|
175
|
+
if isinstance(e, MnemoRateLimitError):
|
|
176
|
+
return True
|
|
177
|
+
if isinstance(e, MnemoError) and e.status and e.status >= 500:
|
|
178
|
+
return True
|
|
179
|
+
if isinstance(e, (httpx.ConnectError, httpx.TimeoutException)):
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
async def attempt() -> Any:
|
|
184
|
+
return await self._single_request(method, path, json)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
result = await retry_with_backoff(
|
|
188
|
+
attempt,
|
|
189
|
+
max_retries=self._max_retries,
|
|
190
|
+
retryable_check=is_retryable,
|
|
191
|
+
)
|
|
192
|
+
self._cb.on_success()
|
|
193
|
+
return result
|
|
194
|
+
except (MnemoAuthError, MnemoError) as e:
|
|
195
|
+
if isinstance(e, MnemoAuthError):
|
|
196
|
+
raise
|
|
197
|
+
self._cb.on_failure()
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
async def _single_request(
|
|
201
|
+
self,
|
|
202
|
+
method: str,
|
|
203
|
+
path: str,
|
|
204
|
+
json: dict[str, Any] | None = None,
|
|
205
|
+
) -> Any:
|
|
206
|
+
client = self._get_client()
|
|
207
|
+
try:
|
|
208
|
+
response = await client.request(method, path, json=json)
|
|
209
|
+
except httpx.TimeoutException as e:
|
|
210
|
+
raise MnemoUnavailableError(f"Request timeout after {self._timeout}s", e)
|
|
211
|
+
except httpx.ConnectError as e:
|
|
212
|
+
raise MnemoUnavailableError(f"Connection error: {e}", e)
|
|
213
|
+
|
|
214
|
+
if response.status_code == 401 or response.status_code == 403:
|
|
215
|
+
raise MnemoAuthError(self._extract_detail(response))
|
|
216
|
+
|
|
217
|
+
if response.status_code == 429:
|
|
218
|
+
retry_after = response.headers.get("Retry-After")
|
|
219
|
+
raise MnemoRateLimitError(
|
|
220
|
+
self._extract_detail(response),
|
|
221
|
+
retry_after=float(retry_after) if retry_after else None,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if response.status_code >= 400:
|
|
225
|
+
raise MnemoError(self._extract_detail(response), status=response.status_code)
|
|
226
|
+
|
|
227
|
+
return response.json()
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _extract_detail(response: httpx.Response) -> str:
|
|
231
|
+
try:
|
|
232
|
+
data = response.json()
|
|
233
|
+
if isinstance(data, dict):
|
|
234
|
+
return str(data.get("detail") or data.get("message") or data)
|
|
235
|
+
return str(data)
|
|
236
|
+
except Exception:
|
|
237
|
+
return f"HTTP {response.status_code}"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Retry with exponential backoff and circuit breaker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CircuitBreaker:
|
|
11
|
+
"""Simple circuit breaker: closed → open (after N failures) → half-open (after timeout)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, failure_threshold: int = 5, reset_timeout: float = 30.0) -> None:
|
|
14
|
+
self._failure_threshold = failure_threshold
|
|
15
|
+
self._reset_timeout = reset_timeout
|
|
16
|
+
self._failures = 0
|
|
17
|
+
self._last_failure_time = 0.0
|
|
18
|
+
self._state: str = "closed" # closed | open | half-open
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def state(self) -> str:
|
|
22
|
+
return self._state
|
|
23
|
+
|
|
24
|
+
def can_execute(self) -> bool:
|
|
25
|
+
if self._state == "closed":
|
|
26
|
+
return True
|
|
27
|
+
if self._state == "open":
|
|
28
|
+
if time.monotonic() - self._last_failure_time >= self._reset_timeout:
|
|
29
|
+
self._state = "half-open"
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
return False # half-open: block until probe completes
|
|
33
|
+
|
|
34
|
+
def on_success(self) -> None:
|
|
35
|
+
self._failures = 0
|
|
36
|
+
self._state = "closed"
|
|
37
|
+
|
|
38
|
+
def on_failure(self) -> None:
|
|
39
|
+
self._failures += 1
|
|
40
|
+
self._last_failure_time = time.monotonic()
|
|
41
|
+
if self._failures >= self._failure_threshold:
|
|
42
|
+
self._state = "open"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def retry_with_backoff(
|
|
46
|
+
coro_factory: Any,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
base_delay: float = 0.1,
|
|
49
|
+
max_delay: float = 2.0,
|
|
50
|
+
retryable_check: Any = None,
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Execute an async callable with exponential backoff.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
coro_factory: Async callable (called each attempt).
|
|
56
|
+
max_retries: Max retry attempts (total attempts = max_retries + 1).
|
|
57
|
+
base_delay: Initial backoff delay in seconds.
|
|
58
|
+
max_delay: Maximum backoff delay in seconds.
|
|
59
|
+
retryable_check: Optional callable(exception) -> bool.
|
|
60
|
+
"""
|
|
61
|
+
last_error: Exception | None = None
|
|
62
|
+
|
|
63
|
+
for attempt in range(max_retries + 1):
|
|
64
|
+
try:
|
|
65
|
+
return await coro_factory()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
last_error = e
|
|
68
|
+
if retryable_check and not retryable_check(e):
|
|
69
|
+
raise
|
|
70
|
+
if attempt < max_retries:
|
|
71
|
+
delay = min(base_delay * (2 ** attempt), max_delay)
|
|
72
|
+
await asyncio.sleep(delay)
|
|
73
|
+
|
|
74
|
+
raise last_error # type: ignore[misc]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Synchronous Mnemoverse client — wraps AsyncMnemoClient with asyncio.run()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from mnemoverse._async_client import AsyncMnemoClient
|
|
10
|
+
from mnemoverse.types import (
|
|
11
|
+
FeedbackResponse,
|
|
12
|
+
HealthResponse,
|
|
13
|
+
ReadResponse,
|
|
14
|
+
StatsResponse,
|
|
15
|
+
WriteBatchResponse,
|
|
16
|
+
WriteResponse,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MnemoClient:
|
|
21
|
+
"""Synchronous client for the Mnemoverse Memory API.
|
|
22
|
+
|
|
23
|
+
Wraps AsyncMnemoClient for use in scripts, notebooks, and sync applications.
|
|
24
|
+
For async applications (FastAPI, Discord bots), use AsyncMnemoClient directly.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
client = MnemoClient(api_key="mk_live_...")
|
|
28
|
+
result = client.write("Caching reduces latency", concepts=["caching"])
|
|
29
|
+
memories = client.read("how to reduce latency?")
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str,
|
|
35
|
+
base_url: str = "https://api.mnemoverse.com",
|
|
36
|
+
timeout: float = 10.0,
|
|
37
|
+
max_retries: int = 3,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._async_client = AsyncMnemoClient(
|
|
40
|
+
api_key=api_key,
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
max_retries=max_retries,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _run(self, coro: Any) -> Any:
|
|
47
|
+
try:
|
|
48
|
+
loop = asyncio.get_running_loop()
|
|
49
|
+
except RuntimeError:
|
|
50
|
+
loop = None
|
|
51
|
+
|
|
52
|
+
if loop and loop.is_running():
|
|
53
|
+
# Inside an existing event loop (e.g., Jupyter notebook)
|
|
54
|
+
import concurrent.futures
|
|
55
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
56
|
+
return pool.submit(asyncio.run, coro).result()
|
|
57
|
+
else:
|
|
58
|
+
return asyncio.run(coro)
|
|
59
|
+
|
|
60
|
+
def write(
|
|
61
|
+
self,
|
|
62
|
+
content: str,
|
|
63
|
+
*,
|
|
64
|
+
concepts: list[str] | None = None,
|
|
65
|
+
domain: str = "general",
|
|
66
|
+
metadata: dict[str, Any] | None = None,
|
|
67
|
+
external_ref: str | None = None,
|
|
68
|
+
) -> WriteResponse:
|
|
69
|
+
"""Store a single memory atom."""
|
|
70
|
+
return self._run(
|
|
71
|
+
self._async_client.write(
|
|
72
|
+
content, concepts=concepts, domain=domain,
|
|
73
|
+
metadata=metadata, external_ref=external_ref,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def write_batch(self, items: list[dict[str, Any]]) -> WriteBatchResponse:
|
|
78
|
+
"""Store up to 500 atoms in one request."""
|
|
79
|
+
return self._run(self._async_client.write_batch(items))
|
|
80
|
+
|
|
81
|
+
def read(
|
|
82
|
+
self,
|
|
83
|
+
query: str,
|
|
84
|
+
*,
|
|
85
|
+
top_k: int = 10,
|
|
86
|
+
domain: str | None = None,
|
|
87
|
+
min_relevance: float = 0.3,
|
|
88
|
+
include_associations: bool = True,
|
|
89
|
+
concepts: list[str] | None = None,
|
|
90
|
+
) -> ReadResponse:
|
|
91
|
+
"""Query memory with semantic search + Hebbian expansion."""
|
|
92
|
+
return self._run(
|
|
93
|
+
self._async_client.read(
|
|
94
|
+
query, top_k=top_k, domain=domain,
|
|
95
|
+
min_relevance=min_relevance,
|
|
96
|
+
include_associations=include_associations,
|
|
97
|
+
concepts=concepts,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def feedback(
|
|
102
|
+
self,
|
|
103
|
+
atom_ids: list[UUID | str],
|
|
104
|
+
outcome: float,
|
|
105
|
+
*,
|
|
106
|
+
concepts: list[str] | None = None,
|
|
107
|
+
query_concepts: list[str] | None = None,
|
|
108
|
+
domain: str = "general",
|
|
109
|
+
) -> FeedbackResponse:
|
|
110
|
+
"""Report outcome (success/failure) for memories."""
|
|
111
|
+
return self._run(
|
|
112
|
+
self._async_client.feedback(
|
|
113
|
+
atom_ids, outcome, concepts=concepts,
|
|
114
|
+
query_concepts=query_concepts, domain=domain,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def stats(self) -> StatsResponse:
|
|
119
|
+
"""Get memory statistics."""
|
|
120
|
+
return self._run(self._async_client.stats())
|
|
121
|
+
|
|
122
|
+
def health(self) -> HealthResponse:
|
|
123
|
+
"""Check API health."""
|
|
124
|
+
return self._run(self._async_client.health())
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Close the underlying HTTP client."""
|
|
128
|
+
self._run(self._async_client.close())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Mnemoverse SDK error types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MnemoError(Exception):
|
|
7
|
+
"""Base error for all Mnemoverse API errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.status = status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MnemoAuthError(MnemoError):
|
|
16
|
+
"""Invalid or missing API key (401/403)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str = "Invalid or missing API key") -> None:
|
|
19
|
+
super().__init__(message, status=401)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MnemoRateLimitError(MnemoError):
|
|
23
|
+
"""Rate limit exceeded (429)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "Rate limit exceeded", retry_after: float | None = None) -> None:
|
|
26
|
+
super().__init__(message, status=429)
|
|
27
|
+
self.retry_after = retry_after
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MnemoUnavailableError(MnemoError):
|
|
31
|
+
"""Service unreachable — circuit breaker open, network error, or timeout."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, cause: Exception | None = None) -> None:
|
|
34
|
+
super().__init__(message, status=None)
|
|
35
|
+
self.__cause__ = cause
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Pydantic models matching mnemoverse-core REST API schemas.
|
|
2
|
+
|
|
3
|
+
Source of truth: mnemoverse-core/src/mnemo/api/schemas.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --- Write ---
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WriteResponse(BaseModel):
|
|
18
|
+
stored: bool
|
|
19
|
+
atom_id: UUID | None = None
|
|
20
|
+
importance: float = 0.0
|
|
21
|
+
reason: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WriteBatchItemResult(BaseModel):
|
|
25
|
+
index: int
|
|
26
|
+
stored: bool
|
|
27
|
+
atom_id: UUID | None = None
|
|
28
|
+
importance: float = 0.0
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WriteBatchResponse(BaseModel):
|
|
33
|
+
total_count: int
|
|
34
|
+
stored_count: int
|
|
35
|
+
results: list[WriteBatchItemResult]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --- Read ---
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryItem(BaseModel):
|
|
42
|
+
atom_id: UUID
|
|
43
|
+
content: str
|
|
44
|
+
relevance: float
|
|
45
|
+
similarity: float
|
|
46
|
+
valence: float
|
|
47
|
+
importance: float
|
|
48
|
+
source: str
|
|
49
|
+
concepts: list[str]
|
|
50
|
+
domain: str
|
|
51
|
+
metadata: dict[str, Any] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReadResponse(BaseModel):
|
|
55
|
+
items: list[MemoryItem]
|
|
56
|
+
episodic_hit: bool
|
|
57
|
+
query_concepts: list[str]
|
|
58
|
+
expanded_concepts: list[str]
|
|
59
|
+
search_time_ms: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Feedback ---
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FeedbackResponse(BaseModel):
|
|
66
|
+
updated_count: int
|
|
67
|
+
avg_valence: float
|
|
68
|
+
coactivation_edges: int = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- Stats ---
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StatsResponse(BaseModel):
|
|
75
|
+
total_atoms: int
|
|
76
|
+
episodes: int
|
|
77
|
+
prototypes: int
|
|
78
|
+
singletons: int
|
|
79
|
+
hebbian_edges: int
|
|
80
|
+
episodic_fingerprints: int
|
|
81
|
+
domains: list[str]
|
|
82
|
+
avg_valence: float
|
|
83
|
+
avg_importance: float
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- Health ---
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class HealthResponse(BaseModel):
|
|
90
|
+
status: str
|
|
91
|
+
database: bool
|
|
92
|
+
version: str
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mnemoverse"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Mnemoverse Memory API — persistent memory for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Edward Izgorodin", email = "helloworld@uinside.org" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ai", "memory", "agents", "llm", "mcp", "hebbian", "cognitive"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.25.0",
|
|
30
|
+
"pydantic>=2.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://mnemoverse.com"
|
|
35
|
+
Documentation = "https://mnemoverse.com/docs/api/python-sdk"
|
|
36
|
+
Repository = "https://github.com/mnemoverse/mnemoverse-sdk-python"
|
|
37
|
+
Issues = "https://github.com/mnemoverse/mnemoverse-sdk-python/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"pytest-asyncio>=0.24",
|
|
43
|
+
"pytest-httpx>=0.30",
|
|
44
|
+
"ruff>=0.4",
|
|
45
|
+
"mypy>=1.10",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
line-length = 100
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
python_version = "3.10"
|
|
54
|
+
strict = true
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Tests for MnemoClient and AsyncMnemoClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import httpx
|
|
7
|
+
from pytest_httpx import HTTPXMock
|
|
8
|
+
|
|
9
|
+
from mnemoverse import AsyncMnemoClient, MnemoAuthError, MnemoRateLimitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client():
|
|
14
|
+
return AsyncMnemoClient(
|
|
15
|
+
api_key="mk_test_abc123",
|
|
16
|
+
base_url="https://test.api.mnemoverse.com",
|
|
17
|
+
timeout=5.0,
|
|
18
|
+
max_retries=0, # no retries in tests
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_write(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
23
|
+
httpx_mock.add_response(
|
|
24
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/write",
|
|
25
|
+
json={
|
|
26
|
+
"stored": True,
|
|
27
|
+
"atom_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
28
|
+
"importance": 0.85,
|
|
29
|
+
"reason": "novel insight",
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
result = await client.write("test memory", concepts=["test"])
|
|
34
|
+
|
|
35
|
+
assert result.stored is True
|
|
36
|
+
assert str(result.atom_id) == "550e8400-e29b-41d4-a716-446655440000"
|
|
37
|
+
assert result.importance == 0.85
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_read(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
41
|
+
httpx_mock.add_response(
|
|
42
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/read",
|
|
43
|
+
json={
|
|
44
|
+
"items": [
|
|
45
|
+
{
|
|
46
|
+
"atom_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
47
|
+
"content": "test memory",
|
|
48
|
+
"relevance": 0.92,
|
|
49
|
+
"similarity": 0.87,
|
|
50
|
+
"valence": 0.5,
|
|
51
|
+
"importance": 0.85,
|
|
52
|
+
"source": "semantic",
|
|
53
|
+
"concepts": ["test"],
|
|
54
|
+
"domain": "general",
|
|
55
|
+
"metadata": {},
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"episodic_hit": False,
|
|
59
|
+
"query_concepts": ["test"],
|
|
60
|
+
"expanded_concepts": ["test"],
|
|
61
|
+
"search_time_ms": 12.5,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
result = await client.read("test query")
|
|
66
|
+
|
|
67
|
+
assert len(result.items) == 1
|
|
68
|
+
assert result.items[0].content == "test memory"
|
|
69
|
+
assert result.search_time_ms == 12.5
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_feedback(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
73
|
+
httpx_mock.add_response(
|
|
74
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/feedback",
|
|
75
|
+
json={"updated_count": 1, "avg_valence": 0.8, "coactivation_edges": 3},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
result = await client.feedback(
|
|
79
|
+
atom_ids=["550e8400-e29b-41d4-a716-446655440000"],
|
|
80
|
+
outcome=1.0,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert result.updated_count == 1
|
|
84
|
+
assert result.avg_valence == 0.8
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_stats(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
88
|
+
httpx_mock.add_response(
|
|
89
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/stats",
|
|
90
|
+
json={
|
|
91
|
+
"total_atoms": 100,
|
|
92
|
+
"episodes": 80,
|
|
93
|
+
"prototypes": 15,
|
|
94
|
+
"singletons": 5,
|
|
95
|
+
"hebbian_edges": 250,
|
|
96
|
+
"episodic_fingerprints": 10,
|
|
97
|
+
"domains": ["general", "engineering"],
|
|
98
|
+
"avg_valence": 0.3,
|
|
99
|
+
"avg_importance": 0.6,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
result = await client.stats()
|
|
104
|
+
|
|
105
|
+
assert result.total_atoms == 100
|
|
106
|
+
assert "engineering" in result.domains
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_health(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
110
|
+
httpx_mock.add_response(
|
|
111
|
+
url="https://test.api.mnemoverse.com/api/v1/health",
|
|
112
|
+
json={"status": "ok", "database": True, "version": "1.0.0"},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
result = await client.health()
|
|
116
|
+
|
|
117
|
+
assert result.status == "ok"
|
|
118
|
+
assert result.database is True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_auth_error(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
122
|
+
httpx_mock.add_response(
|
|
123
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/read",
|
|
124
|
+
status_code=401,
|
|
125
|
+
json={"detail": "Invalid API key"},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
with pytest.raises(MnemoAuthError):
|
|
129
|
+
await client.read("test")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def test_rate_limit_error(client: AsyncMnemoClient, httpx_mock: HTTPXMock):
|
|
133
|
+
httpx_mock.add_response(
|
|
134
|
+
url="https://test.api.mnemoverse.com/api/v1/memory/read",
|
|
135
|
+
status_code=429,
|
|
136
|
+
json={"detail": "Rate limit exceeded"},
|
|
137
|
+
headers={"Retry-After": "60"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
with pytest.raises(MnemoRateLimitError) as exc_info:
|
|
141
|
+
await client.read("test")
|
|
142
|
+
|
|
143
|
+
assert exc_info.value.retry_after == 60.0
|