memoraeu 0.1.1__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.
- memoraeu-0.1.1/.gitignore +21 -0
- memoraeu-0.1.1/PKG-INFO +127 -0
- memoraeu-0.1.1/README.md +114 -0
- memoraeu-0.1.1/memoraeu/__init__.py +20 -0
- memoraeu-0.1.1/memoraeu/client.py +273 -0
- memoraeu-0.1.1/memoraeu/crypto.py +68 -0
- memoraeu-0.1.1/memoraeu/exceptions.py +23 -0
- memoraeu-0.1.1/memoraeu/models.py +57 -0
- memoraeu-0.1.1/pyproject.toml +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
env/
|
|
3
|
+
.env
|
|
4
|
+
|
|
5
|
+
# MCP config (contains secrets — use .mcp.json.example as template)
|
|
6
|
+
.mcp.json
|
|
7
|
+
|
|
8
|
+
# Database
|
|
9
|
+
memoraeu.db
|
|
10
|
+
|
|
11
|
+
# Python
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.pyc
|
|
14
|
+
*.pyo
|
|
15
|
+
.DS_Store
|
|
16
|
+
|
|
17
|
+
# Node
|
|
18
|
+
dashboard/node_modules/
|
|
19
|
+
landing/node_modules/
|
|
20
|
+
dashboard/dist/
|
|
21
|
+
landing/dist/
|
memoraeu-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memoraeu
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Couche mémoire persistante zero-knowledge pour LLMs — client Python
|
|
5
|
+
Project-URL: Homepage, https://memoraeu.com
|
|
6
|
+
Project-URL: Repository, https://github.com/pquattro/memoraeu
|
|
7
|
+
Project-URL: Documentation, https://docs.memoraeu.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: cryptography>=42.0
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# MemoraEU SDK — Python
|
|
15
|
+
|
|
16
|
+
Couche mémoire persistante **zero-knowledge** pour LLMs.
|
|
17
|
+
Compatible avec n'importe quel projet Python — sync ou async.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install memoraeu
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Démarrage rapide
|
|
26
|
+
|
|
27
|
+
### 1. Obtenir vos clés
|
|
28
|
+
|
|
29
|
+
1. Créez un compte sur [app.memoraeu.com](https://app.memoraeu.com)
|
|
30
|
+
2. **Paramètres → Clés de chiffrement** → copiez `MEMORAEU_SALT`
|
|
31
|
+
3. **Paramètres → Clés API** → créez une clé → copiez `MEMORAEU_API_KEY` (`meu-sk-…`)
|
|
32
|
+
4. `secret` = votre mot de passe de connexion MemoraEU
|
|
33
|
+
|
|
34
|
+
### 2. Client sync
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from memoraeu import MemoraEU
|
|
38
|
+
|
|
39
|
+
client = MemoraEU(
|
|
40
|
+
api_url="https://api.memoraeu.com",
|
|
41
|
+
api_key="meu-sk-...", # clé API (authentification HTTP)
|
|
42
|
+
secret="votre-mot-de-passe", # mot de passe MemoraEU (dérivation de clé locale)
|
|
43
|
+
salt="votre-kdf-salt", # salt unique à votre compte (depuis les Paramètres)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Mémoriser
|
|
47
|
+
memory = client.remember(
|
|
48
|
+
"Philippe préfère Python et FastAPI",
|
|
49
|
+
category="prefs",
|
|
50
|
+
tags=["python", "stack"]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Rechercher
|
|
54
|
+
results = client.recall("préférences tech", limit=3)
|
|
55
|
+
for r in results:
|
|
56
|
+
print(f"[{round(r.score*100)}%] {r.memory.content}")
|
|
57
|
+
|
|
58
|
+
# Supprimer
|
|
59
|
+
client.forget(memory.id)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. Client async
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from memoraeu import AsyncMemoraEU
|
|
66
|
+
|
|
67
|
+
async with AsyncMemoraEU(
|
|
68
|
+
api_url="https://api.memoraeu.com",
|
|
69
|
+
api_key="meu-sk-...",
|
|
70
|
+
secret="votre-mot-de-passe",
|
|
71
|
+
salt="votre-kdf-salt",
|
|
72
|
+
) as client:
|
|
73
|
+
memory = await client.remember("...")
|
|
74
|
+
results = await client.recall("...")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Comprendre les paramètres
|
|
78
|
+
|
|
79
|
+
| Paramètre | Rôle |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `api_key` | Authentification HTTP — Bearer token envoyé à chaque requête (`meu-sk-…`) |
|
|
82
|
+
| `secret` | Votre mot de passe MemoraEU — utilisé comme entrée PBKDF2 pour dériver la clé de chiffrement localement |
|
|
83
|
+
| `salt` | Salt KDF unique à votre compte, généré par le serveur — combiné avec `secret` pour produire la clé AES |
|
|
84
|
+
|
|
85
|
+
> **Zero-knowledge** : la clé de chiffrement est dérivée sur votre machine avec `PBKDF2(secret, salt, 210 000 itérations)`. Le serveur reçoit uniquement le contenu chiffré — il ne peut pas le lire même s'il est compromis.
|
|
86
|
+
>
|
|
87
|
+
> ⚠️ Si vous perdez votre `secret` ou votre `salt`, vos mémoires chiffrées sont irrécupérables.
|
|
88
|
+
|
|
89
|
+
## Référence
|
|
90
|
+
|
|
91
|
+
| Méthode | Description |
|
|
92
|
+
|---------|-------------|
|
|
93
|
+
| `remember(content, category?, tags?, scope?)` | Mémorise une information |
|
|
94
|
+
| `recall(query, limit=3, category?, scope?)` | Recherche sémantique |
|
|
95
|
+
| `forget(memory_id)` | Supprime une mémoire |
|
|
96
|
+
| `list_memories(category?, limit=20, scope?)` | Liste les mémoires |
|
|
97
|
+
| `list_categories()` | Catégories triées par usage |
|
|
98
|
+
|
|
99
|
+
## Gestion des erreurs
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from memoraeu import APIError, CryptoError, ConnectionError
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
client.remember("...")
|
|
106
|
+
except APIError as e:
|
|
107
|
+
print(e.status_code, str(e)) # ex: 401, 409
|
|
108
|
+
except CryptoError as e:
|
|
109
|
+
print("Problème de chiffrement", e)
|
|
110
|
+
except ConnectionError as e:
|
|
111
|
+
print("API inaccessible", e)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Auto-hébergement
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
client = MemoraEU(
|
|
118
|
+
api_url="http://localhost:8000",
|
|
119
|
+
api_key="meu-sk-...",
|
|
120
|
+
secret="...",
|
|
121
|
+
salt="...",
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Licence
|
|
126
|
+
|
|
127
|
+
MIT © MemoraEU
|
memoraeu-0.1.1/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# MemoraEU SDK — Python
|
|
2
|
+
|
|
3
|
+
Couche mémoire persistante **zero-knowledge** pour LLMs.
|
|
4
|
+
Compatible avec n'importe quel projet Python — sync ou async.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install memoraeu
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Démarrage rapide
|
|
13
|
+
|
|
14
|
+
### 1. Obtenir vos clés
|
|
15
|
+
|
|
16
|
+
1. Créez un compte sur [app.memoraeu.com](https://app.memoraeu.com)
|
|
17
|
+
2. **Paramètres → Clés de chiffrement** → copiez `MEMORAEU_SALT`
|
|
18
|
+
3. **Paramètres → Clés API** → créez une clé → copiez `MEMORAEU_API_KEY` (`meu-sk-…`)
|
|
19
|
+
4. `secret` = votre mot de passe de connexion MemoraEU
|
|
20
|
+
|
|
21
|
+
### 2. Client sync
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from memoraeu import MemoraEU
|
|
25
|
+
|
|
26
|
+
client = MemoraEU(
|
|
27
|
+
api_url="https://api.memoraeu.com",
|
|
28
|
+
api_key="meu-sk-...", # clé API (authentification HTTP)
|
|
29
|
+
secret="votre-mot-de-passe", # mot de passe MemoraEU (dérivation de clé locale)
|
|
30
|
+
salt="votre-kdf-salt", # salt unique à votre compte (depuis les Paramètres)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Mémoriser
|
|
34
|
+
memory = client.remember(
|
|
35
|
+
"Philippe préfère Python et FastAPI",
|
|
36
|
+
category="prefs",
|
|
37
|
+
tags=["python", "stack"]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Rechercher
|
|
41
|
+
results = client.recall("préférences tech", limit=3)
|
|
42
|
+
for r in results:
|
|
43
|
+
print(f"[{round(r.score*100)}%] {r.memory.content}")
|
|
44
|
+
|
|
45
|
+
# Supprimer
|
|
46
|
+
client.forget(memory.id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. Client async
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from memoraeu import AsyncMemoraEU
|
|
53
|
+
|
|
54
|
+
async with AsyncMemoraEU(
|
|
55
|
+
api_url="https://api.memoraeu.com",
|
|
56
|
+
api_key="meu-sk-...",
|
|
57
|
+
secret="votre-mot-de-passe",
|
|
58
|
+
salt="votre-kdf-salt",
|
|
59
|
+
) as client:
|
|
60
|
+
memory = await client.remember("...")
|
|
61
|
+
results = await client.recall("...")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Comprendre les paramètres
|
|
65
|
+
|
|
66
|
+
| Paramètre | Rôle |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `api_key` | Authentification HTTP — Bearer token envoyé à chaque requête (`meu-sk-…`) |
|
|
69
|
+
| `secret` | Votre mot de passe MemoraEU — utilisé comme entrée PBKDF2 pour dériver la clé de chiffrement localement |
|
|
70
|
+
| `salt` | Salt KDF unique à votre compte, généré par le serveur — combiné avec `secret` pour produire la clé AES |
|
|
71
|
+
|
|
72
|
+
> **Zero-knowledge** : la clé de chiffrement est dérivée sur votre machine avec `PBKDF2(secret, salt, 210 000 itérations)`. Le serveur reçoit uniquement le contenu chiffré — il ne peut pas le lire même s'il est compromis.
|
|
73
|
+
>
|
|
74
|
+
> ⚠️ Si vous perdez votre `secret` ou votre `salt`, vos mémoires chiffrées sont irrécupérables.
|
|
75
|
+
|
|
76
|
+
## Référence
|
|
77
|
+
|
|
78
|
+
| Méthode | Description |
|
|
79
|
+
|---------|-------------|
|
|
80
|
+
| `remember(content, category?, tags?, scope?)` | Mémorise une information |
|
|
81
|
+
| `recall(query, limit=3, category?, scope?)` | Recherche sémantique |
|
|
82
|
+
| `forget(memory_id)` | Supprime une mémoire |
|
|
83
|
+
| `list_memories(category?, limit=20, scope?)` | Liste les mémoires |
|
|
84
|
+
| `list_categories()` | Catégories triées par usage |
|
|
85
|
+
|
|
86
|
+
## Gestion des erreurs
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from memoraeu import APIError, CryptoError, ConnectionError
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
client.remember("...")
|
|
93
|
+
except APIError as e:
|
|
94
|
+
print(e.status_code, str(e)) # ex: 401, 409
|
|
95
|
+
except CryptoError as e:
|
|
96
|
+
print("Problème de chiffrement", e)
|
|
97
|
+
except ConnectionError as e:
|
|
98
|
+
print("API inaccessible", e)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Auto-hébergement
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
client = MemoraEU(
|
|
105
|
+
api_url="http://localhost:8000",
|
|
106
|
+
api_key="meu-sk-...",
|
|
107
|
+
secret="...",
|
|
108
|
+
salt="...",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Licence
|
|
113
|
+
|
|
114
|
+
MIT © MemoraEU
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoraEU SDK — Couche mémoire persistante zero-knowledge pour LLMs
|
|
3
|
+
"""
|
|
4
|
+
from .client import AsyncMemoraEU, MemoraEU
|
|
5
|
+
from .exceptions import APIError, ConnectionError, CryptoError, MemoraEUError
|
|
6
|
+
from .models import Category, Memory, SearchResult
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MemoraEU",
|
|
12
|
+
"AsyncMemoraEU",
|
|
13
|
+
"Memory",
|
|
14
|
+
"SearchResult",
|
|
15
|
+
"Category",
|
|
16
|
+
"MemoraEUError",
|
|
17
|
+
"APIError",
|
|
18
|
+
"CryptoError",
|
|
19
|
+
"ConnectionError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoraEU SDK — Client sync et async
|
|
3
|
+
|
|
4
|
+
Usage sync :
|
|
5
|
+
from memoraeu import MemoraEU
|
|
6
|
+
client = MemoraEU(api_url="http://localhost:8000", secret="...", salt="...")
|
|
7
|
+
memory = client.remember("Philippe préfère Python", category="prefs")
|
|
8
|
+
results = client.recall("préférences stack")
|
|
9
|
+
|
|
10
|
+
Usage async :
|
|
11
|
+
from memoraeu import AsyncMemoraEU
|
|
12
|
+
async with AsyncMemoraEU(api_url="...", secret="...", salt="...") as client:
|
|
13
|
+
memory = await client.remember("...")
|
|
14
|
+
results = await client.recall("...")
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .crypto import derive_key, encrypt, decrypt, is_encrypted
|
|
23
|
+
from .exceptions import APIError, CryptoError
|
|
24
|
+
from .exceptions import ConnectionError as MemoraEUConnectionError
|
|
25
|
+
from .models import Category, Memory, SearchResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BaseClient:
|
|
29
|
+
"""Logique commune : crypto, sérialisation."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_url: str = "http://localhost:8000",
|
|
34
|
+
api_key: str = "",
|
|
35
|
+
secret: str = "",
|
|
36
|
+
salt: str = "memoraeu-default-salt-v1",
|
|
37
|
+
timeout: float = 90.0,
|
|
38
|
+
):
|
|
39
|
+
self.api_url = api_url.rstrip("/")
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self._headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
42
|
+
self._key: Optional[bytes] = None
|
|
43
|
+
if secret:
|
|
44
|
+
try:
|
|
45
|
+
self._key = derive_key(secret, salt)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
raise CryptoError(f"Dérivation de clé échouée : {e}") from e
|
|
48
|
+
|
|
49
|
+
# ── Crypto ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _encrypt(self, content: str) -> str:
|
|
52
|
+
if not self._key:
|
|
53
|
+
return content
|
|
54
|
+
return encrypt(content, self._key)
|
|
55
|
+
|
|
56
|
+
def _decrypt(self, value: str) -> str:
|
|
57
|
+
if not self._key:
|
|
58
|
+
return value
|
|
59
|
+
if not is_encrypted(value):
|
|
60
|
+
return value
|
|
61
|
+
try:
|
|
62
|
+
return decrypt(value, self._key)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise CryptoError(f"Déchiffrement échoué : {e}") from e
|
|
65
|
+
|
|
66
|
+
# ── Réponses ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def _handle_response(self, response: httpx.Response) -> dict:
|
|
69
|
+
try:
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
except httpx.HTTPStatusError as e:
|
|
72
|
+
raise APIError(response.status_code, response.text) from e
|
|
73
|
+
return response.json()
|
|
74
|
+
|
|
75
|
+
def _parse_memory(self, data: dict) -> Memory:
|
|
76
|
+
return Memory.from_dict(data, decrypt_fn=self._decrypt)
|
|
77
|
+
|
|
78
|
+
def _parse_search_results(self, data: dict) -> list[SearchResult]:
|
|
79
|
+
return [SearchResult.from_dict(r, decrypt_fn=self._decrypt) for r in data.get("results", [])]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
83
|
+
# Client synchrone
|
|
84
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
class MemoraEU(_BaseClient):
|
|
87
|
+
"""Client synchrone MemoraEU."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, **kwargs):
|
|
90
|
+
super().__init__(**kwargs)
|
|
91
|
+
self._http = httpx.Client(timeout=self.timeout)
|
|
92
|
+
|
|
93
|
+
def close(self):
|
|
94
|
+
self._http.close()
|
|
95
|
+
|
|
96
|
+
def __enter__(self):
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *args):
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def _post(self, path: str, body: dict) -> dict:
|
|
103
|
+
try:
|
|
104
|
+
r = self._http.post(f"{self.api_url}{path}", json=body, headers=self._headers)
|
|
105
|
+
except httpx.ConnectError as e:
|
|
106
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
107
|
+
return self._handle_response(r)
|
|
108
|
+
|
|
109
|
+
def _get(self, path: str, params: dict = None) -> dict | list:
|
|
110
|
+
try:
|
|
111
|
+
r = self._http.get(f"{self.api_url}{path}", params=params, headers=self._headers)
|
|
112
|
+
except httpx.ConnectError as e:
|
|
113
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
114
|
+
return self._handle_response(r)
|
|
115
|
+
|
|
116
|
+
def _delete(self, path: str) -> bool:
|
|
117
|
+
try:
|
|
118
|
+
r = self._http.delete(f"{self.api_url}{path}", headers=self._headers)
|
|
119
|
+
except httpx.ConnectError as e:
|
|
120
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
121
|
+
return r.status_code == 204
|
|
122
|
+
|
|
123
|
+
# ── API publique ──────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def remember(
|
|
126
|
+
self,
|
|
127
|
+
content: str,
|
|
128
|
+
category: Optional[str] = None,
|
|
129
|
+
tags: list[str] = None,
|
|
130
|
+
source: str = "sdk",
|
|
131
|
+
scope: str = "private",
|
|
132
|
+
) -> Memory:
|
|
133
|
+
"""Mémorise une information. Le contenu est chiffré côté client si secret fourni."""
|
|
134
|
+
data = self._post("/memories", {
|
|
135
|
+
"content": self._encrypt(content),
|
|
136
|
+
"category": category,
|
|
137
|
+
"tags": tags or [],
|
|
138
|
+
"source": source,
|
|
139
|
+
"scope": scope,
|
|
140
|
+
})
|
|
141
|
+
return self._parse_memory(data)
|
|
142
|
+
|
|
143
|
+
def recall(
|
|
144
|
+
self,
|
|
145
|
+
query: str,
|
|
146
|
+
limit: int = 3,
|
|
147
|
+
category: Optional[str] = None,
|
|
148
|
+
scope: str = "private",
|
|
149
|
+
) -> list[SearchResult]:
|
|
150
|
+
"""Recherche sémantique dans les mémoires."""
|
|
151
|
+
data = self._post("/memories/search", {
|
|
152
|
+
"query": query,
|
|
153
|
+
"limit": limit,
|
|
154
|
+
"category": category,
|
|
155
|
+
"scope": scope,
|
|
156
|
+
})
|
|
157
|
+
return self._parse_search_results(data)
|
|
158
|
+
|
|
159
|
+
def forget(self, memory_id: str) -> bool:
|
|
160
|
+
"""Supprime une mémoire par son ID."""
|
|
161
|
+
return self._delete(f"/memories/{memory_id}")
|
|
162
|
+
|
|
163
|
+
def list_memories(
|
|
164
|
+
self,
|
|
165
|
+
category: Optional[str] = None,
|
|
166
|
+
limit: int = 20,
|
|
167
|
+
scope: str = "private",
|
|
168
|
+
) -> list[Memory]:
|
|
169
|
+
"""Liste les mémoires récentes."""
|
|
170
|
+
params = {"limit": limit, "scope": scope}
|
|
171
|
+
if category:
|
|
172
|
+
params["category"] = category
|
|
173
|
+
data = self._get("/memories", params=params)
|
|
174
|
+
return [self._parse_memory(m) for m in data]
|
|
175
|
+
|
|
176
|
+
def list_categories(self) -> list[Category]:
|
|
177
|
+
"""Retourne les catégories triées par usage."""
|
|
178
|
+
data = self._get("/memories/categories")
|
|
179
|
+
return [Category(name=c["name"], usage_count=c["usage_count"]) for c in data.get("categories", [])]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
183
|
+
# Client asynchrone
|
|
184
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
class AsyncMemoraEU(_BaseClient):
|
|
187
|
+
"""Client asynchrone MemoraEU."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, **kwargs):
|
|
190
|
+
super().__init__(**kwargs)
|
|
191
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
192
|
+
|
|
193
|
+
async def __aenter__(self):
|
|
194
|
+
self._http = httpx.AsyncClient(timeout=self.timeout)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
async def __aexit__(self, *args):
|
|
198
|
+
if self._http:
|
|
199
|
+
await self._http.aclose()
|
|
200
|
+
|
|
201
|
+
async def _post(self, path: str, body: dict) -> dict:
|
|
202
|
+
try:
|
|
203
|
+
r = await self._http.post(f"{self.api_url}{path}", json=body, headers=self._headers)
|
|
204
|
+
except httpx.ConnectError as e:
|
|
205
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
206
|
+
return self._handle_response(r)
|
|
207
|
+
|
|
208
|
+
async def _get(self, path: str, params: dict = None) -> dict | list:
|
|
209
|
+
try:
|
|
210
|
+
r = await self._http.get(f"{self.api_url}{path}", params=params, headers=self._headers)
|
|
211
|
+
except httpx.ConnectError as e:
|
|
212
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
213
|
+
return self._handle_response(r)
|
|
214
|
+
|
|
215
|
+
async def _delete(self, path: str) -> bool:
|
|
216
|
+
try:
|
|
217
|
+
r = await self._http.delete(f"{self.api_url}{path}", headers=self._headers)
|
|
218
|
+
except httpx.ConnectError as e:
|
|
219
|
+
raise MemoraEUConnectionError(f"Impossible de joindre {self.api_url}") from e
|
|
220
|
+
return r.status_code == 204
|
|
221
|
+
|
|
222
|
+
# ── API publique ──────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
async def remember(
|
|
225
|
+
self,
|
|
226
|
+
content: str,
|
|
227
|
+
category: Optional[str] = None,
|
|
228
|
+
tags: list[str] = None,
|
|
229
|
+
source: str = "sdk",
|
|
230
|
+
scope: str = "private",
|
|
231
|
+
) -> Memory:
|
|
232
|
+
data = await self._post("/memories", {
|
|
233
|
+
"content": self._encrypt(content),
|
|
234
|
+
"category": category,
|
|
235
|
+
"tags": tags or [],
|
|
236
|
+
"source": source,
|
|
237
|
+
"scope": scope,
|
|
238
|
+
})
|
|
239
|
+
return self._parse_memory(data)
|
|
240
|
+
|
|
241
|
+
async def recall(
|
|
242
|
+
self,
|
|
243
|
+
query: str,
|
|
244
|
+
limit: int = 3,
|
|
245
|
+
category: Optional[str] = None,
|
|
246
|
+
scope: str = "private",
|
|
247
|
+
) -> list[SearchResult]:
|
|
248
|
+
data = await self._post("/memories/search", {
|
|
249
|
+
"query": query,
|
|
250
|
+
"limit": limit,
|
|
251
|
+
"category": category,
|
|
252
|
+
"scope": scope,
|
|
253
|
+
})
|
|
254
|
+
return self._parse_search_results(data)
|
|
255
|
+
|
|
256
|
+
async def forget(self, memory_id: str) -> bool:
|
|
257
|
+
return await self._delete(f"/memories/{memory_id}")
|
|
258
|
+
|
|
259
|
+
async def list_memories(
|
|
260
|
+
self,
|
|
261
|
+
category: Optional[str] = None,
|
|
262
|
+
limit: int = 20,
|
|
263
|
+
scope: str = "private",
|
|
264
|
+
) -> list[Memory]:
|
|
265
|
+
params = {"limit": limit, "scope": scope}
|
|
266
|
+
if category:
|
|
267
|
+
params["category"] = category
|
|
268
|
+
data = await self._get("/memories", params=params)
|
|
269
|
+
return [self._parse_memory(m) for m in data]
|
|
270
|
+
|
|
271
|
+
async def list_categories(self) -> list[Category]:
|
|
272
|
+
data = await self._get("/memories/categories")
|
|
273
|
+
return [Category(name=c["name"], usage_count=c["usage_count"]) for c in data.get("categories", [])]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoraEU — Chiffrement zero-knowledge côté client
|
|
3
|
+
AES-256-GCM + PBKDF2-HMAC-SHA256
|
|
4
|
+
|
|
5
|
+
Le serveur ne voit jamais le texte clair ni la clé.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import base64
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
10
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 210 000 itérations (recommandation OWASP 2024 pour PBKDF2-SHA256)
|
|
15
|
+
PBKDF2_ITERATIONS = 210_000
|
|
16
|
+
|
|
17
|
+
# Préfixe pour détecter les valeurs chiffrées sans ambiguïté
|
|
18
|
+
ENC_PREFIX = "ENCv1:"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def derive_key(password: str, salt: str) -> bytes:
|
|
22
|
+
"""Dérive une clé AES-256 depuis un mot de passe et un salt."""
|
|
23
|
+
salt_bytes = salt.encode() if isinstance(salt, str) else salt
|
|
24
|
+
kdf = PBKDF2HMAC(
|
|
25
|
+
algorithm=hashes.SHA256(),
|
|
26
|
+
length=32,
|
|
27
|
+
salt=salt_bytes,
|
|
28
|
+
iterations=PBKDF2_ITERATIONS,
|
|
29
|
+
)
|
|
30
|
+
return kdf.derive(password.encode())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encrypt(plaintext: str, key: bytes) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Chiffre un texte avec AES-256-GCM.
|
|
36
|
+
Retourne : ENCv1:<base64(nonce[12] + ciphertext + tag[16])>
|
|
37
|
+
"""
|
|
38
|
+
nonce = os.urandom(12)
|
|
39
|
+
ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
40
|
+
return ENC_PREFIX + base64.b64encode(nonce + ciphertext).decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decrypt(ciphertext_b64: str, key: bytes) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Déchiffre un texte chiffré par encrypt().
|
|
46
|
+
Accepte les anciens formats (sans préfixe ENCv1:) pour rétrocompatibilité.
|
|
47
|
+
Lève une exception si la clé est mauvaise ou les données corrompues.
|
|
48
|
+
"""
|
|
49
|
+
raw = ciphertext_b64[len(ENC_PREFIX):] if ciphertext_b64.startswith(ENC_PREFIX) else ciphertext_b64
|
|
50
|
+
data = base64.b64decode(raw)
|
|
51
|
+
nonce = data[:12]
|
|
52
|
+
ciphertext = data[12:]
|
|
53
|
+
plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
|
|
54
|
+
return plaintext.decode("utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_encrypted(value: str) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Détecte si une valeur est chiffrée via le préfixe ENCv1:.
|
|
60
|
+
Rétrocompatible : accepte aussi les anciens blobs base64 bruts (≥ 28 octets).
|
|
61
|
+
"""
|
|
62
|
+
if value.startswith(ENC_PREFIX):
|
|
63
|
+
return True
|
|
64
|
+
try:
|
|
65
|
+
data = base64.b64decode(value)
|
|
66
|
+
return len(data) >= 28 # nonce(12) + tag(16) minimum
|
|
67
|
+
except Exception:
|
|
68
|
+
return False
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoraEU SDK — Exceptions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MemoraEUError(Exception):
|
|
7
|
+
"""Erreur générique MemoraEU."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(MemoraEUError):
|
|
11
|
+
"""Erreur retournée par l'API (HTTP 4xx/5xx)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, message: str):
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
super().__init__(f"[{status_code}] {message}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CryptoError(MemoraEUError):
|
|
19
|
+
"""Erreur de chiffrement/déchiffrement."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConnectionError(MemoraEUError):
|
|
23
|
+
"""Impossible de joindre l'API MemoraEU."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoraEU SDK — Modèles de données
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Memory:
|
|
11
|
+
id: str
|
|
12
|
+
content: str
|
|
13
|
+
category: Optional[str]
|
|
14
|
+
tags: list[str]
|
|
15
|
+
source: str
|
|
16
|
+
scope: str
|
|
17
|
+
org_id: str
|
|
18
|
+
user_id: str
|
|
19
|
+
created_at: datetime
|
|
20
|
+
updated_at: datetime
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, data: dict, decrypt_fn=None) -> "Memory":
|
|
24
|
+
content = data["content"]
|
|
25
|
+
if decrypt_fn:
|
|
26
|
+
content = decrypt_fn(content)
|
|
27
|
+
return cls(
|
|
28
|
+
id=data["id"],
|
|
29
|
+
content=content,
|
|
30
|
+
category=data.get("category"),
|
|
31
|
+
tags=data.get("tags", []),
|
|
32
|
+
source=data.get("source", "sdk"),
|
|
33
|
+
scope=data.get("scope", "private"),
|
|
34
|
+
org_id=data.get("org_id", ""),
|
|
35
|
+
user_id=data.get("user_id", ""),
|
|
36
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
37
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SearchResult:
|
|
43
|
+
memory: Memory
|
|
44
|
+
score: float
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict, decrypt_fn=None) -> "SearchResult":
|
|
48
|
+
return cls(
|
|
49
|
+
memory=Memory.from_dict(data["memory"], decrypt_fn=decrypt_fn),
|
|
50
|
+
score=data["score"],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Category:
|
|
56
|
+
name: str
|
|
57
|
+
usage_count: int
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "memoraeu"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Couche mémoire persistante zero-knowledge pour LLMs — client Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"cryptography>=42.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://memoraeu.com"
|
|
19
|
+
Repository = "https://github.com/pquattro/memoraeu"
|
|
20
|
+
Documentation = "https://docs.memoraeu.com"
|