remora-ai 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.
- remora_ai-0.1.0/.gitignore +29 -0
- remora_ai-0.1.0/PKG-INFO +125 -0
- remora_ai-0.1.0/README.md +110 -0
- remora_ai-0.1.0/pyproject.toml +39 -0
- remora_ai-0.1.0/remora/__init__.py +38 -0
- remora_ai-0.1.0/remora/client.py +214 -0
- remora_ai-0.1.0/remora/exceptions.py +24 -0
- remora_ai-0.1.0/remora/models.py +142 -0
- remora_ai-0.1.0/tests/test_sdk.py +78 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Node dependencies & outputs
|
|
2
|
+
node_modules/
|
|
3
|
+
.npm
|
|
4
|
+
dist/
|
|
5
|
+
|
|
6
|
+
# Python caches & build artifacts
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
build/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
|
|
15
|
+
# Environment files
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
|
|
20
|
+
# OS / IDE
|
|
21
|
+
.DS_Store
|
|
22
|
+
Thumbs.db
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
|
|
28
|
+
# Bun / Test logs
|
|
29
|
+
*.log
|
remora_ai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: remora-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Remora AI Memory Infrastructure
|
|
5
|
+
Author-email: Remora Team <hello@remora.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agents,ai,crewai,embeddings,langchain,llm,mem0,memory,pgvector,vector-search
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Remora Python SDK (`remora-ai`)
|
|
17
|
+
|
|
18
|
+
> **Developer-first AI Memory Infrastructure Client** for persistent, evolving agent memory.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 📦 Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install remora-ai
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
*(Note: The client uses Python standard library `urllib` internally, requiring zero extra dependencies!)*
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 🚀 Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from remora import RemoraClient
|
|
36
|
+
|
|
37
|
+
remora = RemoraClient(
|
|
38
|
+
api_key="rl_live_your_key_here",
|
|
39
|
+
base_url="http://localhost:3000" # Defaults to http://localhost:3000
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 1. Ingest a conversation (async extraction with zero chatbot latency)
|
|
43
|
+
remora.ingest(
|
|
44
|
+
user_id="usr_alice",
|
|
45
|
+
messages=[
|
|
46
|
+
{"role": "user", "content": "I am building a SaaS using Python and PostgreSQL."}
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2. Retrieve compact prompt-ready context before replying
|
|
51
|
+
ctx = remora.context(
|
|
52
|
+
user_id="usr_alice",
|
|
53
|
+
query="What tech stack should I recommend?"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
print(ctx.context_text)
|
|
57
|
+
# Output:
|
|
58
|
+
# Relevant user memories:
|
|
59
|
+
# - User is building a SaaS using Python and PostgreSQL.
|
|
60
|
+
# Use these memories only when relevant.
|
|
61
|
+
|
|
62
|
+
# 3. Direct memory search with hybrid ranking scores
|
|
63
|
+
memories = remora.search(
|
|
64
|
+
user_id="usr_alice",
|
|
65
|
+
query="programming language and database"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for m in memories:
|
|
69
|
+
print(f"- {m.content} (Score: {m.final_score})")
|
|
70
|
+
print(f" Similarity: {m.score_breakdown.semantic_similarity}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 🤖 Integration with OpenAI / LangChain
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from openai import OpenAI
|
|
79
|
+
from remora import RemoraClient
|
|
80
|
+
|
|
81
|
+
remora = RemoraClient(api_key="rl_live_...")
|
|
82
|
+
openai = OpenAI()
|
|
83
|
+
|
|
84
|
+
user_id = "usr_alice"
|
|
85
|
+
user_message = "What database should I choose?"
|
|
86
|
+
|
|
87
|
+
# 1. Fetch relevant persistent memories
|
|
88
|
+
ctx = remora.context(user_id=user_id, query=user_message)
|
|
89
|
+
|
|
90
|
+
# 2. Inject context into system prompt
|
|
91
|
+
system_prompt = f"You are a helpful coding copilot.\n\n{ctx.context_text}"
|
|
92
|
+
|
|
93
|
+
response = openai.chat.completions.create(
|
|
94
|
+
model="gpt-4o",
|
|
95
|
+
messages=[
|
|
96
|
+
{"role": "system", "content": system_prompt},
|
|
97
|
+
{"role": "user", "content": user_message}
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
print(response.choices[0].message.content)
|
|
102
|
+
|
|
103
|
+
# 3. Asynchronously ingest the turn for future memory formation
|
|
104
|
+
remora.ingest(
|
|
105
|
+
user_id=user_id,
|
|
106
|
+
messages=[
|
|
107
|
+
{"role": "user", "content": user_message},
|
|
108
|
+
{"role": "assistant", "content": response.choices[0].message.content}
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 📚 API Reference
|
|
116
|
+
|
|
117
|
+
- `remora.add(user_id, content, namespace="default", type="FACT", importance=0.5)`
|
|
118
|
+
- `remora.search(user_id, query, limit=5)`
|
|
119
|
+
- `remora.context(user_id, query, limit=5)`
|
|
120
|
+
- `remora.ingest(user_id, messages, sync=False)`
|
|
121
|
+
- `remora.get(memory_id)`
|
|
122
|
+
- `remora.update(memory_id, content="...")`
|
|
123
|
+
- `remora.delete(memory_id)`
|
|
124
|
+
- `remora.delete_user_memories(user_id)`: GDPR / Privacy wipe
|
|
125
|
+
- `remora.export_user_memories(user_id)`: Data portability export
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Remora Python SDK (`remora-ai`)
|
|
2
|
+
|
|
3
|
+
> **Developer-first AI Memory Infrastructure Client** for persistent, evolving agent memory.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 📦 Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install remora-ai
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
*(Note: The client uses Python standard library `urllib` internally, requiring zero extra dependencies!)*
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 🚀 Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from remora import RemoraClient
|
|
21
|
+
|
|
22
|
+
remora = RemoraClient(
|
|
23
|
+
api_key="rl_live_your_key_here",
|
|
24
|
+
base_url="http://localhost:3000" # Defaults to http://localhost:3000
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# 1. Ingest a conversation (async extraction with zero chatbot latency)
|
|
28
|
+
remora.ingest(
|
|
29
|
+
user_id="usr_alice",
|
|
30
|
+
messages=[
|
|
31
|
+
{"role": "user", "content": "I am building a SaaS using Python and PostgreSQL."}
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 2. Retrieve compact prompt-ready context before replying
|
|
36
|
+
ctx = remora.context(
|
|
37
|
+
user_id="usr_alice",
|
|
38
|
+
query="What tech stack should I recommend?"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
print(ctx.context_text)
|
|
42
|
+
# Output:
|
|
43
|
+
# Relevant user memories:
|
|
44
|
+
# - User is building a SaaS using Python and PostgreSQL.
|
|
45
|
+
# Use these memories only when relevant.
|
|
46
|
+
|
|
47
|
+
# 3. Direct memory search with hybrid ranking scores
|
|
48
|
+
memories = remora.search(
|
|
49
|
+
user_id="usr_alice",
|
|
50
|
+
query="programming language and database"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for m in memories:
|
|
54
|
+
print(f"- {m.content} (Score: {m.final_score})")
|
|
55
|
+
print(f" Similarity: {m.score_breakdown.semantic_similarity}")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 🤖 Integration with OpenAI / LangChain
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from openai import OpenAI
|
|
64
|
+
from remora import RemoraClient
|
|
65
|
+
|
|
66
|
+
remora = RemoraClient(api_key="rl_live_...")
|
|
67
|
+
openai = OpenAI()
|
|
68
|
+
|
|
69
|
+
user_id = "usr_alice"
|
|
70
|
+
user_message = "What database should I choose?"
|
|
71
|
+
|
|
72
|
+
# 1. Fetch relevant persistent memories
|
|
73
|
+
ctx = remora.context(user_id=user_id, query=user_message)
|
|
74
|
+
|
|
75
|
+
# 2. Inject context into system prompt
|
|
76
|
+
system_prompt = f"You are a helpful coding copilot.\n\n{ctx.context_text}"
|
|
77
|
+
|
|
78
|
+
response = openai.chat.completions.create(
|
|
79
|
+
model="gpt-4o",
|
|
80
|
+
messages=[
|
|
81
|
+
{"role": "system", "content": system_prompt},
|
|
82
|
+
{"role": "user", "content": user_message}
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
print(response.choices[0].message.content)
|
|
87
|
+
|
|
88
|
+
# 3. Asynchronously ingest the turn for future memory formation
|
|
89
|
+
remora.ingest(
|
|
90
|
+
user_id=user_id,
|
|
91
|
+
messages=[
|
|
92
|
+
{"role": "user", "content": user_message},
|
|
93
|
+
{"role": "assistant", "content": response.choices[0].message.content}
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 📚 API Reference
|
|
101
|
+
|
|
102
|
+
- `remora.add(user_id, content, namespace="default", type="FACT", importance=0.5)`
|
|
103
|
+
- `remora.search(user_id, query, limit=5)`
|
|
104
|
+
- `remora.context(user_id, query, limit=5)`
|
|
105
|
+
- `remora.ingest(user_id, messages, sync=False)`
|
|
106
|
+
- `remora.get(memory_id)`
|
|
107
|
+
- `remora.update(memory_id, content="...")`
|
|
108
|
+
- `remora.delete(memory_id)`
|
|
109
|
+
- `remora.delete_user_memories(user_id)`: GDPR / Privacy wipe
|
|
110
|
+
- `remora.export_user_memories(user_id)`: Data portability export
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remora-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for Remora AI Memory Infrastructure"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Remora Team", email = "hello@remora.ai" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ai",
|
|
17
|
+
"memory",
|
|
18
|
+
"agents",
|
|
19
|
+
"llm",
|
|
20
|
+
"mem0",
|
|
21
|
+
"langchain",
|
|
22
|
+
"crewai",
|
|
23
|
+
"embeddings",
|
|
24
|
+
"vector-search",
|
|
25
|
+
"pgvector"
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.25.0",
|
|
29
|
+
"pydantic>=2.0.0"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-asyncio>=0.21.0"
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["remora"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remora Python SDK
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Official Python client library for Remora AI Memory Infrastructure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import RemoraClient
|
|
8
|
+
from .models import (
|
|
9
|
+
Memory,
|
|
10
|
+
RankedMemory,
|
|
11
|
+
ContextResult,
|
|
12
|
+
IngestResult,
|
|
13
|
+
ScoreBreakdown,
|
|
14
|
+
MemoryType,
|
|
15
|
+
MemoryStatus
|
|
16
|
+
)
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
RemoraError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
RateLimitError
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"RemoraClient",
|
|
27
|
+
"Memory",
|
|
28
|
+
"RankedMemory",
|
|
29
|
+
"ContextResult",
|
|
30
|
+
"IngestResult",
|
|
31
|
+
"ScoreBreakdown",
|
|
32
|
+
"MemoryType",
|
|
33
|
+
"MemoryStatus",
|
|
34
|
+
"RemoraError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"NotFoundError",
|
|
37
|
+
"RateLimitError"
|
|
38
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.request
|
|
3
|
+
import urllib.error
|
|
4
|
+
from typing import Optional, List, Dict, Any, Union
|
|
5
|
+
|
|
6
|
+
from .models import Memory, RankedMemory, ContextResult, IngestResult, MemoryType, MemoryStatus
|
|
7
|
+
from .exceptions import RemoraError, AuthenticationError, NotFoundError, RateLimitError
|
|
8
|
+
|
|
9
|
+
class RemoraClient:
|
|
10
|
+
"""
|
|
11
|
+
Synchronous Python Client for Remora AI Memory Infrastructure.
|
|
12
|
+
Zero external dependencies required (uses standard library urllib).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: str, base_url: str = "http://localhost:3000", timeout: float = 30.0):
|
|
16
|
+
if not api_key:
|
|
17
|
+
raise AuthenticationError("RemoraClient: api_key is required.")
|
|
18
|
+
self.api_key = api_key.strip()
|
|
19
|
+
self.base_url = base_url.rstrip("/")
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
|
|
22
|
+
def _request(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Any:
|
|
23
|
+
url = f"{self.base_url}{path}"
|
|
24
|
+
headers = {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
27
|
+
"User-Agent": "remora-python-sdk/0.1.0"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
data_bytes = None
|
|
31
|
+
if payload is not None:
|
|
32
|
+
data_bytes = json.dumps(payload).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
38
|
+
status_code = response.getcode()
|
|
39
|
+
body = response.read().decode("utf-8")
|
|
40
|
+
parsed = json.loads(body) if body else {}
|
|
41
|
+
return parsed.get("data", {})
|
|
42
|
+
except urllib.error.HTTPError as e:
|
|
43
|
+
raw_body = e.read().decode("utf-8") if e.fp else ""
|
|
44
|
+
error_json = {}
|
|
45
|
+
try:
|
|
46
|
+
error_json = json.loads(raw_body)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
message = error_json.get("message", f"HTTP Error {e.code}: {e.reason}")
|
|
51
|
+
|
|
52
|
+
if e.code == 401:
|
|
53
|
+
raise AuthenticationError(message, raw_response=error_json)
|
|
54
|
+
elif e.code == 404:
|
|
55
|
+
raise NotFoundError(message, raw_response=error_json)
|
|
56
|
+
elif e.code == 429:
|
|
57
|
+
retry_after = e.headers.get("Retry-After")
|
|
58
|
+
retry_sec = int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
59
|
+
raise RateLimitError(message, retry_after=retry_sec, raw_response=error_json)
|
|
60
|
+
else:
|
|
61
|
+
raise RemoraError(message, status_code=e.code, raw_response=error_json)
|
|
62
|
+
except urllib.error.URLError as e:
|
|
63
|
+
raise RemoraError(f"Connection error to Remora at {url}: {e.reason}")
|
|
64
|
+
|
|
65
|
+
def add(
|
|
66
|
+
self,
|
|
67
|
+
user_id: str,
|
|
68
|
+
content: str,
|
|
69
|
+
namespace: str = "default",
|
|
70
|
+
type: MemoryType = "FACT",
|
|
71
|
+
importance: float = 0.5,
|
|
72
|
+
confidence: float = 1.0,
|
|
73
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
74
|
+
) -> Memory:
|
|
75
|
+
"""Store a durable memory for an end-user."""
|
|
76
|
+
payload = {
|
|
77
|
+
"user_id": user_id,
|
|
78
|
+
"content": content,
|
|
79
|
+
"namespace": namespace,
|
|
80
|
+
"type": type,
|
|
81
|
+
"importance": importance,
|
|
82
|
+
"confidence": confidence,
|
|
83
|
+
"metadata": metadata or {}
|
|
84
|
+
}
|
|
85
|
+
res = self._request("POST", "/v1/memories", payload)
|
|
86
|
+
return Memory.from_dict(res)
|
|
87
|
+
|
|
88
|
+
def search(
|
|
89
|
+
self,
|
|
90
|
+
user_id: str,
|
|
91
|
+
query: str,
|
|
92
|
+
namespace: Optional[str] = None,
|
|
93
|
+
limit: int = 5
|
|
94
|
+
) -> List[RankedMemory]:
|
|
95
|
+
"""Search memories with semantic vector similarity and hybrid ranking."""
|
|
96
|
+
payload: Dict[str, Any] = {
|
|
97
|
+
"user_id": user_id,
|
|
98
|
+
"query": query,
|
|
99
|
+
"limit": limit
|
|
100
|
+
}
|
|
101
|
+
if namespace:
|
|
102
|
+
payload["namespace"] = namespace
|
|
103
|
+
|
|
104
|
+
res = self._request("POST", "/v1/memories/search", payload)
|
|
105
|
+
if isinstance(res, list):
|
|
106
|
+
return [RankedMemory.from_dict(m) for m in res]
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
def context(
|
|
110
|
+
self,
|
|
111
|
+
user_id: str,
|
|
112
|
+
query: str,
|
|
113
|
+
namespace: Optional[str] = None,
|
|
114
|
+
limit: int = 5
|
|
115
|
+
) -> ContextResult:
|
|
116
|
+
"""Retrieve prompt-ready formatted context text to inject directly into an LLM."""
|
|
117
|
+
payload: Dict[str, Any] = {
|
|
118
|
+
"user_id": user_id,
|
|
119
|
+
"query": query,
|
|
120
|
+
"limit": limit
|
|
121
|
+
}
|
|
122
|
+
if namespace:
|
|
123
|
+
payload["namespace"] = namespace
|
|
124
|
+
|
|
125
|
+
res = self._request("POST", "/v1/memories/context", payload)
|
|
126
|
+
return ContextResult.from_dict(res)
|
|
127
|
+
|
|
128
|
+
def ingest(
|
|
129
|
+
self,
|
|
130
|
+
user_id: str,
|
|
131
|
+
messages: List[Dict[str, str]],
|
|
132
|
+
namespace: str = "default",
|
|
133
|
+
sync: bool = False
|
|
134
|
+
) -> IngestResult:
|
|
135
|
+
"""
|
|
136
|
+
Extract memories automatically from raw conversation turns.
|
|
137
|
+
Defaults to sync=False (returns 202 Accepted + jobId with zero chatbot latency).
|
|
138
|
+
"""
|
|
139
|
+
payload = {
|
|
140
|
+
"user_id": user_id,
|
|
141
|
+
"messages": messages,
|
|
142
|
+
"namespace": namespace,
|
|
143
|
+
"sync": sync
|
|
144
|
+
}
|
|
145
|
+
res = self._request("POST", "/v1/memories/ingest", payload)
|
|
146
|
+
return IngestResult.from_dict(res)
|
|
147
|
+
|
|
148
|
+
def get(self, memory_id: str) -> Memory:
|
|
149
|
+
"""Get single memory by ID along with its evolution versions."""
|
|
150
|
+
res = self._request("GET", f"/v1/memories/{memory_id}")
|
|
151
|
+
return Memory.from_dict(res)
|
|
152
|
+
|
|
153
|
+
def update(
|
|
154
|
+
self,
|
|
155
|
+
memory_id: str,
|
|
156
|
+
content: Optional[str] = None,
|
|
157
|
+
importance: Optional[float] = None,
|
|
158
|
+
confidence: Optional[float] = None,
|
|
159
|
+
type: Optional[MemoryType] = None,
|
|
160
|
+
status: Optional[MemoryStatus] = None
|
|
161
|
+
) -> Memory:
|
|
162
|
+
"""Update a memory (automatically logs a MemoryVersion audit entry)."""
|
|
163
|
+
payload: Dict[str, Any] = {}
|
|
164
|
+
if content is not None:
|
|
165
|
+
payload["content"] = content
|
|
166
|
+
if importance is not None:
|
|
167
|
+
payload["importance"] = importance
|
|
168
|
+
if confidence is not None:
|
|
169
|
+
payload["confidence"] = confidence
|
|
170
|
+
if type is not None:
|
|
171
|
+
payload["type"] = type
|
|
172
|
+
if status is not None:
|
|
173
|
+
payload["status"] = status
|
|
174
|
+
|
|
175
|
+
res = self._request("PATCH", f"/v1/memories/{memory_id}", payload)
|
|
176
|
+
return Memory.from_dict(res)
|
|
177
|
+
|
|
178
|
+
def delete(self, memory_id: str) -> Memory:
|
|
179
|
+
"""Soft-delete a memory."""
|
|
180
|
+
res = self._request("DELETE", f"/v1/memories/{memory_id}")
|
|
181
|
+
return Memory.from_dict(res)
|
|
182
|
+
|
|
183
|
+
def delete_user_memories(self, user_id: str) -> Dict[str, Any]:
|
|
184
|
+
"""GDPR / Privacy: Delete all memories for an external user."""
|
|
185
|
+
return self._request("DELETE", f"/v1/memories/users/{user_id}")
|
|
186
|
+
|
|
187
|
+
def export_user_memories(self, user_id: str) -> Dict[str, Any]:
|
|
188
|
+
"""Data Portability: Export all memories and version history for a user."""
|
|
189
|
+
return self._request("GET", f"/v1/memories/users/{user_id}/export")
|
|
190
|
+
|
|
191
|
+
def list(
|
|
192
|
+
self,
|
|
193
|
+
user_id: Optional[str] = None,
|
|
194
|
+
namespace: Optional[str] = None,
|
|
195
|
+
limit: int = 20,
|
|
196
|
+
offset: int = 0
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
"""List memories with optional pagination and filters."""
|
|
199
|
+
params = []
|
|
200
|
+
if user_id:
|
|
201
|
+
params.append(f"user_id={user_id}")
|
|
202
|
+
if namespace:
|
|
203
|
+
params.append(f"namespace={namespace}")
|
|
204
|
+
params.append(f"limit={limit}")
|
|
205
|
+
params.append(f"offset={offset}")
|
|
206
|
+
|
|
207
|
+
query = f"?{'&'.join(params)}" if params else ""
|
|
208
|
+
res = self._request("GET", f"/v1/memories{query}")
|
|
209
|
+
result = res if isinstance(res, dict) else {}
|
|
210
|
+
memories = [Memory.from_dict(m) for m in result.get("memories", [])]
|
|
211
|
+
return {
|
|
212
|
+
"total": result.get("total", 0),
|
|
213
|
+
"memories": memories
|
|
214
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
class RemoraError(Exception):
|
|
4
|
+
"""Base exception for all Remora errors."""
|
|
5
|
+
def __init__(self, message: str, status_code: int = 500, raw_response: Optional[dict] = None):
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.status_code = status_code
|
|
8
|
+
self.raw_response = raw_response or {}
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(RemoraError):
|
|
11
|
+
"""Raised when the API key is missing, invalid, expired, or revoked."""
|
|
12
|
+
def __init__(self, message: str = "Unauthorized: Invalid or missing API key.", raw_response: Optional[dict] = None):
|
|
13
|
+
super().__init__(message, status_code=401, raw_response=raw_response)
|
|
14
|
+
|
|
15
|
+
class NotFoundError(RemoraError):
|
|
16
|
+
"""Raised when a requested resource (e.g. memory ID) does not exist."""
|
|
17
|
+
def __init__(self, message: str = "Resource not found.", raw_response: Optional[dict] = None):
|
|
18
|
+
super().__init__(message, status_code=404, raw_response=raw_response)
|
|
19
|
+
|
|
20
|
+
class RateLimitError(RemoraError):
|
|
21
|
+
"""Raised when the API key exceeds its sliding window rate limit."""
|
|
22
|
+
def __init__(self, message: str = "Rate limit exceeded.", retry_after: Optional[int] = None, raw_response: Optional[dict] = None):
|
|
23
|
+
super().__init__(message, status_code=429, raw_response=raw_response)
|
|
24
|
+
self.retry_after = retry_after
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, List, Dict, Any, Literal
|
|
3
|
+
|
|
4
|
+
MemoryType = Literal[
|
|
5
|
+
"FACT",
|
|
6
|
+
"PREFERENCE",
|
|
7
|
+
"GOAL",
|
|
8
|
+
"INSTRUCTION",
|
|
9
|
+
"DECISION",
|
|
10
|
+
"PROFILE",
|
|
11
|
+
"RELATIONSHIP",
|
|
12
|
+
"PROJECT_CONTEXT",
|
|
13
|
+
"BEHAVIOR",
|
|
14
|
+
"TEMPORARY",
|
|
15
|
+
"OTHER"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
MemoryStatus = Literal[
|
|
19
|
+
"ACTIVE",
|
|
20
|
+
"SUPERSEDED",
|
|
21
|
+
"ARCHIVED",
|
|
22
|
+
"DELETED",
|
|
23
|
+
"EXPIRED"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ScoreBreakdown:
|
|
28
|
+
semantic_similarity: float
|
|
29
|
+
importance: float
|
|
30
|
+
confidence: float
|
|
31
|
+
recency: float
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ScoreBreakdown":
|
|
35
|
+
return cls(
|
|
36
|
+
semantic_similarity=data.get("semanticSimilarity", 0.0),
|
|
37
|
+
importance=data.get("importance", 0.0),
|
|
38
|
+
confidence=data.get("confidence", 0.0),
|
|
39
|
+
recency=data.get("recency", 0.0),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Memory:
|
|
44
|
+
id: str
|
|
45
|
+
organization_id: str
|
|
46
|
+
project_id: str
|
|
47
|
+
external_user_id: str
|
|
48
|
+
namespace: str
|
|
49
|
+
content: str
|
|
50
|
+
type: str
|
|
51
|
+
status: str
|
|
52
|
+
importance: float
|
|
53
|
+
confidence: float
|
|
54
|
+
created_at: str
|
|
55
|
+
updated_at: str
|
|
56
|
+
last_accessed_at: Optional[str] = None
|
|
57
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
58
|
+
versions: Optional[List[Dict[str, Any]]] = None
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Memory":
|
|
62
|
+
return cls(
|
|
63
|
+
id=data.get("id", ""),
|
|
64
|
+
organization_id=data.get("organizationId", ""),
|
|
65
|
+
project_id=data.get("projectId", ""),
|
|
66
|
+
external_user_id=data.get("externalUserId", ""),
|
|
67
|
+
namespace=data.get("namespace", "default"),
|
|
68
|
+
content=data.get("content", ""),
|
|
69
|
+
type=data.get("type", "FACT"),
|
|
70
|
+
status=data.get("status", "ACTIVE"),
|
|
71
|
+
importance=float(data.get("importance", 0.5)),
|
|
72
|
+
confidence=float(data.get("confidence", 1.0)),
|
|
73
|
+
last_accessed_at=data.get("lastAccessedAt"),
|
|
74
|
+
created_at=data.get("createdAt", ""),
|
|
75
|
+
updated_at=data.get("updatedAt", ""),
|
|
76
|
+
metadata=data.get("metadata"),
|
|
77
|
+
versions=data.get("versions"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RankedMemory(Memory):
|
|
82
|
+
final_score: float = 0.0
|
|
83
|
+
score_breakdown: Optional[ScoreBreakdown] = None
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RankedMemory":
|
|
87
|
+
base = super().from_dict(data)
|
|
88
|
+
breakdown = None
|
|
89
|
+
if "scoreBreakdown" in data and isinstance(data["scoreBreakdown"], dict):
|
|
90
|
+
breakdown = ScoreBreakdown.from_dict(data["scoreBreakdown"])
|
|
91
|
+
|
|
92
|
+
return cls(
|
|
93
|
+
id=base.id,
|
|
94
|
+
organization_id=base.organization_id,
|
|
95
|
+
project_id=base.project_id,
|
|
96
|
+
external_user_id=base.external_user_id,
|
|
97
|
+
namespace=base.namespace,
|
|
98
|
+
content=base.content,
|
|
99
|
+
type=base.type,
|
|
100
|
+
status=base.status,
|
|
101
|
+
importance=base.importance,
|
|
102
|
+
confidence=base.confidence,
|
|
103
|
+
last_accessed_at=base.last_accessed_at,
|
|
104
|
+
created_at=base.created_at,
|
|
105
|
+
updated_at=base.updated_at,
|
|
106
|
+
metadata=base.metadata,
|
|
107
|
+
versions=base.versions,
|
|
108
|
+
final_score=float(data.get("finalScore", 0.0)),
|
|
109
|
+
score_breakdown=breakdown
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ContextResult:
|
|
114
|
+
context_text: str
|
|
115
|
+
memories: List[RankedMemory] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ContextResult":
|
|
119
|
+
memories_raw = data.get("memories", [])
|
|
120
|
+
memories = [RankedMemory.from_dict(m) for m in memories_raw]
|
|
121
|
+
return cls(
|
|
122
|
+
context_text=data.get("contextText", ""),
|
|
123
|
+
memories=memories
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class IngestResult:
|
|
128
|
+
ingested: Optional[int] = None
|
|
129
|
+
job_id: Optional[str] = None
|
|
130
|
+
status: Optional[str] = None
|
|
131
|
+
memories: List[Memory] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: Dict[str, Any]) -> "IngestResult":
|
|
135
|
+
memories_raw = data.get("memories", [])
|
|
136
|
+
memories = [Memory.from_dict(m) for m in memories_raw]
|
|
137
|
+
return cls(
|
|
138
|
+
ingested=data.get("ingested"),
|
|
139
|
+
job_id=data.get("jobId"),
|
|
140
|
+
status=data.get("status"),
|
|
141
|
+
memories=memories
|
|
142
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from remora.models import Memory, RankedMemory, ContextResult, ScoreBreakdown
|
|
3
|
+
from remora.client import RemoraClient
|
|
4
|
+
from remora.exceptions import AuthenticationError
|
|
5
|
+
|
|
6
|
+
class TestRemoraSDK(unittest.TestCase):
|
|
7
|
+
def test_client_init_requires_key(self):
|
|
8
|
+
with self.assertRaises(AuthenticationError):
|
|
9
|
+
RemoraClient(api_key="")
|
|
10
|
+
|
|
11
|
+
def test_memory_model_deserialization(self):
|
|
12
|
+
raw = {
|
|
13
|
+
"id": "mem_123",
|
|
14
|
+
"organizationId": "org_abc",
|
|
15
|
+
"projectId": "proj_xyz",
|
|
16
|
+
"externalUserId": "usr_alice",
|
|
17
|
+
"namespace": "personal",
|
|
18
|
+
"content": "User prefers dark mode.",
|
|
19
|
+
"type": "PREFERENCE",
|
|
20
|
+
"status": "ACTIVE",
|
|
21
|
+
"importance": 0.8,
|
|
22
|
+
"confidence": 0.95,
|
|
23
|
+
"createdAt": "2026-10-20T12:00:00Z",
|
|
24
|
+
"updatedAt": "2026-10-20T12:00:00Z"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
memory = Memory.from_dict(raw)
|
|
28
|
+
self.assertEqual(memory.id, "mem_123")
|
|
29
|
+
self.assertEqual(memory.content, "User prefers dark mode.")
|
|
30
|
+
self.assertEqual(memory.importance, 0.8)
|
|
31
|
+
self.assertEqual(memory.confidence, 0.95)
|
|
32
|
+
|
|
33
|
+
def test_ranked_memory_deserialization(self):
|
|
34
|
+
raw = {
|
|
35
|
+
"id": "mem_123",
|
|
36
|
+
"organizationId": "org_abc",
|
|
37
|
+
"projectId": "proj_xyz",
|
|
38
|
+
"externalUserId": "usr_alice",
|
|
39
|
+
"namespace": "default",
|
|
40
|
+
"content": "User prefers Python.",
|
|
41
|
+
"type": "PREFERENCE",
|
|
42
|
+
"status": "ACTIVE",
|
|
43
|
+
"importance": 0.8,
|
|
44
|
+
"confidence": 0.9,
|
|
45
|
+
"finalScore": 0.895,
|
|
46
|
+
"scoreBreakdown": {
|
|
47
|
+
"semanticSimilarity": 0.92,
|
|
48
|
+
"importance": 0.8,
|
|
49
|
+
"confidence": 0.9,
|
|
50
|
+
"recency": 0.95
|
|
51
|
+
},
|
|
52
|
+
"createdAt": "2026-10-20T12:00:00Z",
|
|
53
|
+
"updatedAt": "2026-10-20T12:00:00Z"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ranked = RankedMemory.from_dict(raw)
|
|
57
|
+
self.assertEqual(ranked.final_score, 0.895)
|
|
58
|
+
self.assertIsNotNone(ranked.score_breakdown)
|
|
59
|
+
self.assertEqual(ranked.score_breakdown.semantic_similarity, 0.92)
|
|
60
|
+
|
|
61
|
+
def test_context_result_deserialization(self):
|
|
62
|
+
raw = {
|
|
63
|
+
"contextText": "Relevant user memories:\n- User likes Python.",
|
|
64
|
+
"memories": [
|
|
65
|
+
{
|
|
66
|
+
"id": "mem_1",
|
|
67
|
+
"content": "User likes Python.",
|
|
68
|
+
"finalScore": 0.9
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ctx = ContextResult.from_dict(raw)
|
|
74
|
+
self.assertIn("Relevant user memories:", ctx.context_text)
|
|
75
|
+
self.assertEqual(len(ctx.memories), 1)
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|