memorylayer-py 0.1.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.
- memorylayer_py-0.1.0.dist-info/METADATA +241 -0
- memorylayer_py-0.1.0.dist-info/RECORD +10 -0
- memorylayer_py-0.1.0.dist-info/WHEEL +5 -0
- memorylayer_py-0.1.0.dist-info/top_level.txt +1 -0
- rec0/__init__.py +30 -0
- rec0/async_client.py +201 -0
- rec0/client.py +232 -0
- rec0/exceptions.py +31 -0
- rec0/models.py +61 -0
- rec0/version.py +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memorylayer-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privacy-first memory API for LLMs
|
|
5
|
+
Author-email: "rec0.ai" <hello@rec0.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://rec0.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.rec0.ai
|
|
9
|
+
Project-URL: Repository, https://github.com/rec0ai/rec0-python
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/rec0ai/rec0-python/issues
|
|
11
|
+
Keywords: llm,memory,ai,privacy,rag
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: requests>=2.28.0
|
|
24
|
+
Requires-Dist: httpx>=0.24.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# rec0 — memory for any LLM
|
|
31
|
+
|
|
32
|
+
> Give your AI a permanent memory in 3 lines of code.
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/rec0/)
|
|
35
|
+
[](https://python.org)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install rec0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from rec0 import Memory
|
|
48
|
+
|
|
49
|
+
mem = Memory(api_key="r0_xxx", user_id="user_123")
|
|
50
|
+
mem.store("User prefers Python and dark mode")
|
|
51
|
+
context = mem.context("user preferences")
|
|
52
|
+
# inject context into your LLM prompt — done
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That's it. `context` returns a bullet-list string ready to prepend to any system prompt.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Why rec0
|
|
60
|
+
|
|
61
|
+
| | rec0 | Mem0 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| **Privacy** | Data never leaves your servers | Processed externally |
|
|
64
|
+
| **Cost** | $0.002 / 1K ops | ~$0.10 / 1K ops |
|
|
65
|
+
| **Setup** | 3 lines | OAuth + config |
|
|
66
|
+
| **LLM support** | Any model | OpenAI-first |
|
|
67
|
+
| **GDPR** | 1 API call | Manual |
|
|
68
|
+
|
|
69
|
+
- **Privacy-first:** embeddings and summaries run on YOUR infrastructure — no user data touches third-party APIs
|
|
70
|
+
- **LLM-agnostic:** works with OpenAI, Anthropic, Gemini, Llama, Mistral — anything that takes a string
|
|
71
|
+
- **Memory lifecycle:** automatic importance scoring, recall-count boosting, and time-based decay
|
|
72
|
+
- **GDPR compliant:** right-to-erasure in one call (`mem.delete_user()`)
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Full API reference
|
|
77
|
+
|
|
78
|
+
### `Memory(user_id, api_key, app_id, base_url)`
|
|
79
|
+
|
|
80
|
+
| Parameter | Type | Default | Description |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `user_id` | `str` | required | Your end-user identifier |
|
|
83
|
+
| `api_key` | `str` | `$REC0_API_KEY` | Your rec0 API key |
|
|
84
|
+
| `app_id` | `str` | `"default"` | Namespace for multi-app isolation |
|
|
85
|
+
| `base_url` | `str` | prod URL | Override for self-hosting |
|
|
86
|
+
|
|
87
|
+
### Methods
|
|
88
|
+
|
|
89
|
+
#### `mem.store(content)` → `MemoryObject`
|
|
90
|
+
Store a new memory. Auto-generates embedding and summary server-side.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
m = mem.store("User is building a SaaS product in Python")
|
|
94
|
+
print(m.id) # UUID
|
|
95
|
+
print(m.importance) # starts at 1.0, increases with each recall
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### `mem.context(query, limit=5)` → `str`
|
|
99
|
+
**The most-used method.** Returns a bullet-list string to inject into your LLM prompt.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
context = mem.context("what does the user like", limit=5)
|
|
103
|
+
# "- User prefers Python and dark mode\n- User is building a SaaS product"
|
|
104
|
+
|
|
105
|
+
# Typical usage with OpenAI:
|
|
106
|
+
messages = [
|
|
107
|
+
{"role": "system", "content": f"User context:\n{context}"},
|
|
108
|
+
{"role": "user", "content": user_message},
|
|
109
|
+
]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### `mem.recall(query, limit=5)` → `List[MemoryObject]`
|
|
113
|
+
Returns memories ranked by semantic similarity. Use when you need scores or metadata.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
memories = mem.recall("programming preferences", limit=3)
|
|
117
|
+
for m in memories:
|
|
118
|
+
print(f"{m.content} (score: {m.relevance_score})")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### `mem.list()` → `List[MemoryObject]`
|
|
122
|
+
All active memories for this user, ordered by creation time.
|
|
123
|
+
|
|
124
|
+
#### `mem.delete(memory_id)` → `None`
|
|
125
|
+
Soft-delete a specific memory (retained for audit trail).
|
|
126
|
+
|
|
127
|
+
#### `mem.delete_user()` → `dict`
|
|
128
|
+
GDPR right-to-erasure. Removes all memories for this user.
|
|
129
|
+
|
|
130
|
+
#### `mem.export()` → `dict`
|
|
131
|
+
GDPR data export. Returns all memory data as a dictionary.
|
|
132
|
+
|
|
133
|
+
#### `mem.ping()` → `bool`
|
|
134
|
+
Connectivity check. Returns `True` if the API is reachable.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
if not mem.ping():
|
|
138
|
+
print("rec0 API unreachable — check your key")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Error handling
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from rec0 import Memory, Rec0Error, AuthError, RateLimitError, NotFoundError
|
|
147
|
+
|
|
148
|
+
mem = Memory(api_key="r0_xxx", user_id="user_123")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
mem.store("User loves rec0")
|
|
152
|
+
except AuthError:
|
|
153
|
+
print("Invalid API key — check REC0_API_KEY")
|
|
154
|
+
except RateLimitError as e:
|
|
155
|
+
print(f"Rate limited — retry in {e.retry_after}s")
|
|
156
|
+
except NotFoundError:
|
|
157
|
+
print("Memory not found")
|
|
158
|
+
except Rec0Error as e:
|
|
159
|
+
print(f"Unexpected error: {e}")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rate limits are handled automatically: rec0 will wait `retry_after` seconds and retry once before raising.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Async usage
|
|
167
|
+
|
|
168
|
+
Every method has an async equivalent via `AsyncMemory`:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import asyncio
|
|
172
|
+
from rec0 import AsyncMemory
|
|
173
|
+
|
|
174
|
+
async def main():
|
|
175
|
+
mem = AsyncMemory(api_key="r0_xxx", user_id="user_123")
|
|
176
|
+
await mem.store("User is a night-owl developer")
|
|
177
|
+
context = await mem.context("when does the user work")
|
|
178
|
+
print(context)
|
|
179
|
+
|
|
180
|
+
asyncio.run(main())
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`AsyncMemory` uses `httpx` under the hood and is safe to use in FastAPI, Django async views, and any `asyncio` application.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Environment variables
|
|
188
|
+
|
|
189
|
+
| Variable | Description |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `REC0_API_KEY` | Your rec0 API key (used automatically if `api_key=` not passed) |
|
|
192
|
+
| `REC0_BASE_URL` | Override the API base URL (optional, for self-hosting) |
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
export REC0_API_KEY=r0_your_key_here
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
# api_key is now auto-loaded — no need to hardcode it
|
|
200
|
+
mem = Memory(user_id="user_123")
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## MemoryObject fields
|
|
206
|
+
|
|
207
|
+
| Field | Type | Description |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| `id` | `str` | UUID |
|
|
210
|
+
| `content` | `str` | The original memory text |
|
|
211
|
+
| `summary` | `str \| None` | Auto-generated summary |
|
|
212
|
+
| `importance` | `float` | 1.0–10.0; increases with recall |
|
|
213
|
+
| `recall_count` | `int` | Times this memory was recalled |
|
|
214
|
+
| `relevance_score` | `float \| None` | Similarity score (recall only) |
|
|
215
|
+
| `created_at` | `datetime` | When stored |
|
|
216
|
+
| `is_active` | `bool` | False if deleted |
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Self-hosting
|
|
221
|
+
|
|
222
|
+
rec0 is open-source. Deploy your own instance on Railway, Fly, or any server:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
git clone https://github.com/patelyash2511/memorylayer
|
|
226
|
+
# See README for Railway deployment instructions
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Then point the SDK at your instance:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
mem = Memory(
|
|
233
|
+
api_key="your_key",
|
|
234
|
+
user_id="user_123",
|
|
235
|
+
base_url="https://your-instance.up.railway.app",
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
[rec0.ai](https://rec0.ai) · [docs](https://docs.rec0.ai) · [discord](https://discord.gg/rec0) · [twitter](https://twitter.com/rec0ai)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
rec0/__init__.py,sha256=NReh7VjGkvbR76XKFFvymatPGHFWThhlLxGAJT3lCgA,742
|
|
2
|
+
rec0/async_client.py,sha256=B-GpMT0BpxscOT_kXwxf1LiGktQXyHBPJFL5ykuTHk4,7445
|
|
3
|
+
rec0/client.py,sha256=C_fGgd503bJTDD6Gs1qDJrGLoc0BZ6ztKk2mCyHxjoY,7970
|
|
4
|
+
rec0/exceptions.py,sha256=u7b3DgO0A7Ecs2pOOWTX1i-ihoKpQqBLw8LQvwgoqBw,740
|
|
5
|
+
rec0/models.py,sha256=nh4jDfOeenZMdu2daDbvVd9XDd3sOmr6F15K0JXJWvs,1826
|
|
6
|
+
rec0/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
7
|
+
memorylayer_py-0.1.0.dist-info/METADATA,sha256=PdtGY0LaEXCkI5IvPKPrQrt2qLMDiBwtv3E7fmP2A44,7038
|
|
8
|
+
memorylayer_py-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
memorylayer_py-0.1.0.dist-info/top_level.txt,sha256=8N2dtJTBzcUZEXxkphDTo3LB6vKzMcbiag7DWe4iQQU,5
|
|
10
|
+
memorylayer_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rec0
|
rec0/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""rec0 — privacy-first memory API for LLMs.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from rec0 import Memory
|
|
6
|
+
|
|
7
|
+
mem = Memory(api_key="r0_xxx", user_id="user_123")
|
|
8
|
+
mem.store("User prefers dark mode and uses VSCode")
|
|
9
|
+
context = mem.context("user preferences")
|
|
10
|
+
# inject context into your LLM prompt — done
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .async_client import AsyncMemory
|
|
14
|
+
from .client import Memory
|
|
15
|
+
from .exceptions import AuthError, NotFoundError, RateLimitError, Rec0Error, ServerError
|
|
16
|
+
from .models import MemoryObject, RecallResult
|
|
17
|
+
from .version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Memory",
|
|
21
|
+
"AsyncMemory",
|
|
22
|
+
"MemoryObject",
|
|
23
|
+
"RecallResult",
|
|
24
|
+
"Rec0Error",
|
|
25
|
+
"AuthError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"ServerError",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
rec0/async_client.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""rec0 async client — AsyncMemory class backed by httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .exceptions import AuthError, NotFoundError, RateLimitError, Rec0Error, ServerError
|
|
13
|
+
from .models import MemoryObject, RecallResult
|
|
14
|
+
from .version import __version__
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BASE_URL = "https://memorylayer-production.up.railway.app"
|
|
19
|
+
_DEFAULT_TIMEOUT = 10 # seconds
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raise_for_response(resp: httpx.Response) -> None:
|
|
23
|
+
"""Map HTTP error codes to typed exceptions."""
|
|
24
|
+
if resp.status_code < 400:
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
body = resp.json()
|
|
28
|
+
detail = body.get("detail", body)
|
|
29
|
+
if isinstance(detail, dict):
|
|
30
|
+
message = detail.get("message", str(detail))
|
|
31
|
+
else:
|
|
32
|
+
message = str(detail)
|
|
33
|
+
except Exception:
|
|
34
|
+
message = resp.text or f"HTTP {resp.status_code}"
|
|
35
|
+
|
|
36
|
+
if resp.status_code == 401:
|
|
37
|
+
raise AuthError(message)
|
|
38
|
+
if resp.status_code == 404:
|
|
39
|
+
raise NotFoundError(message)
|
|
40
|
+
if resp.status_code == 429:
|
|
41
|
+
retry_after = 60
|
|
42
|
+
try:
|
|
43
|
+
detail = resp.json().get("detail", {})
|
|
44
|
+
if isinstance(detail, dict):
|
|
45
|
+
retry_after = int(detail.get("retry_after_seconds", 60))
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
raise RateLimitError(message, retry_after=retry_after)
|
|
49
|
+
if resp.status_code >= 500:
|
|
50
|
+
raise ServerError(message)
|
|
51
|
+
raise Rec0Error(f"HTTP {resp.status_code}: {message}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AsyncMemory:
|
|
55
|
+
"""Asynchronous rec0 client.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
from rec0 import AsyncMemory
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
mem = AsyncMemory(api_key="r0_xxx", user_id="user_123")
|
|
63
|
+
await mem.store("User is a Python developer")
|
|
64
|
+
context = await mem.context("user preferences")
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
user_id: str,
|
|
70
|
+
api_key: Optional[str] = None,
|
|
71
|
+
app_id: str = "default",
|
|
72
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
73
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
74
|
+
) -> None:
|
|
75
|
+
resolved_key = api_key or os.environ.get("REC0_API_KEY", "")
|
|
76
|
+
if not resolved_key:
|
|
77
|
+
raise AuthError(
|
|
78
|
+
"No API key provided. Pass api_key= or set REC0_API_KEY env var."
|
|
79
|
+
)
|
|
80
|
+
self._api_key = resolved_key
|
|
81
|
+
self.user_id = user_id
|
|
82
|
+
self.app_id = app_id
|
|
83
|
+
self._base_url = base_url.rstrip("/")
|
|
84
|
+
self._timeout = timeout
|
|
85
|
+
self._headers = {
|
|
86
|
+
"X-API-Key": self._api_key,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"User-Agent": f"rec0-python/{__version__}",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async def _post(self, path: str, json: dict) -> httpx.Response:
|
|
94
|
+
try:
|
|
95
|
+
async with httpx.AsyncClient(
|
|
96
|
+
headers=self._headers, timeout=self._timeout
|
|
97
|
+
) as client:
|
|
98
|
+
resp = await client.post(f"{self._base_url}{path}", json=json)
|
|
99
|
+
except httpx.TimeoutException:
|
|
100
|
+
raise Rec0Error("Request timed out")
|
|
101
|
+
except httpx.ConnectError as exc:
|
|
102
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
103
|
+
_raise_for_response(resp)
|
|
104
|
+
return resp
|
|
105
|
+
|
|
106
|
+
async def _get(self, path: str, params: Optional[dict] = None) -> httpx.Response:
|
|
107
|
+
try:
|
|
108
|
+
async with httpx.AsyncClient(
|
|
109
|
+
headers=self._headers, timeout=self._timeout
|
|
110
|
+
) as client:
|
|
111
|
+
resp = await client.get(f"{self._base_url}{path}", params=params)
|
|
112
|
+
except httpx.TimeoutException:
|
|
113
|
+
raise Rec0Error("Request timed out")
|
|
114
|
+
except httpx.ConnectError as exc:
|
|
115
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
116
|
+
_raise_for_response(resp)
|
|
117
|
+
return resp
|
|
118
|
+
|
|
119
|
+
async def _delete(self, path: str) -> httpx.Response:
|
|
120
|
+
try:
|
|
121
|
+
async with httpx.AsyncClient(
|
|
122
|
+
headers=self._headers, timeout=self._timeout
|
|
123
|
+
) as client:
|
|
124
|
+
resp = await client.delete(f"{self._base_url}{path}")
|
|
125
|
+
except httpx.TimeoutException:
|
|
126
|
+
raise Rec0Error("Request timed out")
|
|
127
|
+
except httpx.ConnectError as exc:
|
|
128
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
129
|
+
_raise_for_response(resp)
|
|
130
|
+
return resp
|
|
131
|
+
|
|
132
|
+
async def _post_with_retry(self, path: str, json: dict) -> httpx.Response:
|
|
133
|
+
"""POST with one automatic retry on RateLimitError."""
|
|
134
|
+
try:
|
|
135
|
+
return await self._post(path, json)
|
|
136
|
+
except RateLimitError as exc:
|
|
137
|
+
logger.warning("rec0: rate limited, retrying in %ds...", exc.retry_after)
|
|
138
|
+
await asyncio.sleep(exc.retry_after)
|
|
139
|
+
return await self._post(path, json)
|
|
140
|
+
|
|
141
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async def store(self, content: str) -> MemoryObject:
|
|
144
|
+
"""Store a new memory."""
|
|
145
|
+
resp = await self._post_with_retry(
|
|
146
|
+
"/v1/memory/store",
|
|
147
|
+
{"user_id": self.user_id, "app_id": self.app_id, "content": content},
|
|
148
|
+
)
|
|
149
|
+
return MemoryObject._from_dict(resp.json())
|
|
150
|
+
|
|
151
|
+
async def recall(self, query: str, limit: int = 5) -> List[MemoryObject]:
|
|
152
|
+
"""Recall memories most relevant to *query*."""
|
|
153
|
+
resp = await self._post_with_retry(
|
|
154
|
+
"/v1/memory/recall",
|
|
155
|
+
{
|
|
156
|
+
"user_id": self.user_id,
|
|
157
|
+
"app_id": self.app_id,
|
|
158
|
+
"query": query,
|
|
159
|
+
"limit": limit,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
data = resp.json()
|
|
163
|
+
return [MemoryObject._from_dict(m) for m in data.get("memories", [])]
|
|
164
|
+
|
|
165
|
+
async def context(self, query: str, limit: int = 5) -> str:
|
|
166
|
+
"""Build a context string ready to inject into any LLM prompt."""
|
|
167
|
+
memories = await self.recall(query, limit=limit)
|
|
168
|
+
return "\n".join(f"- {m.content}" for m in memories)
|
|
169
|
+
|
|
170
|
+
async def list(self) -> List[MemoryObject]:
|
|
171
|
+
"""List all active memories for this user."""
|
|
172
|
+
resp = await self._get(
|
|
173
|
+
"/v1/memory/list",
|
|
174
|
+
params={"user_id": self.user_id, "app_id": self.app_id},
|
|
175
|
+
)
|
|
176
|
+
data = resp.json()
|
|
177
|
+
return [MemoryObject._from_dict(m) for m in data.get("memories", [])]
|
|
178
|
+
|
|
179
|
+
async def delete(self, memory_id: str) -> None:
|
|
180
|
+
"""Soft-delete a specific memory by ID."""
|
|
181
|
+
await self._delete(f"/v1/memory/{memory_id}")
|
|
182
|
+
|
|
183
|
+
async def delete_user(self) -> dict:
|
|
184
|
+
"""GDPR right-to-erasure: delete all memories for this user."""
|
|
185
|
+
resp = await self._delete(f"/v1/users/{self.user_id}")
|
|
186
|
+
if resp.status_code == 204 or not resp.content:
|
|
187
|
+
return {"deleted": True, "memories_removed": 0}
|
|
188
|
+
return resp.json()
|
|
189
|
+
|
|
190
|
+
async def export(self) -> dict:
|
|
191
|
+
"""GDPR data export: return all memories for this user as a dict."""
|
|
192
|
+
resp = await self._get(f"/v1/users/{self.user_id}/export")
|
|
193
|
+
return resp.json()
|
|
194
|
+
|
|
195
|
+
async def ping(self) -> bool:
|
|
196
|
+
"""Check connectivity. Returns True if server is reachable."""
|
|
197
|
+
try:
|
|
198
|
+
await self._get("/health")
|
|
199
|
+
return True
|
|
200
|
+
except Exception:
|
|
201
|
+
return False
|
rec0/client.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""rec0 sync client — Memory class backed by requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .exceptions import AuthError, NotFoundError, RateLimitError, Rec0Error, ServerError
|
|
13
|
+
from .models import MemoryObject, RecallResult
|
|
14
|
+
from .version import __version__
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BASE_URL = "https://memorylayer-production.up.railway.app"
|
|
19
|
+
_DEFAULT_TIMEOUT = 10 # seconds
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raise_for_response(resp: requests.Response) -> None:
|
|
23
|
+
"""Map HTTP error codes to typed exceptions."""
|
|
24
|
+
if resp.status_code < 400:
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
body = resp.json()
|
|
28
|
+
detail = body.get("detail", body)
|
|
29
|
+
if isinstance(detail, dict):
|
|
30
|
+
message = detail.get("message", str(detail))
|
|
31
|
+
else:
|
|
32
|
+
message = str(detail)
|
|
33
|
+
except Exception:
|
|
34
|
+
message = resp.text or f"HTTP {resp.status_code}"
|
|
35
|
+
|
|
36
|
+
if resp.status_code == 401:
|
|
37
|
+
raise AuthError(message)
|
|
38
|
+
if resp.status_code == 404:
|
|
39
|
+
raise NotFoundError(message)
|
|
40
|
+
if resp.status_code == 429:
|
|
41
|
+
retry_after = 60
|
|
42
|
+
try:
|
|
43
|
+
detail = resp.json().get("detail", {})
|
|
44
|
+
if isinstance(detail, dict):
|
|
45
|
+
retry_after = int(detail.get("retry_after_seconds", 60))
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
raise RateLimitError(message, retry_after=retry_after)
|
|
49
|
+
if resp.status_code >= 500:
|
|
50
|
+
raise ServerError(message)
|
|
51
|
+
raise Rec0Error(f"HTTP {resp.status_code}: {message}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Memory:
|
|
55
|
+
"""Synchronous rec0 client.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
from rec0 import Memory
|
|
60
|
+
|
|
61
|
+
mem = Memory(api_key="r0_xxx", user_id="user_123")
|
|
62
|
+
mem.store("User prefers dark mode")
|
|
63
|
+
context = mem.context("user preferences")
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
user_id: str,
|
|
69
|
+
api_key: Optional[str] = None,
|
|
70
|
+
app_id: str = "default",
|
|
71
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
72
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
73
|
+
) -> None:
|
|
74
|
+
resolved_key = api_key or os.environ.get("REC0_API_KEY", "")
|
|
75
|
+
if not resolved_key:
|
|
76
|
+
raise AuthError(
|
|
77
|
+
"No API key provided. Pass api_key= or set REC0_API_KEY env var."
|
|
78
|
+
)
|
|
79
|
+
self._api_key = resolved_key
|
|
80
|
+
self.user_id = user_id
|
|
81
|
+
self.app_id = app_id
|
|
82
|
+
self._base_url = base_url.rstrip("/")
|
|
83
|
+
self._timeout = timeout
|
|
84
|
+
self._session = requests.Session()
|
|
85
|
+
self._session.headers.update(
|
|
86
|
+
{
|
|
87
|
+
"X-API-Key": self._api_key,
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": f"rec0-python/{__version__}",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _post(self, path: str, json: dict) -> requests.Response:
|
|
96
|
+
try:
|
|
97
|
+
resp = self._session.post(
|
|
98
|
+
f"{self._base_url}{path}", json=json, timeout=self._timeout
|
|
99
|
+
)
|
|
100
|
+
except requests.Timeout:
|
|
101
|
+
raise Rec0Error("Request timed out")
|
|
102
|
+
except requests.ConnectionError as exc:
|
|
103
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
104
|
+
_raise_for_response(resp)
|
|
105
|
+
return resp
|
|
106
|
+
|
|
107
|
+
def _get(self, path: str, params: Optional[dict] = None) -> requests.Response:
|
|
108
|
+
try:
|
|
109
|
+
resp = self._session.get(
|
|
110
|
+
f"{self._base_url}{path}", params=params, timeout=self._timeout
|
|
111
|
+
)
|
|
112
|
+
except requests.Timeout:
|
|
113
|
+
raise Rec0Error("Request timed out")
|
|
114
|
+
except requests.ConnectionError as exc:
|
|
115
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
116
|
+
_raise_for_response(resp)
|
|
117
|
+
return resp
|
|
118
|
+
|
|
119
|
+
def _delete(self, path: str) -> requests.Response:
|
|
120
|
+
try:
|
|
121
|
+
resp = self._session.delete(
|
|
122
|
+
f"{self._base_url}{path}", timeout=self._timeout
|
|
123
|
+
)
|
|
124
|
+
except requests.Timeout:
|
|
125
|
+
raise Rec0Error("Request timed out")
|
|
126
|
+
except requests.ConnectionError as exc:
|
|
127
|
+
raise Rec0Error(f"Connection error: {exc}")
|
|
128
|
+
_raise_for_response(resp)
|
|
129
|
+
return resp
|
|
130
|
+
|
|
131
|
+
def _post_with_retry(self, path: str, json: dict) -> requests.Response:
|
|
132
|
+
"""POST with one automatic retry on RateLimitError."""
|
|
133
|
+
try:
|
|
134
|
+
return self._post(path, json)
|
|
135
|
+
except RateLimitError as exc:
|
|
136
|
+
logger.warning("rec0: rate limited, retrying in %ds...", exc.retry_after)
|
|
137
|
+
time.sleep(exc.retry_after)
|
|
138
|
+
return self._post(path, json)
|
|
139
|
+
|
|
140
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def store(self, content: str) -> MemoryObject:
|
|
143
|
+
"""Store a new memory.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
content: The text to remember.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The stored :class:`MemoryObject`.
|
|
150
|
+
"""
|
|
151
|
+
resp = self._post_with_retry(
|
|
152
|
+
"/v1/memory/store",
|
|
153
|
+
{"user_id": self.user_id, "app_id": self.app_id, "content": content},
|
|
154
|
+
)
|
|
155
|
+
return MemoryObject._from_dict(resp.json())
|
|
156
|
+
|
|
157
|
+
def recall(self, query: str, limit: int = 5) -> List[MemoryObject]:
|
|
158
|
+
"""Recall memories most relevant to *query*.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
query: Natural language query.
|
|
162
|
+
limit: Maximum number of results to return (default 5).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of :class:`MemoryObject` sorted by relevance (highest first).
|
|
166
|
+
"""
|
|
167
|
+
resp = self._post_with_retry(
|
|
168
|
+
"/v1/memory/recall",
|
|
169
|
+
{
|
|
170
|
+
"user_id": self.user_id,
|
|
171
|
+
"app_id": self.app_id,
|
|
172
|
+
"query": query,
|
|
173
|
+
"limit": limit,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
data = resp.json()
|
|
177
|
+
return [MemoryObject._from_dict(m) for m in data.get("memories", [])]
|
|
178
|
+
|
|
179
|
+
def context(self, query: str, limit: int = 5) -> str:
|
|
180
|
+
"""Build a context string ready to inject into any LLM prompt.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
query: What you want to remember about the user.
|
|
184
|
+
limit: Maximum memories to include (default 5).
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Newline-separated bullet list: ``"- memory1\\n- memory2\\n..."``
|
|
188
|
+
Returns an empty string when no memories exist.
|
|
189
|
+
"""
|
|
190
|
+
memories = self.recall(query, limit=limit)
|
|
191
|
+
return "\n".join(f"- {m.content}" for m in memories)
|
|
192
|
+
|
|
193
|
+
def list(self) -> List[MemoryObject]:
|
|
194
|
+
"""List all active memories for this user, ordered by creation time."""
|
|
195
|
+
resp = self._get(
|
|
196
|
+
"/v1/memory/list",
|
|
197
|
+
params={"user_id": self.user_id, "app_id": self.app_id},
|
|
198
|
+
)
|
|
199
|
+
data = resp.json()
|
|
200
|
+
return [MemoryObject._from_dict(m) for m in data.get("memories", [])]
|
|
201
|
+
|
|
202
|
+
def delete(self, memory_id: str) -> None:
|
|
203
|
+
"""Soft-delete a specific memory by ID."""
|
|
204
|
+
self._delete(f"/v1/memory/{memory_id}")
|
|
205
|
+
|
|
206
|
+
def delete_user(self) -> dict:
|
|
207
|
+
"""GDPR right-to-erasure: delete all memories for this user.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
``{"deleted": True, "memories_removed": N}``
|
|
211
|
+
"""
|
|
212
|
+
resp = self._delete(f"/v1/users/{self.user_id}")
|
|
213
|
+
if resp.status_code == 204 or not resp.content:
|
|
214
|
+
return {"deleted": True, "memories_removed": 0}
|
|
215
|
+
return resp.json()
|
|
216
|
+
|
|
217
|
+
def export(self) -> dict:
|
|
218
|
+
"""GDPR data export: return all memories for this user as a dict."""
|
|
219
|
+
resp = self._get(f"/v1/users/{self.user_id}/export")
|
|
220
|
+
return resp.json()
|
|
221
|
+
|
|
222
|
+
def ping(self) -> bool:
|
|
223
|
+
"""Check connectivity and confirm the API key works.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
``True`` if the server is reachable, ``False`` otherwise.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
self._get("/health")
|
|
230
|
+
return True
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
rec0/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""rec0 exceptions — every error maps to a meaningful Python type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Rec0Error(Exception):
|
|
7
|
+
"""Base exception for all rec0 errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(Rec0Error):
|
|
11
|
+
"""Raised on 401 — invalid or missing API key."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitError(Rec0Error):
|
|
15
|
+
"""Raised on 429 — rate limit exceeded.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
retry_after: seconds to wait before retrying.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, retry_after: int = 60) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(Rec0Error):
|
|
27
|
+
"""Raised on 404 — resource not found."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ServerError(Rec0Error):
|
|
31
|
+
"""Raised on 5xx — unexpected server error."""
|
rec0/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""rec0 data models — clean dataclasses for API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MemoryObject:
|
|
12
|
+
id: str
|
|
13
|
+
user_id: str
|
|
14
|
+
app_id: str
|
|
15
|
+
content: str
|
|
16
|
+
summary: Optional[str]
|
|
17
|
+
importance: float
|
|
18
|
+
recall_count: int
|
|
19
|
+
created_at: datetime
|
|
20
|
+
updated_at: datetime
|
|
21
|
+
is_active: bool
|
|
22
|
+
rec0_version: str
|
|
23
|
+
relevance_score: Optional[float] = None # only present on recall results
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _from_dict(cls, data: dict) -> "MemoryObject":
|
|
27
|
+
return cls(
|
|
28
|
+
id=data["id"],
|
|
29
|
+
user_id=data["user_id"],
|
|
30
|
+
app_id=data["app_id"],
|
|
31
|
+
content=data["content"],
|
|
32
|
+
summary=data.get("summary"),
|
|
33
|
+
importance=float(data.get("importance", 1.0)),
|
|
34
|
+
recall_count=int(data.get("recall_count", 0)),
|
|
35
|
+
created_at=_parse_dt(data["created_at"]),
|
|
36
|
+
updated_at=_parse_dt(data["updated_at"]),
|
|
37
|
+
is_active=bool(data.get("is_active", True)),
|
|
38
|
+
rec0_version=data.get("rec0_version", "1.0.0"),
|
|
39
|
+
relevance_score=float(data["relevance_score"]) if "relevance_score" in data else None,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RecallResult:
|
|
45
|
+
memories: List[MemoryObject]
|
|
46
|
+
total_memories: int
|
|
47
|
+
recall_time_ms: int
|
|
48
|
+
rec0_version: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_dt(value: str | datetime) -> datetime:
|
|
52
|
+
if isinstance(value, datetime):
|
|
53
|
+
return value
|
|
54
|
+
# Handle ISO strings with or without timezone suffix
|
|
55
|
+
value = value.rstrip("Z")
|
|
56
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
57
|
+
try:
|
|
58
|
+
return datetime.strptime(value, fmt)
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
raise ValueError(f"Cannot parse datetime: {value!r}")
|
rec0/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|