mrmemory 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.
@@ -0,0 +1,34 @@
1
+ # Environment
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Rust
7
+ target/
8
+ **/*.rs.bk
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.pyc
13
+ *.egg-info/
14
+ dist/
15
+ .venv/
16
+ venv/
17
+
18
+ # Node/TypeScript
19
+ node_modules/
20
+ dist/
21
+ *.tsbuildinfo
22
+
23
+ # IDE
24
+ .vscode/
25
+ .idea/
26
+ *.swp
27
+ *.swo
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Docker
34
+ docker-compose.override.yml
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: mrmemory
3
+ Version: 0.1.0
4
+ Summary: Agent Memory Relay — persistent long-term memory for AI agents
5
+ Project-URL: Homepage, https://mrmemory.dev
6
+ Project-URL: Documentation, https://docs.amr.dev
7
+ Project-URL: Repository, https://github.com/amr-dev/amr-python
8
+ Author-email: AMR <sdk@amr.dev>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,llm,memory,semantic-search
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.13; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: respx>=0.22; extra == 'dev'
28
+ Requires-Dist: ruff>=0.8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # amr — Agent Memory Relay
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/amr)](https://pypi.org/project/amr/)
34
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
35
+ [![Docs](https://img.shields.io/badge/docs-mrmemory.dev-8b7aff)](https://mrmemory.dev/docs.html)
36
+
37
+ Persistent long-term memory for AI agents. One line to install, three lines to integrate.
38
+
39
+ **[Docs](https://mrmemory.dev/docs.html)** · **[API Reference](https://mrmemory.dev/docs.html#endpoints)** · **[Website](https://mrmemory.dev)**
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install amr
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from amr import AMR
51
+
52
+ amr = AMR("amr_sk_...") # or set AMR_API_KEY env var
53
+
54
+ # Store a memory
55
+ amr.remember("User prefers dark mode and vim keybindings")
56
+
57
+ # Semantic recall
58
+ memories = amr.recall("What are the user's preferences?")
59
+ for m in memories:
60
+ print(m.content, m.score)
61
+
62
+ # Forget a memory
63
+ amr.forget(memories[0].id)
64
+ ```
65
+
66
+ ## Async Support
67
+
68
+ ```python
69
+ from amr import AsyncAMR
70
+
71
+ async with AsyncAMR("amr_sk_...") as amr:
72
+ await amr.remember("User prefers dark mode")
73
+ memories = await amr.recall("What does the user prefer?")
74
+ ```
75
+
76
+ ## Configuration
77
+
78
+ ```python
79
+ amr = AMR(
80
+ api_key="amr_sk_...", # or set AMR_API_KEY env var
81
+ agent_id="my-assistant", # default agent ID
82
+ namespace="default", # default namespace
83
+ timeout=10.0, # seconds
84
+ max_retries=3, # retry on transient failures
85
+ )
86
+ ```
87
+
88
+ ## API Endpoints
89
+
90
+ All requests go to `https://amr-memory-api.fly.dev`.
91
+
92
+ | Method | Endpoint | Description |
93
+ |--------|----------|-------------|
94
+ | POST | `/v1/remember` | Store a memory |
95
+ | GET | `/v1/recall?q=...` | Semantic search |
96
+ | DELETE | `/v1/forget/:id` | Delete a memory |
97
+ | GET | `/v1/memories` | List all memories |
98
+
99
+ Auth: `Authorization: Bearer amr_sk_...`
100
+
101
+ ## Pricing
102
+
103
+ Starts at **$5/mo** — 10K memories, 50K API calls. [Sign up →](https://amr-memory-api.fly.dev/v1/billing/checkout)
104
+
105
+ ## Links
106
+
107
+ - **Docs:** https://mrmemory.dev/docs.html
108
+ - **Dashboard:** https://mrmemory.dev
109
+ - **GitHub:** https://github.com/amr-dev/amr-python
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,83 @@
1
+ # amr — Agent Memory Relay
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/amr)](https://pypi.org/project/amr/)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+ [![Docs](https://img.shields.io/badge/docs-mrmemory.dev-8b7aff)](https://mrmemory.dev/docs.html)
6
+
7
+ Persistent long-term memory for AI agents. One line to install, three lines to integrate.
8
+
9
+ **[Docs](https://mrmemory.dev/docs.html)** · **[API Reference](https://mrmemory.dev/docs.html#endpoints)** · **[Website](https://mrmemory.dev)**
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install amr
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from amr import AMR
21
+
22
+ amr = AMR("amr_sk_...") # or set AMR_API_KEY env var
23
+
24
+ # Store a memory
25
+ amr.remember("User prefers dark mode and vim keybindings")
26
+
27
+ # Semantic recall
28
+ memories = amr.recall("What are the user's preferences?")
29
+ for m in memories:
30
+ print(m.content, m.score)
31
+
32
+ # Forget a memory
33
+ amr.forget(memories[0].id)
34
+ ```
35
+
36
+ ## Async Support
37
+
38
+ ```python
39
+ from amr import AsyncAMR
40
+
41
+ async with AsyncAMR("amr_sk_...") as amr:
42
+ await amr.remember("User prefers dark mode")
43
+ memories = await amr.recall("What does the user prefer?")
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ ```python
49
+ amr = AMR(
50
+ api_key="amr_sk_...", # or set AMR_API_KEY env var
51
+ agent_id="my-assistant", # default agent ID
52
+ namespace="default", # default namespace
53
+ timeout=10.0, # seconds
54
+ max_retries=3, # retry on transient failures
55
+ )
56
+ ```
57
+
58
+ ## API Endpoints
59
+
60
+ All requests go to `https://amr-memory-api.fly.dev`.
61
+
62
+ | Method | Endpoint | Description |
63
+ |--------|----------|-------------|
64
+ | POST | `/v1/remember` | Store a memory |
65
+ | GET | `/v1/recall?q=...` | Semantic search |
66
+ | DELETE | `/v1/forget/:id` | Delete a memory |
67
+ | GET | `/v1/memories` | List all memories |
68
+
69
+ Auth: `Authorization: Bearer amr_sk_...`
70
+
71
+ ## Pricing
72
+
73
+ Starts at **$5/mo** — 10K memories, 50K API calls. [Sign up →](https://amr-memory-api.fly.dev/v1/billing/checkout)
74
+
75
+ ## Links
76
+
77
+ - **Docs:** https://mrmemory.dev/docs.html
78
+ - **Dashboard:** https://mrmemory.dev
79
+ - **GitHub:** https://github.com/amr-dev/amr-python
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mrmemory"
7
+ version = "0.1.0"
8
+ description = "Agent Memory Relay — persistent long-term memory for AI agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "AMR", email = "sdk@amr.dev" }]
13
+ keywords = ["ai", "agents", "memory", "llm", "semantic-search"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.27,<1"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "respx>=0.22", "ruff>=0.8", "mypy>=1.13"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://mrmemory.dev"
33
+ Documentation = "https://docs.amr.dev"
34
+ Repository = "https://github.com/amr-dev/amr-python"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/amr"]
38
+
39
+ [tool.ruff]
40
+ target-version = "py310"
41
+ line-length = 100
42
+
43
+ [tool.mypy]
44
+ strict = true
45
+ python_version = "3.10"
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
49
+ testpaths = ["tests"]
@@ -0,0 +1,28 @@
1
+ """AMR — Agent Memory Relay. Persistent long-term memory for AI agents."""
2
+
3
+ from amr.client import AMR
4
+ from amr.async_client import AsyncAMR
5
+ from amr.types import Memory, MemoryEvent
6
+ from amr.errors import (
7
+ AMRError,
8
+ AuthenticationError,
9
+ RateLimitError,
10
+ NotFoundError,
11
+ ValidationError,
12
+ ServerError,
13
+ )
14
+
15
+ __all__ = [
16
+ "AMR",
17
+ "AsyncAMR",
18
+ "Memory",
19
+ "MemoryEvent",
20
+ "AMRError",
21
+ "AuthenticationError",
22
+ "RateLimitError",
23
+ "NotFoundError",
24
+ "ValidationError",
25
+ "ServerError",
26
+ ]
27
+
28
+ __version__ = "0.1.0"
@@ -0,0 +1,47 @@
1
+ """Configuration resolution for AMR clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ DEFAULT_BASE_URL = "https://api.amr.dev/v1"
9
+ DEFAULT_TIMEOUT = 10.0
10
+ DEFAULT_MAX_RETRIES = 3
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class Config:
15
+ """Resolved client configuration."""
16
+
17
+ api_key: str
18
+ base_url: str
19
+ agent_id: str | None
20
+ namespace: str | None
21
+ timeout: float
22
+ max_retries: int
23
+
24
+ @classmethod
25
+ def resolve(
26
+ cls,
27
+ api_key: str | None = None,
28
+ base_url: str | None = None,
29
+ agent_id: str | None = None,
30
+ namespace: str | None = None,
31
+ timeout: float | None = None,
32
+ max_retries: int | None = None,
33
+ ) -> Config:
34
+ """Build config from explicit args → env vars → defaults."""
35
+ resolved_key = api_key or os.environ.get("AMR_API_KEY", "")
36
+ if not resolved_key:
37
+ raise ValueError(
38
+ "No API key provided. Pass api_key= or set AMR_API_KEY environment variable."
39
+ )
40
+ return cls(
41
+ api_key=resolved_key,
42
+ base_url=(base_url or os.environ.get("AMR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"),
43
+ agent_id=agent_id or os.environ.get("AMR_AGENT_ID"),
44
+ namespace=namespace or os.environ.get("AMR_NAMESPACE"),
45
+ timeout=timeout if timeout is not None else DEFAULT_TIMEOUT,
46
+ max_retries=max_retries if max_retries is not None else DEFAULT_MAX_RETRIES,
47
+ )
@@ -0,0 +1,137 @@
1
+ """HTTP transport with retry logic for AMR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from amr._config import Config
12
+ from amr.errors import (
13
+ AMRError,
14
+ AuthenticationError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ ServerError,
18
+ ValidationError,
19
+ )
20
+
21
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
22
+
23
+
24
+ def _raise_for_status(response: httpx.Response) -> None:
25
+ """Raise an appropriate AMRError for non-2xx responses."""
26
+ code = response.status_code
27
+ if 200 <= code < 300:
28
+ return
29
+
30
+ try:
31
+ body = response.json()
32
+ message = body.get("error", body.get("message", response.text))
33
+ except Exception:
34
+ message = response.text or f"HTTP {code}"
35
+
36
+ if code == 401:
37
+ raise AuthenticationError(message, status_code=401)
38
+ if code == 404:
39
+ raise NotFoundError(message)
40
+ if code == 422:
41
+ raise ValidationError(message)
42
+ if code == 429:
43
+ retry_after = float(response.headers.get("retry-after", "1"))
44
+ raise RateLimitError(message, retry_after=retry_after)
45
+ if code >= 500:
46
+ raise ServerError(message, status_code=code)
47
+ raise AMRError(message, status_code=code)
48
+
49
+
50
+ def _backoff(attempt: int) -> float:
51
+ """Exponential backoff with jitter."""
52
+ base = min(2**attempt, 30)
53
+ return base * (0.5 + random.random() * 0.5)
54
+
55
+
56
+ class SyncTransport:
57
+ """Synchronous HTTP transport."""
58
+
59
+ def __init__(self, config: Config) -> None:
60
+ self._config = config
61
+ self._client = httpx.Client(
62
+ base_url=config.base_url,
63
+ headers={
64
+ "Authorization": f"Bearer {config.api_key}",
65
+ "Content-Type": "application/json",
66
+ "User-Agent": "amr-python/0.1.0",
67
+ },
68
+ timeout=config.timeout,
69
+ )
70
+
71
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
72
+ """Make an HTTP request with automatic retries."""
73
+ last_exc: Exception | None = None
74
+ for attempt in range(self._config.max_retries + 1):
75
+ try:
76
+ response = self._client.request(method, path, **kwargs)
77
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._config.max_retries:
78
+ wait = float(response.headers.get("retry-after", "0")) or _backoff(attempt)
79
+ time.sleep(wait)
80
+ continue
81
+ _raise_for_status(response)
82
+ if response.status_code == 204:
83
+ return None
84
+ return response.json()
85
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
86
+ last_exc = exc
87
+ if attempt < self._config.max_retries:
88
+ time.sleep(_backoff(attempt))
89
+ continue
90
+ raise AMRError(f"Connection failed: {exc}") from exc
91
+ raise last_exc or AMRError("Request failed after retries") # type: ignore[misc]
92
+
93
+ def close(self) -> None:
94
+ self._client.close()
95
+
96
+
97
+ class AsyncTransport:
98
+ """Asynchronous HTTP transport."""
99
+
100
+ def __init__(self, config: Config) -> None:
101
+ self._config = config
102
+ self._client = httpx.AsyncClient(
103
+ base_url=config.base_url,
104
+ headers={
105
+ "Authorization": f"Bearer {config.api_key}",
106
+ "Content-Type": "application/json",
107
+ "User-Agent": "amr-python/0.1.0",
108
+ },
109
+ timeout=config.timeout,
110
+ )
111
+
112
+ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
113
+ """Make an async HTTP request with automatic retries."""
114
+ import asyncio
115
+
116
+ last_exc: Exception | None = None
117
+ for attempt in range(self._config.max_retries + 1):
118
+ try:
119
+ response = await self._client.request(method, path, **kwargs)
120
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._config.max_retries:
121
+ wait = float(response.headers.get("retry-after", "0")) or _backoff(attempt)
122
+ await asyncio.sleep(wait)
123
+ continue
124
+ _raise_for_status(response)
125
+ if response.status_code == 204:
126
+ return None
127
+ return response.json()
128
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
129
+ last_exc = exc
130
+ if attempt < self._config.max_retries:
131
+ await asyncio.sleep(_backoff(attempt))
132
+ continue
133
+ raise AMRError(f"Connection failed: {exc}") from exc
134
+ raise last_exc or AMRError("Request failed after retries") # type: ignore[misc]
135
+
136
+ async def close(self) -> None:
137
+ await self._client.aclose()
@@ -0,0 +1,154 @@
1
+ """Asynchronous AMR client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ from amr._config import Config
9
+ from amr._http import AsyncTransport
10
+ from amr.types import Memory
11
+
12
+
13
+ class AsyncAMR:
14
+ """Asynchronous client for Agent Memory Relay.
15
+
16
+ Usage::
17
+
18
+ from amr import AsyncAMR
19
+
20
+ async with AsyncAMR("amr_sk_...") as amr:
21
+ await amr.remember("User prefers dark mode", tags=["preferences"])
22
+ memories = await amr.recall("What does the user prefer?")
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str | None = None,
28
+ *,
29
+ base_url: str | None = None,
30
+ agent_id: str | None = None,
31
+ namespace: str | None = None,
32
+ timeout: float | None = None,
33
+ max_retries: int | None = None,
34
+ ) -> None:
35
+ self._config = Config.resolve(
36
+ api_key=api_key,
37
+ base_url=base_url,
38
+ agent_id=agent_id,
39
+ namespace=namespace,
40
+ timeout=timeout,
41
+ max_retries=max_retries,
42
+ )
43
+ self._transport = AsyncTransport(self._config)
44
+
45
+ # -- Context manager --
46
+
47
+ async def __aenter__(self) -> AsyncAMR:
48
+ return self
49
+
50
+ async def __aexit__(self, *_: Any) -> None:
51
+ await self.close()
52
+
53
+ async def close(self) -> None:
54
+ """Close the underlying HTTP connection pool."""
55
+ await self._transport.close()
56
+
57
+ # -- Core API --
58
+
59
+ async def remember(
60
+ self,
61
+ content: str,
62
+ *,
63
+ tags: list[str] | None = None,
64
+ namespace: str | None = None,
65
+ agent_id: str | None = None,
66
+ ttl: timedelta | None = None,
67
+ ) -> Memory:
68
+ """Store a memory."""
69
+ body: dict[str, Any] = {"content": content}
70
+ if tags:
71
+ body["tags"] = tags
72
+ if namespace or self._config.namespace:
73
+ body["namespace"] = namespace or self._config.namespace
74
+ if agent_id or self._config.agent_id:
75
+ body["agent_id"] = agent_id or self._config.agent_id
76
+ if ttl is not None:
77
+ body["ttl"] = int(ttl.total_seconds())
78
+
79
+ data = await self._transport.request("POST", "/remember", json=body)
80
+ return Memory.from_dict(data)
81
+
82
+ async def recall(
83
+ self,
84
+ query: str,
85
+ *,
86
+ namespace: str | None = None,
87
+ agent_id: str | None = None,
88
+ tags: list[str] | None = None,
89
+ limit: int = 5,
90
+ threshold: float = 0.7,
91
+ ) -> list[Memory]:
92
+ """Retrieve relevant memories via semantic search."""
93
+ body: dict[str, Any] = {"query": query, "limit": limit, "threshold": threshold}
94
+ if tags:
95
+ body["tags"] = tags
96
+ if namespace or self._config.namespace:
97
+ body["namespace"] = namespace or self._config.namespace
98
+ if agent_id or self._config.agent_id:
99
+ body["agent_id"] = agent_id or self._config.agent_id
100
+
101
+ data = await self._transport.request("POST", "/recall", json=body)
102
+ return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
103
+
104
+ async def share(
105
+ self,
106
+ memory_ids: str | list[str],
107
+ *,
108
+ target_agent: str,
109
+ permissions: str = "read",
110
+ ) -> None:
111
+ """Share memories with another agent."""
112
+ ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
113
+ await self._transport.request(
114
+ "POST",
115
+ "/share",
116
+ json={
117
+ "memory_ids": ids,
118
+ "target_agent_id": target_agent,
119
+ "permissions": permissions,
120
+ },
121
+ )
122
+
123
+ async def forget(self, memory_ids: str | list[str]) -> None:
124
+ """Delete one or more memories."""
125
+ ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
126
+ await self._transport.request("DELETE", "/forget", json={"memory_ids": ids})
127
+
128
+ async def forget_all(self, *, namespace: str | None = None) -> None:
129
+ """Delete all memories, optionally scoped to a namespace."""
130
+ body: dict[str, Any] = {"forget_all": True}
131
+ if namespace:
132
+ body["namespace"] = namespace
133
+ await self._transport.request("DELETE", "/forget", json=body)
134
+
135
+ async def memories(
136
+ self,
137
+ *,
138
+ namespace: str | None = None,
139
+ agent_id: str | None = None,
140
+ tags: list[str] | None = None,
141
+ limit: int = 20,
142
+ offset: int = 0,
143
+ ) -> list[Memory]:
144
+ """List memories with optional filters."""
145
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
146
+ if namespace or self._config.namespace:
147
+ params["namespace"] = namespace or self._config.namespace
148
+ if agent_id or self._config.agent_id:
149
+ params["agent_id"] = agent_id or self._config.agent_id
150
+ if tags:
151
+ params["tags"] = ",".join(tags)
152
+
153
+ data = await self._transport.request("GET", "/memories", params=params)
154
+ return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
@@ -0,0 +1,202 @@
1
+ """Synchronous AMR client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ from amr._config import Config
9
+ from amr._http import SyncTransport
10
+ from amr.types import Memory
11
+
12
+
13
+ class AMR:
14
+ """Synchronous client for Agent Memory Relay.
15
+
16
+ Usage::
17
+
18
+ from amr import AMR
19
+
20
+ amr = AMR("amr_sk_...")
21
+ amr.remember("User prefers dark mode", tags=["preferences"])
22
+ memories = amr.recall("What does the user prefer?")
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str | None = None,
28
+ *,
29
+ base_url: str | None = None,
30
+ agent_id: str | None = None,
31
+ namespace: str | None = None,
32
+ timeout: float | None = None,
33
+ max_retries: int | None = None,
34
+ ) -> None:
35
+ self._config = Config.resolve(
36
+ api_key=api_key,
37
+ base_url=base_url,
38
+ agent_id=agent_id,
39
+ namespace=namespace,
40
+ timeout=timeout,
41
+ max_retries=max_retries,
42
+ )
43
+ self._transport = SyncTransport(self._config)
44
+
45
+ # -- Context manager --
46
+
47
+ def __enter__(self) -> AMR:
48
+ return self
49
+
50
+ def __exit__(self, *_: Any) -> None:
51
+ self.close()
52
+
53
+ def close(self) -> None:
54
+ """Close the underlying HTTP connection pool."""
55
+ self._transport.close()
56
+
57
+ # -- Core API --
58
+
59
+ def remember(
60
+ self,
61
+ content: str,
62
+ *,
63
+ tags: list[str] | None = None,
64
+ namespace: str | None = None,
65
+ agent_id: str | None = None,
66
+ ttl: timedelta | None = None,
67
+ ) -> Memory:
68
+ """Store a memory.
69
+
70
+ Args:
71
+ content: The memory text to store.
72
+ tags: Optional tags for filtering.
73
+ namespace: Override the client default namespace.
74
+ agent_id: Override the client default agent ID.
75
+ ttl: Auto-expire after this duration.
76
+
77
+ Returns:
78
+ The created Memory object.
79
+ """
80
+ body: dict[str, Any] = {"content": content}
81
+ if tags:
82
+ body["tags"] = tags
83
+ if namespace or self._config.namespace:
84
+ body["namespace"] = namespace or self._config.namespace
85
+ if agent_id or self._config.agent_id:
86
+ body["agent_id"] = agent_id or self._config.agent_id
87
+ if ttl is not None:
88
+ body["ttl"] = int(ttl.total_seconds())
89
+
90
+ data = self._transport.request("POST", "/remember", json=body)
91
+ return Memory.from_dict(data)
92
+
93
+ def recall(
94
+ self,
95
+ query: str,
96
+ *,
97
+ namespace: str | None = None,
98
+ agent_id: str | None = None,
99
+ tags: list[str] | None = None,
100
+ limit: int = 5,
101
+ threshold: float = 0.7,
102
+ ) -> list[Memory]:
103
+ """Retrieve relevant memories via semantic search.
104
+
105
+ Args:
106
+ query: Natural language query.
107
+ namespace: Filter by namespace.
108
+ agent_id: Filter by agent ID.
109
+ tags: Filter by tags (AND).
110
+ limit: Maximum results (default 5).
111
+ threshold: Minimum similarity score (default 0.7).
112
+
113
+ Returns:
114
+ List of Memory objects ranked by relevance.
115
+ """
116
+ body: dict[str, Any] = {"query": query, "limit": limit, "threshold": threshold}
117
+ if tags:
118
+ body["tags"] = tags
119
+ if namespace or self._config.namespace:
120
+ body["namespace"] = namespace or self._config.namespace
121
+ if agent_id or self._config.agent_id:
122
+ body["agent_id"] = agent_id or self._config.agent_id
123
+
124
+ data = self._transport.request("POST", "/recall", json=body)
125
+ return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
126
+
127
+ def share(
128
+ self,
129
+ memory_ids: str | list[str],
130
+ *,
131
+ target_agent: str,
132
+ permissions: str = "read",
133
+ ) -> None:
134
+ """Share memories with another agent.
135
+
136
+ Args:
137
+ memory_ids: Single ID or list of memory IDs to share.
138
+ target_agent: The target agent ID.
139
+ permissions: "read" (default) or "readwrite".
140
+ """
141
+ ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
142
+ self._transport.request(
143
+ "POST",
144
+ "/share",
145
+ json={
146
+ "memory_ids": ids,
147
+ "target_agent_id": target_agent,
148
+ "permissions": permissions,
149
+ },
150
+ )
151
+
152
+ def forget(self, memory_ids: str | list[str]) -> None:
153
+ """Delete one or more memories.
154
+
155
+ Args:
156
+ memory_ids: Single ID or list of memory IDs to delete.
157
+ """
158
+ ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
159
+ self._transport.request("DELETE", "/forget", json={"memory_ids": ids})
160
+
161
+ def forget_all(self, *, namespace: str | None = None) -> None:
162
+ """Delete all memories, optionally scoped to a namespace.
163
+
164
+ Args:
165
+ namespace: If provided, only delete memories in this namespace.
166
+ """
167
+ body: dict[str, Any] = {"forget_all": True}
168
+ if namespace:
169
+ body["namespace"] = namespace
170
+ self._transport.request("DELETE", "/forget", json=body)
171
+
172
+ def memories(
173
+ self,
174
+ *,
175
+ namespace: str | None = None,
176
+ agent_id: str | None = None,
177
+ tags: list[str] | None = None,
178
+ limit: int = 20,
179
+ offset: int = 0,
180
+ ) -> list[Memory]:
181
+ """List memories with optional filters.
182
+
183
+ Args:
184
+ namespace: Filter by namespace.
185
+ agent_id: Filter by agent ID.
186
+ tags: Filter by tags.
187
+ limit: Page size (default 20).
188
+ offset: Pagination offset.
189
+
190
+ Returns:
191
+ List of Memory objects.
192
+ """
193
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
194
+ if namespace or self._config.namespace:
195
+ params["namespace"] = namespace or self._config.namespace
196
+ if agent_id or self._config.agent_id:
197
+ params["agent_id"] = agent_id or self._config.agent_id
198
+ if tags:
199
+ params["tags"] = ",".join(tags)
200
+
201
+ data = self._transport.request("GET", "/memories", params=params)
202
+ return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
@@ -0,0 +1,44 @@
1
+ """AMR exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AMRError(Exception):
7
+ """Base exception for all AMR errors."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None) -> None:
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+
13
+
14
+ class AuthenticationError(AMRError):
15
+ """Invalid or missing API key (401)."""
16
+
17
+
18
+ class RateLimitError(AMRError):
19
+ """Too many requests (429)."""
20
+
21
+ def __init__(self, message: str, retry_after: float = 1.0) -> None:
22
+ super().__init__(message, status_code=429)
23
+ self.retry_after = retry_after
24
+
25
+
26
+ class NotFoundError(AMRError):
27
+ """Resource not found (404)."""
28
+
29
+ def __init__(self, message: str = "Not found") -> None:
30
+ super().__init__(message, status_code=404)
31
+
32
+
33
+ class ValidationError(AMRError):
34
+ """Invalid request parameters (422)."""
35
+
36
+ def __init__(self, message: str = "Validation error") -> None:
37
+ super().__init__(message, status_code=422)
38
+
39
+
40
+ class ServerError(AMRError):
41
+ """AMR server error (5xx)."""
42
+
43
+ def __init__(self, message: str = "Server error", status_code: int = 500) -> None:
44
+ super().__init__(message, status_code=status_code)
File without changes
@@ -0,0 +1,69 @@
1
+ """AMR data types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timedelta
7
+ from typing import Literal
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Memory:
12
+ """A stored memory."""
13
+
14
+ id: str
15
+ content: str
16
+ tags: list[str] = field(default_factory=list)
17
+ namespace: str = "default"
18
+ agent_id: str = ""
19
+ score: float | None = None
20
+ ttl: timedelta | None = None
21
+ expires_at: datetime | None = None
22
+ created_at: datetime = field(default_factory=datetime.now)
23
+ updated_at: datetime = field(default_factory=datetime.now)
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: dict) -> Memory: # type: ignore[type-arg]
27
+ """Parse a Memory from an API response dict."""
28
+ return cls(
29
+ id=data["id"],
30
+ content=data["content"],
31
+ tags=data.get("tags", []),
32
+ namespace=data.get("namespace", "default"),
33
+ agent_id=data.get("agent_id", ""),
34
+ score=data.get("score"),
35
+ ttl=timedelta(seconds=data["ttl"]) if data.get("ttl") else None,
36
+ expires_at=_parse_dt(data.get("expires_at")),
37
+ created_at=_parse_dt(data.get("created_at")) or datetime.now(),
38
+ updated_at=_parse_dt(data.get("updated_at")) or datetime.now(),
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class MemoryEvent:
44
+ """A real-time memory event from the WebSocket stream."""
45
+
46
+ type: Literal["memory.created", "memory.shared", "memory.expired"]
47
+ memory_id: str
48
+ memory: Memory | None = None
49
+ timestamp: datetime = field(default_factory=datetime.now)
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> MemoryEvent: # type: ignore[type-arg]
53
+ """Parse a MemoryEvent from a WebSocket message."""
54
+ mem = Memory.from_dict(data["memory"]) if data.get("memory") else None
55
+ return cls(
56
+ type=data["type"],
57
+ memory_id=data.get("memory_id", mem.id if mem else ""),
58
+ memory=mem,
59
+ timestamp=_parse_dt(data.get("timestamp")) or datetime.now(),
60
+ )
61
+
62
+
63
+ def _parse_dt(value: str | None) -> datetime | None:
64
+ """Parse an ISO 8601 datetime string."""
65
+ if value is None:
66
+ return None
67
+ # Handle trailing Z
68
+ s = value.replace("Z", "+00:00")
69
+ return datetime.fromisoformat(s)
File without changes
@@ -0,0 +1,74 @@
1
+ """Tests for the async AMR client."""
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from amr import AsyncAMR, Memory
8
+
9
+
10
+ API_KEY = "amr_sk_test_key_1234567890"
11
+ BASE_URL = "https://api.amr.dev/v1"
12
+
13
+ MOCK_MEMORY = {
14
+ "id": "mem_abc123",
15
+ "content": "User prefers dark mode",
16
+ "tags": ["preferences"],
17
+ "namespace": "default",
18
+ "agent_id": "test-agent",
19
+ "created_at": "2026-03-12T10:00:00Z",
20
+ "updated_at": "2026-03-12T10:00:00Z",
21
+ }
22
+
23
+
24
+ @respx.mock
25
+ async def test_async_remember():
26
+ respx.post(f"{BASE_URL}/remember").mock(
27
+ return_value=httpx.Response(201, json=MOCK_MEMORY)
28
+ )
29
+ async with AsyncAMR(API_KEY) as amr:
30
+ memory = await amr.remember("User prefers dark mode", tags=["preferences"])
31
+
32
+ assert isinstance(memory, Memory)
33
+ assert memory.id == "mem_abc123"
34
+
35
+
36
+ @respx.mock
37
+ async def test_async_recall():
38
+ respx.post(f"{BASE_URL}/recall").mock(
39
+ return_value=httpx.Response(200, json={"memories": [{**MOCK_MEMORY, "score": 0.92}]})
40
+ )
41
+ async with AsyncAMR(API_KEY) as amr:
42
+ memories = await amr.recall("dark mode?")
43
+
44
+ assert len(memories) == 1
45
+ assert memories[0].score == 0.92
46
+
47
+
48
+ @respx.mock
49
+ async def test_async_share():
50
+ respx.post(f"{BASE_URL}/share").mock(
51
+ return_value=httpx.Response(200, json={"ok": True})
52
+ )
53
+ async with AsyncAMR(API_KEY) as amr:
54
+ await amr.share("mem_abc123", target_agent="agent-2")
55
+
56
+
57
+ @respx.mock
58
+ async def test_async_forget():
59
+ respx.delete(f"{BASE_URL}/forget").mock(
60
+ return_value=httpx.Response(204)
61
+ )
62
+ async with AsyncAMR(API_KEY) as amr:
63
+ await amr.forget("mem_abc123")
64
+
65
+
66
+ @respx.mock
67
+ async def test_async_memories():
68
+ respx.get(f"{BASE_URL}/memories").mock(
69
+ return_value=httpx.Response(200, json={"memories": [MOCK_MEMORY]})
70
+ )
71
+ async with AsyncAMR(API_KEY) as amr:
72
+ memories = await amr.memories()
73
+
74
+ assert len(memories) == 1
@@ -0,0 +1,151 @@
1
+ """Tests for the synchronous AMR client."""
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from amr import AMR, Memory
8
+ from amr.errors import AuthenticationError, RateLimitError, NotFoundError, ValidationError
9
+
10
+
11
+ API_KEY = "amr_sk_test_key_1234567890"
12
+ BASE_URL = "https://api.amr.dev/v1"
13
+
14
+ MOCK_MEMORY = {
15
+ "id": "mem_abc123",
16
+ "content": "User prefers dark mode",
17
+ "tags": ["preferences"],
18
+ "namespace": "default",
19
+ "agent_id": "test-agent",
20
+ "created_at": "2026-03-12T10:00:00Z",
21
+ "updated_at": "2026-03-12T10:00:00Z",
22
+ }
23
+
24
+
25
+ @respx.mock
26
+ def test_remember():
27
+ respx.post(f"{BASE_URL}/remember").mock(
28
+ return_value=httpx.Response(201, json=MOCK_MEMORY)
29
+ )
30
+ with AMR(API_KEY) as amr:
31
+ memory = amr.remember("User prefers dark mode", tags=["preferences"])
32
+
33
+ assert isinstance(memory, Memory)
34
+ assert memory.id == "mem_abc123"
35
+ assert memory.content == "User prefers dark mode"
36
+ assert memory.tags == ["preferences"]
37
+
38
+
39
+ @respx.mock
40
+ def test_recall():
41
+ respx.post(f"{BASE_URL}/recall").mock(
42
+ return_value=httpx.Response(200, json={"memories": [{**MOCK_MEMORY, "score": 0.95}]})
43
+ )
44
+ with AMR(API_KEY) as amr:
45
+ memories = amr.recall("What does the user prefer?")
46
+
47
+ assert len(memories) == 1
48
+ assert memories[0].score == 0.95
49
+ assert memories[0].content == "User prefers dark mode"
50
+
51
+
52
+ @respx.mock
53
+ def test_share():
54
+ respx.post(f"{BASE_URL}/share").mock(
55
+ return_value=httpx.Response(200, json={"ok": True})
56
+ )
57
+ with AMR(API_KEY) as amr:
58
+ amr.share("mem_abc123", target_agent="agent-2") # no exception = success
59
+
60
+
61
+ @respx.mock
62
+ def test_forget():
63
+ respx.delete(f"{BASE_URL}/forget").mock(
64
+ return_value=httpx.Response(204)
65
+ )
66
+ with AMR(API_KEY) as amr:
67
+ amr.forget("mem_abc123") # no exception = success
68
+
69
+
70
+ @respx.mock
71
+ def test_forget_all():
72
+ respx.delete(f"{BASE_URL}/forget").mock(
73
+ return_value=httpx.Response(204)
74
+ )
75
+ with AMR(API_KEY) as amr:
76
+ amr.forget_all(namespace="test")
77
+
78
+
79
+ @respx.mock
80
+ def test_memories_list():
81
+ respx.get(f"{BASE_URL}/memories").mock(
82
+ return_value=httpx.Response(200, json={"memories": [MOCK_MEMORY]})
83
+ )
84
+ with AMR(API_KEY) as amr:
85
+ memories = amr.memories(limit=10)
86
+
87
+ assert len(memories) == 1
88
+ assert memories[0].id == "mem_abc123"
89
+
90
+
91
+ @respx.mock
92
+ def test_auth_error():
93
+ respx.post(f"{BASE_URL}/remember").mock(
94
+ return_value=httpx.Response(401, json={"error": "Invalid API key"})
95
+ )
96
+ with AMR(API_KEY, max_retries=0) as amr:
97
+ with pytest.raises(AuthenticationError):
98
+ amr.remember("test")
99
+
100
+
101
+ @respx.mock
102
+ def test_rate_limit_error():
103
+ respx.post(f"{BASE_URL}/remember").mock(
104
+ return_value=httpx.Response(429, json={"error": "Too many requests"}, headers={"retry-after": "5"})
105
+ )
106
+ with AMR(API_KEY, max_retries=0) as amr:
107
+ with pytest.raises(RateLimitError) as exc_info:
108
+ amr.remember("test")
109
+ assert exc_info.value.retry_after == 5.0
110
+
111
+
112
+ @respx.mock
113
+ def test_not_found_error():
114
+ respx.delete(f"{BASE_URL}/forget").mock(
115
+ return_value=httpx.Response(404, json={"error": "Memory not found"})
116
+ )
117
+ with AMR(API_KEY, max_retries=0) as amr:
118
+ with pytest.raises(NotFoundError):
119
+ amr.forget("mem_nonexistent")
120
+
121
+
122
+ @respx.mock
123
+ def test_validation_error():
124
+ respx.post(f"{BASE_URL}/remember").mock(
125
+ return_value=httpx.Response(422, json={"error": "content is required"})
126
+ )
127
+ with AMR(API_KEY, max_retries=0) as amr:
128
+ with pytest.raises(ValidationError):
129
+ amr.remember("")
130
+
131
+
132
+ def test_no_api_key():
133
+ import os
134
+ env_backup = os.environ.pop("AMR_API_KEY", None)
135
+ try:
136
+ with pytest.raises(ValueError, match="No API key"):
137
+ AMR()
138
+ finally:
139
+ if env_backup:
140
+ os.environ["AMR_API_KEY"] = env_backup
141
+
142
+
143
+ def test_env_api_key():
144
+ import os
145
+ os.environ["AMR_API_KEY"] = "amr_sk_from_env"
146
+ try:
147
+ amr = AMR()
148
+ assert amr._config.api_key == "amr_sk_from_env"
149
+ amr.close()
150
+ finally:
151
+ del os.environ["AMR_API_KEY"]