audrey-memory 0.23.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audrey_memory-0.23.1/PKG-INFO +104 -0
- audrey_memory-0.23.1/README.md +80 -0
- audrey_memory-0.23.1/audrey_memory/__init__.py +51 -0
- audrey_memory-0.23.1/audrey_memory/_version.py +1 -0
- audrey_memory-0.23.1/audrey_memory/client.py +372 -0
- audrey_memory-0.23.1/audrey_memory/py.typed +1 -0
- audrey_memory-0.23.1/audrey_memory/types.py +157 -0
- audrey_memory-0.23.1/audrey_memory.egg-info/PKG-INFO +104 -0
- audrey_memory-0.23.1/audrey_memory.egg-info/SOURCES.txt +13 -0
- audrey_memory-0.23.1/audrey_memory.egg-info/dependency_links.txt +1 -0
- audrey_memory-0.23.1/audrey_memory.egg-info/requires.txt +2 -0
- audrey_memory-0.23.1/audrey_memory.egg-info/top_level.txt +1 -0
- audrey_memory-0.23.1/pyproject.toml +56 -0
- audrey_memory-0.23.1/setup.cfg +4 -0
- audrey_memory-0.23.1/tests/test_client.py +252 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: audrey-memory
|
|
3
|
+
Version: 0.23.1
|
|
4
|
+
Summary: Typed Python client for the Audrey LLM memory server
|
|
5
|
+
Author: evilander
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Evilander/Audrey
|
|
8
|
+
Project-URL: Repository, https://github.com/Evilander/Audrey
|
|
9
|
+
Project-URL: Issues, https://github.com/Evilander/Audrey/issues
|
|
10
|
+
Keywords: ai,agents,audrey,llm,memory,pydantic,python
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
24
|
+
|
|
25
|
+
# Audrey Python SDK
|
|
26
|
+
|
|
27
|
+
Typed Python client for the Audrey REST API.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install audrey-memory
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development from this repository:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd python
|
|
39
|
+
python -m pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
Start Audrey's REST API:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx audrey serve
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then use the client:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from audrey_memory import Audrey
|
|
54
|
+
|
|
55
|
+
brain = Audrey(
|
|
56
|
+
base_url="http://127.0.0.1:7437",
|
|
57
|
+
api_key="secret",
|
|
58
|
+
agent="support-agent",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
memory_id = brain.encode(
|
|
62
|
+
"Stripe returns HTTP 429 above 100 req/s",
|
|
63
|
+
source="direct-observation",
|
|
64
|
+
tags=["stripe", "rate-limit"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
results = brain.recall("stripe rate limits", limit=5)
|
|
68
|
+
snapshot = brain.snapshot()
|
|
69
|
+
brain.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Restore snapshots only into an empty Audrey store, such as a sidecar started with a fresh `AUDREY_DATA_DIR`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
restore_target = Audrey(base_url="http://127.0.0.1:7437", api_key="secret")
|
|
76
|
+
restore_target.restore(snapshot)
|
|
77
|
+
restore_target.close()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Async usage:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
|
|
85
|
+
from audrey_memory import AsyncAudrey
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def main() -> None:
|
|
89
|
+
async with AsyncAudrey(base_url="http://127.0.0.1:7437") as brain:
|
|
90
|
+
await brain.health()
|
|
91
|
+
await brain.encode("Deploy failed due to OOM", source="direct-observation")
|
|
92
|
+
await brain.recall("deploy failure", limit=3)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Features
|
|
99
|
+
|
|
100
|
+
- Sync and async clients powered by `httpx`
|
|
101
|
+
- Pydantic request and response models
|
|
102
|
+
- Bearer auth via `AUDREY_API_KEY`
|
|
103
|
+
- Optional `X-Audrey-Agent` header on client requests
|
|
104
|
+
- Snapshot export and restore support
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Audrey Python SDK
|
|
2
|
+
|
|
3
|
+
Typed Python client for the Audrey REST API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install audrey-memory
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For local development from this repository:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd python
|
|
15
|
+
python -m pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
Start Audrey's REST API:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx audrey serve
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then use the client:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from audrey_memory import Audrey
|
|
30
|
+
|
|
31
|
+
brain = Audrey(
|
|
32
|
+
base_url="http://127.0.0.1:7437",
|
|
33
|
+
api_key="secret",
|
|
34
|
+
agent="support-agent",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
memory_id = brain.encode(
|
|
38
|
+
"Stripe returns HTTP 429 above 100 req/s",
|
|
39
|
+
source="direct-observation",
|
|
40
|
+
tags=["stripe", "rate-limit"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
results = brain.recall("stripe rate limits", limit=5)
|
|
44
|
+
snapshot = brain.snapshot()
|
|
45
|
+
brain.close()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Restore snapshots only into an empty Audrey store, such as a sidecar started with a fresh `AUDREY_DATA_DIR`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
restore_target = Audrey(base_url="http://127.0.0.1:7437", api_key="secret")
|
|
52
|
+
restore_target.restore(snapshot)
|
|
53
|
+
restore_target.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Async usage:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from audrey_memory import AsyncAudrey
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def main() -> None:
|
|
65
|
+
async with AsyncAudrey(base_url="http://127.0.0.1:7437") as brain:
|
|
66
|
+
await brain.health()
|
|
67
|
+
await brain.encode("Deploy failed due to OOM", source="direct-observation")
|
|
68
|
+
await brain.recall("deploy failure", limit=3)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
asyncio.run(main())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
- Sync and async clients powered by `httpx`
|
|
77
|
+
- Pydantic request and response models
|
|
78
|
+
- Bearer auth via `AUDREY_API_KEY`
|
|
79
|
+
- Optional `X-Audrey-Agent` header on client requests
|
|
80
|
+
- Snapshot export and restore support
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import AsyncAudrey, Audrey, AudreyAPIError
|
|
3
|
+
from .types import (
|
|
4
|
+
AckResponse,
|
|
5
|
+
Affect,
|
|
6
|
+
AnalyticsResponse,
|
|
7
|
+
ConsolidateRequest,
|
|
8
|
+
ContradictionStatus,
|
|
9
|
+
DreamRequest,
|
|
10
|
+
EncodeRequest,
|
|
11
|
+
EncodeResponse,
|
|
12
|
+
ForgetRequest,
|
|
13
|
+
ForgetResponse,
|
|
14
|
+
HealthResponse,
|
|
15
|
+
MarkUsedRequest,
|
|
16
|
+
MemorySnapshot,
|
|
17
|
+
OperationResult,
|
|
18
|
+
RecallError,
|
|
19
|
+
RecallRequest,
|
|
20
|
+
RecallResponse,
|
|
21
|
+
RecallResult,
|
|
22
|
+
RestoreResponse,
|
|
23
|
+
StatusResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"AckResponse",
|
|
29
|
+
"Affect",
|
|
30
|
+
"AnalyticsResponse",
|
|
31
|
+
"AsyncAudrey",
|
|
32
|
+
"Audrey",
|
|
33
|
+
"AudreyAPIError",
|
|
34
|
+
"ConsolidateRequest",
|
|
35
|
+
"ContradictionStatus",
|
|
36
|
+
"DreamRequest",
|
|
37
|
+
"EncodeRequest",
|
|
38
|
+
"EncodeResponse",
|
|
39
|
+
"ForgetRequest",
|
|
40
|
+
"ForgetResponse",
|
|
41
|
+
"HealthResponse",
|
|
42
|
+
"MarkUsedRequest",
|
|
43
|
+
"MemorySnapshot",
|
|
44
|
+
"OperationResult",
|
|
45
|
+
"RecallError",
|
|
46
|
+
"RecallRequest",
|
|
47
|
+
"RecallResponse",
|
|
48
|
+
"RecallResult",
|
|
49
|
+
"RestoreResponse",
|
|
50
|
+
"StatusResponse",
|
|
51
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.23.1"
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, TypeVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .types import (
|
|
10
|
+
AckResponse,
|
|
11
|
+
ConsolidateRequest,
|
|
12
|
+
DreamRequest,
|
|
13
|
+
EncodeRequest,
|
|
14
|
+
EncodeResponse,
|
|
15
|
+
ForgetRequest,
|
|
16
|
+
ForgetResponse,
|
|
17
|
+
HealthResponse,
|
|
18
|
+
MarkUsedRequest,
|
|
19
|
+
MemorySnapshot,
|
|
20
|
+
OperationResult,
|
|
21
|
+
RecallRequest,
|
|
22
|
+
RecallResponse,
|
|
23
|
+
RecallResult,
|
|
24
|
+
RestoreResponse,
|
|
25
|
+
StatusResponse,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:7437"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AudreyAPIError(RuntimeError):
|
|
34
|
+
def __init__(self, status_code: int, message: str, response_body: Any = None) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.response_body = response_body
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_headers(api_key: str | None, agent: str | None) -> dict[str, str]:
|
|
41
|
+
headers = {
|
|
42
|
+
"Accept": "application/json",
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"User-Agent": f"audrey-memory-python/{__version__}",
|
|
45
|
+
}
|
|
46
|
+
if api_key:
|
|
47
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
48
|
+
if agent:
|
|
49
|
+
headers["X-Audrey-Agent"] = agent
|
|
50
|
+
return headers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _dump_payload(payload: BaseModel | Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
54
|
+
if payload is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(payload, BaseModel):
|
|
57
|
+
return payload.model_dump(exclude_none=True, mode="json")
|
|
58
|
+
return {key: value for key, value in dict(payload).items() if value is not None}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _error_message(response: httpx.Response, data: Any) -> str:
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
detail = data.get("error") or data.get("message")
|
|
64
|
+
if isinstance(detail, str) and detail.strip():
|
|
65
|
+
return detail
|
|
66
|
+
return f"Audrey API request failed with status {response.status_code}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _decode_json(response: httpx.Response) -> Any:
|
|
70
|
+
try:
|
|
71
|
+
data = response.json()
|
|
72
|
+
except ValueError:
|
|
73
|
+
data = None
|
|
74
|
+
if response.is_error:
|
|
75
|
+
raise AudreyAPIError(response.status_code, _error_message(response, data), data)
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate(model_type: type[ModelT], data: Any) -> ModelT:
|
|
80
|
+
return model_type.model_validate(data)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_model_payload(
|
|
84
|
+
payload: BaseModel | Mapping[str, Any] | str,
|
|
85
|
+
model_type: type[ModelT],
|
|
86
|
+
field_name: str,
|
|
87
|
+
extra: dict[str, Any],
|
|
88
|
+
) -> ModelT:
|
|
89
|
+
if isinstance(payload, model_type):
|
|
90
|
+
if extra:
|
|
91
|
+
raise TypeError(f"{model_type.__name__} payload cannot be combined with keyword overrides")
|
|
92
|
+
return payload
|
|
93
|
+
if isinstance(payload, Mapping):
|
|
94
|
+
if extra:
|
|
95
|
+
raise TypeError(f"Mapping payload cannot be combined with keyword overrides for {model_type.__name__}")
|
|
96
|
+
return model_type.model_validate(payload)
|
|
97
|
+
return model_type.model_validate({field_name: payload, **extra})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _optional_model_payload(
|
|
101
|
+
payload: BaseModel | Mapping[str, Any] | None,
|
|
102
|
+
model_type: type[ModelT],
|
|
103
|
+
extra: dict[str, Any],
|
|
104
|
+
) -> ModelT | None:
|
|
105
|
+
if isinstance(payload, model_type):
|
|
106
|
+
if extra:
|
|
107
|
+
raise TypeError(f"{model_type.__name__} payload cannot be combined with keyword overrides")
|
|
108
|
+
return payload
|
|
109
|
+
if payload is None:
|
|
110
|
+
return model_type.model_validate(extra) if extra else None
|
|
111
|
+
if extra:
|
|
112
|
+
raise TypeError(f"Mapping payload cannot be combined with keyword overrides for {model_type.__name__}")
|
|
113
|
+
return model_type.model_validate(payload)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Audrey:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
120
|
+
*,
|
|
121
|
+
api_key: str | None = None,
|
|
122
|
+
agent: str | None = None,
|
|
123
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
124
|
+
transport: httpx.BaseTransport | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self._client = httpx.Client(
|
|
127
|
+
base_url=base_url.rstrip("/"),
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
transport=transport,
|
|
130
|
+
headers=_build_headers(api_key, agent),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
self._client.close()
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> Audrey:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
def health(self) -> HealthResponse:
|
|
143
|
+
return _validate(HealthResponse, _decode_json(self._client.get("/health")))
|
|
144
|
+
|
|
145
|
+
def status(self) -> StatusResponse:
|
|
146
|
+
return _validate(StatusResponse, _decode_json(self._client.get("/v1/status")))
|
|
147
|
+
|
|
148
|
+
def impact(self, *, window_days: int = 7, limit: int = 5) -> dict[str, Any]:
|
|
149
|
+
"""Closed-loop visibility report: validations, decay, promotions over a window.
|
|
150
|
+
|
|
151
|
+
Mirrors `audrey impact` and `Audrey.impact()` on the TypeScript side.
|
|
152
|
+
"""
|
|
153
|
+
return _decode_json(
|
|
154
|
+
self._client.get(
|
|
155
|
+
"/v1/impact",
|
|
156
|
+
params={"windowDays": window_days, "limit": limit},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def analytics(self) -> dict[str, Any]:
|
|
161
|
+
# analytics() is kept as an alias of impact() for callers that already
|
|
162
|
+
# adopted the older spelling. New code should call impact() directly.
|
|
163
|
+
return self.impact()
|
|
164
|
+
|
|
165
|
+
def encode(self, payload: EncodeRequest | Mapping[str, Any] | str, /, **kwargs: Any) -> str:
|
|
166
|
+
request = _build_model_payload(payload, EncodeRequest, "content", kwargs)
|
|
167
|
+
data = _decode_json(self._client.post("/v1/encode", json=_dump_payload(request)))
|
|
168
|
+
return _validate(EncodeResponse, data).id
|
|
169
|
+
|
|
170
|
+
def recall(self, payload: RecallRequest | Mapping[str, Any] | str, /, **kwargs: Any):
|
|
171
|
+
request = _build_model_payload(payload, RecallRequest, "query", kwargs)
|
|
172
|
+
data = _decode_json(self._client.post("/v1/recall", json=_dump_payload(request)))
|
|
173
|
+
if isinstance(data, dict) and isinstance(data.get("results"), list):
|
|
174
|
+
data = data["results"]
|
|
175
|
+
elif not isinstance(data, list):
|
|
176
|
+
raise TypeError(f"unexpected /v1/recall payload shape: {type(data).__name__}")
|
|
177
|
+
return [_validate(RecallResult, row) for row in data]
|
|
178
|
+
|
|
179
|
+
def recall_response(self, payload: RecallRequest | Mapping[str, Any] | str, /, **kwargs: Any) -> RecallResponse:
|
|
180
|
+
request = _build_model_payload(payload, RecallRequest, "query", kwargs)
|
|
181
|
+
data = _decode_json(self._client.post("/v1/recall", json=_dump_payload(request)))
|
|
182
|
+
if isinstance(data, list):
|
|
183
|
+
return RecallResponse(results=[_validate(RecallResult, row) for row in data])
|
|
184
|
+
if isinstance(data, dict):
|
|
185
|
+
return _validate(RecallResponse, data)
|
|
186
|
+
raise TypeError(f"unexpected /v1/recall payload shape: {type(data).__name__}")
|
|
187
|
+
|
|
188
|
+
def dream(self, payload: DreamRequest | Mapping[str, Any] | None = None, /, **kwargs: Any) -> OperationResult:
|
|
189
|
+
request = _optional_model_payload(payload, DreamRequest, kwargs)
|
|
190
|
+
data = _decode_json(self._client.post("/v1/dream", json=_dump_payload(request)))
|
|
191
|
+
return _validate(OperationResult, data)
|
|
192
|
+
|
|
193
|
+
def consolidate(
|
|
194
|
+
self,
|
|
195
|
+
payload: ConsolidateRequest | Mapping[str, Any] | None = None,
|
|
196
|
+
/,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> OperationResult:
|
|
199
|
+
request = _optional_model_payload(payload, ConsolidateRequest, kwargs)
|
|
200
|
+
data = _decode_json(self._client.post("/v1/consolidate", json=_dump_payload(request)))
|
|
201
|
+
return _validate(OperationResult, data)
|
|
202
|
+
|
|
203
|
+
def mark_used(self, memory_id: str) -> AckResponse:
|
|
204
|
+
request = MarkUsedRequest(id=memory_id)
|
|
205
|
+
data = _decode_json(self._client.post("/v1/mark-used", json=_dump_payload(request)))
|
|
206
|
+
return _validate(AckResponse, data)
|
|
207
|
+
|
|
208
|
+
def validate(self, memory_id: str, outcome: str = "used") -> dict[str, Any]:
|
|
209
|
+
"""Closed-loop feedback. outcome is one of {"used","helpful","wrong"}.
|
|
210
|
+
|
|
211
|
+
"helpful" reinforces salience and retrieval. "wrong" decreases
|
|
212
|
+
salience and bumps challenge_count for semantic memories. "used"
|
|
213
|
+
is a neutral signal that the memory was referenced.
|
|
214
|
+
"""
|
|
215
|
+
if outcome not in ("used", "helpful", "wrong"):
|
|
216
|
+
raise ValueError(f"outcome must be used|helpful|wrong, got {outcome!r}")
|
|
217
|
+
return _decode_json(self._client.post("/v1/validate", json={"id": memory_id, "outcome": outcome}))
|
|
218
|
+
|
|
219
|
+
def forget(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
id: str | None = None,
|
|
223
|
+
query: str | None = None,
|
|
224
|
+
purge: bool | None = None,
|
|
225
|
+
min_similarity: float | None = None,
|
|
226
|
+
) -> ForgetResponse | None:
|
|
227
|
+
request = ForgetRequest(
|
|
228
|
+
id=id,
|
|
229
|
+
query=query,
|
|
230
|
+
purge=purge,
|
|
231
|
+
minSimilarity=min_similarity,
|
|
232
|
+
)
|
|
233
|
+
data = _decode_json(self._client.post("/v1/forget", json=_dump_payload(request)))
|
|
234
|
+
if data is None:
|
|
235
|
+
return None
|
|
236
|
+
return _validate(ForgetResponse, data)
|
|
237
|
+
|
|
238
|
+
def snapshot(self) -> MemorySnapshot:
|
|
239
|
+
# Server exposes snapshot as GET /v1/export.
|
|
240
|
+
data = _decode_json(self._client.get("/v1/export"))
|
|
241
|
+
return _validate(MemorySnapshot, data)
|
|
242
|
+
|
|
243
|
+
def restore(self, snapshot: MemorySnapshot | Mapping[str, Any]) -> RestoreResponse:
|
|
244
|
+
# Server exposes restore as POST /v1/import. The TS handler reads
|
|
245
|
+
# body.snapshot (not the body root), so wrap the payload accordingly.
|
|
246
|
+
request = snapshot if isinstance(snapshot, MemorySnapshot) else MemorySnapshot.model_validate(snapshot)
|
|
247
|
+
data = _decode_json(self._client.post("/v1/import", json={"snapshot": _dump_payload(request)}))
|
|
248
|
+
return _validate(RestoreResponse, data)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class AsyncAudrey:
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
255
|
+
*,
|
|
256
|
+
api_key: str | None = None,
|
|
257
|
+
agent: str | None = None,
|
|
258
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
259
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
self._client = httpx.AsyncClient(
|
|
262
|
+
base_url=base_url.rstrip("/"),
|
|
263
|
+
timeout=timeout,
|
|
264
|
+
transport=transport,
|
|
265
|
+
headers=_build_headers(api_key, agent),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
await self._client.aclose()
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> AsyncAudrey:
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
275
|
+
await self.aclose()
|
|
276
|
+
|
|
277
|
+
async def health(self) -> HealthResponse:
|
|
278
|
+
return _validate(HealthResponse, _decode_json(await self._client.get("/health")))
|
|
279
|
+
|
|
280
|
+
async def status(self) -> StatusResponse:
|
|
281
|
+
return _validate(StatusResponse, _decode_json(await self._client.get("/v1/status")))
|
|
282
|
+
|
|
283
|
+
async def impact(self, *, window_days: int = 7, limit: int = 5) -> dict[str, Any]:
|
|
284
|
+
"""Closed-loop visibility report — async counterpart of `Audrey.impact`."""
|
|
285
|
+
response = await self._client.get(
|
|
286
|
+
"/v1/impact",
|
|
287
|
+
params={"windowDays": window_days, "limit": limit},
|
|
288
|
+
)
|
|
289
|
+
return _decode_json(response)
|
|
290
|
+
|
|
291
|
+
async def analytics(self) -> dict[str, Any]:
|
|
292
|
+
return await self.impact()
|
|
293
|
+
|
|
294
|
+
async def encode(self, payload: EncodeRequest | Mapping[str, Any] | str, /, **kwargs: Any) -> str:
|
|
295
|
+
request = _build_model_payload(payload, EncodeRequest, "content", kwargs)
|
|
296
|
+
data = _decode_json(await self._client.post("/v1/encode", json=_dump_payload(request)))
|
|
297
|
+
return _validate(EncodeResponse, data).id
|
|
298
|
+
|
|
299
|
+
async def recall(self, payload: RecallRequest | Mapping[str, Any] | str, /, **kwargs: Any):
|
|
300
|
+
request = _build_model_payload(payload, RecallRequest, "query", kwargs)
|
|
301
|
+
data = _decode_json(await self._client.post("/v1/recall", json=_dump_payload(request)))
|
|
302
|
+
if isinstance(data, dict) and isinstance(data.get("results"), list):
|
|
303
|
+
data = data["results"]
|
|
304
|
+
elif not isinstance(data, list):
|
|
305
|
+
raise TypeError(f"unexpected /v1/recall payload shape: {type(data).__name__}")
|
|
306
|
+
return [_validate(RecallResult, row) for row in data]
|
|
307
|
+
|
|
308
|
+
async def recall_response(self, payload: RecallRequest | Mapping[str, Any] | str, /, **kwargs: Any) -> RecallResponse:
|
|
309
|
+
request = _build_model_payload(payload, RecallRequest, "query", kwargs)
|
|
310
|
+
data = _decode_json(await self._client.post("/v1/recall", json=_dump_payload(request)))
|
|
311
|
+
if isinstance(data, list):
|
|
312
|
+
return RecallResponse(results=[_validate(RecallResult, row) for row in data])
|
|
313
|
+
if isinstance(data, dict):
|
|
314
|
+
return _validate(RecallResponse, data)
|
|
315
|
+
raise TypeError(f"unexpected /v1/recall payload shape: {type(data).__name__}")
|
|
316
|
+
|
|
317
|
+
async def dream(self, payload: DreamRequest | Mapping[str, Any] | None = None, /, **kwargs: Any) -> OperationResult:
|
|
318
|
+
request = _optional_model_payload(payload, DreamRequest, kwargs)
|
|
319
|
+
data = _decode_json(await self._client.post("/v1/dream", json=_dump_payload(request)))
|
|
320
|
+
return _validate(OperationResult, data)
|
|
321
|
+
|
|
322
|
+
async def consolidate(
|
|
323
|
+
self,
|
|
324
|
+
payload: ConsolidateRequest | Mapping[str, Any] | None = None,
|
|
325
|
+
/,
|
|
326
|
+
**kwargs: Any,
|
|
327
|
+
) -> OperationResult:
|
|
328
|
+
request = _optional_model_payload(payload, ConsolidateRequest, kwargs)
|
|
329
|
+
data = _decode_json(await self._client.post("/v1/consolidate", json=_dump_payload(request)))
|
|
330
|
+
return _validate(OperationResult, data)
|
|
331
|
+
|
|
332
|
+
async def mark_used(self, memory_id: str) -> AckResponse:
|
|
333
|
+
request = MarkUsedRequest(id=memory_id)
|
|
334
|
+
data = _decode_json(await self._client.post("/v1/mark-used", json=_dump_payload(request)))
|
|
335
|
+
return _validate(AckResponse, data)
|
|
336
|
+
|
|
337
|
+
async def validate(self, memory_id: str, outcome: str = "used") -> dict[str, Any]:
|
|
338
|
+
"""Closed-loop feedback. See sync validate()."""
|
|
339
|
+
if outcome not in ("used", "helpful", "wrong"):
|
|
340
|
+
raise ValueError(f"outcome must be used|helpful|wrong, got {outcome!r}")
|
|
341
|
+
return _decode_json(await self._client.post("/v1/validate", json={"id": memory_id, "outcome": outcome}))
|
|
342
|
+
|
|
343
|
+
async def forget(
|
|
344
|
+
self,
|
|
345
|
+
*,
|
|
346
|
+
id: str | None = None,
|
|
347
|
+
query: str | None = None,
|
|
348
|
+
purge: bool | None = None,
|
|
349
|
+
min_similarity: float | None = None,
|
|
350
|
+
) -> ForgetResponse | None:
|
|
351
|
+
request = ForgetRequest(
|
|
352
|
+
id=id,
|
|
353
|
+
query=query,
|
|
354
|
+
purge=purge,
|
|
355
|
+
minSimilarity=min_similarity,
|
|
356
|
+
)
|
|
357
|
+
data = _decode_json(await self._client.post("/v1/forget", json=_dump_payload(request)))
|
|
358
|
+
if data is None:
|
|
359
|
+
return None
|
|
360
|
+
return _validate(ForgetResponse, data)
|
|
361
|
+
|
|
362
|
+
async def snapshot(self) -> MemorySnapshot:
|
|
363
|
+
# Server exposes snapshot as GET /v1/export.
|
|
364
|
+
data = _decode_json(await self._client.get("/v1/export"))
|
|
365
|
+
return _validate(MemorySnapshot, data)
|
|
366
|
+
|
|
367
|
+
async def restore(self, snapshot: MemorySnapshot | Mapping[str, Any]) -> RestoreResponse:
|
|
368
|
+
# Server exposes restore as POST /v1/import. The TS handler reads
|
|
369
|
+
# body.snapshot (not the body root), so wrap the payload accordingly.
|
|
370
|
+
request = snapshot if isinstance(snapshot, MemorySnapshot) else MemorySnapshot.model_validate(snapshot)
|
|
371
|
+
data = _decode_json(await self._client.post("/v1/import", json={"snapshot": _dump_payload(request)}))
|
|
372
|
+
return _validate(RestoreResponse, data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AudreyModel(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Affect(AudreyModel):
|
|
13
|
+
valence: float
|
|
14
|
+
arousal: float | None = None
|
|
15
|
+
label: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HealthResponse(AudreyModel):
|
|
19
|
+
ok: bool
|
|
20
|
+
version: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContradictionStatus(AudreyModel):
|
|
24
|
+
open: int = 0
|
|
25
|
+
resolved: int = 0
|
|
26
|
+
context_dependent: int = 0
|
|
27
|
+
reopened: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StatusResponse(AudreyModel):
|
|
31
|
+
episodic: int | None = None
|
|
32
|
+
semantic: int | None = None
|
|
33
|
+
procedural: int | None = None
|
|
34
|
+
causalLinks: int | None = None
|
|
35
|
+
contradictions: ContradictionStatus | None = None
|
|
36
|
+
dormant: int | None = None
|
|
37
|
+
lastConsolidation: str | None = None
|
|
38
|
+
totalConsolidationRuns: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AnalyticsRow(AudreyModel):
|
|
42
|
+
id: str | None = None
|
|
43
|
+
content: str | None = None
|
|
44
|
+
agent: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AnalyticsResponse(AudreyModel):
|
|
48
|
+
topEpisodes: list[AnalyticsRow] = Field(default_factory=list)
|
|
49
|
+
topSemantics: list[AnalyticsRow] = Field(default_factory=list)
|
|
50
|
+
recentRuns: list[AnalyticsRow] = Field(default_factory=list)
|
|
51
|
+
metrics: list[AnalyticsRow] = Field(default_factory=list)
|
|
52
|
+
agents: list[AnalyticsRow] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EncodeRequest(AudreyModel):
|
|
56
|
+
content: str
|
|
57
|
+
source: str
|
|
58
|
+
salience: float | None = Field(default=None, ge=0, le=1)
|
|
59
|
+
tags: list[str] | None = None
|
|
60
|
+
context: dict[str, Any] | None = None
|
|
61
|
+
affect: Affect | None = None
|
|
62
|
+
causal: dict[str, Any] | None = None
|
|
63
|
+
supersedes: str | None = None
|
|
64
|
+
private: bool | None = None
|
|
65
|
+
agent: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EncodeResponse(AudreyModel):
|
|
69
|
+
id: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RecallRequest(AudreyModel):
|
|
73
|
+
query: str
|
|
74
|
+
limit: int | None = Field(default=None, ge=1, le=50)
|
|
75
|
+
context: dict[str, Any] | None = None
|
|
76
|
+
mood: dict[str, Any] | None = None
|
|
77
|
+
types: list[str] | None = None
|
|
78
|
+
scope: str | None = None
|
|
79
|
+
includePrivate: bool | None = None
|
|
80
|
+
agent: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RecallResult(AudreyModel):
|
|
84
|
+
id: str
|
|
85
|
+
content: str
|
|
86
|
+
type: str | None = None
|
|
87
|
+
confidence: float | None = None
|
|
88
|
+
score: float | None = None
|
|
89
|
+
source: str | None = None
|
|
90
|
+
createdAt: str | None = None
|
|
91
|
+
agent: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RecallError(AudreyModel):
|
|
95
|
+
type: str | None = None
|
|
96
|
+
stage: str | None = None
|
|
97
|
+
message: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RecallResponse(AudreyModel):
|
|
101
|
+
results: list[RecallResult] = Field(default_factory=list)
|
|
102
|
+
partialFailure: bool = Field(default=False, alias="partial_failure")
|
|
103
|
+
errors: list[RecallError] = Field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DreamRequest(AudreyModel):
|
|
107
|
+
dormantThreshold: float | None = Field(default=None, ge=0, le=1)
|
|
108
|
+
minClusterSize: int | None = Field(default=None, ge=1)
|
|
109
|
+
similarityThreshold: float | None = Field(default=None, ge=0, le=1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ConsolidateRequest(AudreyModel):
|
|
113
|
+
minClusterSize: int | None = Field(default=None, ge=1)
|
|
114
|
+
similarityThreshold: float | None = Field(default=None, ge=0, le=1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class OperationResult(AudreyModel):
|
|
118
|
+
ok: bool | None = None
|
|
119
|
+
status: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class MarkUsedRequest(AudreyModel):
|
|
123
|
+
id: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AckResponse(AudreyModel):
|
|
127
|
+
ok: bool
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ForgetRequest(AudreyModel):
|
|
131
|
+
id: str | None = None
|
|
132
|
+
query: str | None = None
|
|
133
|
+
purge: bool | None = None
|
|
134
|
+
minSimilarity: float | None = Field(default=None, ge=0, le=1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ForgetResponse(AudreyModel):
|
|
138
|
+
id: str | None = None
|
|
139
|
+
type: str | None = None
|
|
140
|
+
purged: bool | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MemorySnapshot(AudreyModel):
|
|
144
|
+
version: str
|
|
145
|
+
exportedAt: str | None = None
|
|
146
|
+
episodes: list[dict[str, Any]] = Field(default_factory=list)
|
|
147
|
+
semantics: list[dict[str, Any]] = Field(default_factory=list)
|
|
148
|
+
procedures: list[dict[str, Any]] = Field(default_factory=list)
|
|
149
|
+
causalLinks: list[dict[str, Any]] = Field(default_factory=list)
|
|
150
|
+
contradictions: list[dict[str, Any]] = Field(default_factory=list)
|
|
151
|
+
consolidationRuns: list[dict[str, Any]] = Field(default_factory=list)
|
|
152
|
+
consolidationMetrics: list[dict[str, Any]] = Field(default_factory=list)
|
|
153
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RestoreResponse(StatusResponse):
|
|
157
|
+
ok: bool
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: audrey-memory
|
|
3
|
+
Version: 0.23.1
|
|
4
|
+
Summary: Typed Python client for the Audrey LLM memory server
|
|
5
|
+
Author: evilander
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Evilander/Audrey
|
|
8
|
+
Project-URL: Repository, https://github.com/Evilander/Audrey
|
|
9
|
+
Project-URL: Issues, https://github.com/Evilander/Audrey/issues
|
|
10
|
+
Keywords: ai,agents,audrey,llm,memory,pydantic,python
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
24
|
+
|
|
25
|
+
# Audrey Python SDK
|
|
26
|
+
|
|
27
|
+
Typed Python client for the Audrey REST API.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install audrey-memory
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development from this repository:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd python
|
|
39
|
+
python -m pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
Start Audrey's REST API:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx audrey serve
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then use the client:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from audrey_memory import Audrey
|
|
54
|
+
|
|
55
|
+
brain = Audrey(
|
|
56
|
+
base_url="http://127.0.0.1:7437",
|
|
57
|
+
api_key="secret",
|
|
58
|
+
agent="support-agent",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
memory_id = brain.encode(
|
|
62
|
+
"Stripe returns HTTP 429 above 100 req/s",
|
|
63
|
+
source="direct-observation",
|
|
64
|
+
tags=["stripe", "rate-limit"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
results = brain.recall("stripe rate limits", limit=5)
|
|
68
|
+
snapshot = brain.snapshot()
|
|
69
|
+
brain.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Restore snapshots only into an empty Audrey store, such as a sidecar started with a fresh `AUDREY_DATA_DIR`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
restore_target = Audrey(base_url="http://127.0.0.1:7437", api_key="secret")
|
|
76
|
+
restore_target.restore(snapshot)
|
|
77
|
+
restore_target.close()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Async usage:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
|
|
85
|
+
from audrey_memory import AsyncAudrey
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def main() -> None:
|
|
89
|
+
async with AsyncAudrey(base_url="http://127.0.0.1:7437") as brain:
|
|
90
|
+
await brain.health()
|
|
91
|
+
await brain.encode("Deploy failed due to OOM", source="direct-observation")
|
|
92
|
+
await brain.recall("deploy failure", limit=3)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Features
|
|
99
|
+
|
|
100
|
+
- Sync and async clients powered by `httpx`
|
|
101
|
+
- Pydantic request and response models
|
|
102
|
+
- Bearer auth via `AUDREY_API_KEY`
|
|
103
|
+
- Optional `X-Audrey-Agent` header on client requests
|
|
104
|
+
- Snapshot export and restore support
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
audrey_memory/__init__.py
|
|
4
|
+
audrey_memory/_version.py
|
|
5
|
+
audrey_memory/client.py
|
|
6
|
+
audrey_memory/py.typed
|
|
7
|
+
audrey_memory/types.py
|
|
8
|
+
audrey_memory.egg-info/PKG-INFO
|
|
9
|
+
audrey_memory.egg-info/SOURCES.txt
|
|
10
|
+
audrey_memory.egg-info/dependency_links.txt
|
|
11
|
+
audrey_memory.egg-info/requires.txt
|
|
12
|
+
audrey_memory.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
audrey_memory
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audrey-memory"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Typed Python client for the Audrey LLM memory server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "evilander" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ai",
|
|
17
|
+
"agents",
|
|
18
|
+
"audrey",
|
|
19
|
+
"llm",
|
|
20
|
+
"memory",
|
|
21
|
+
"pydantic",
|
|
22
|
+
"python",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"httpx>=0.27,<1",
|
|
37
|
+
"pydantic>=2.7,<3",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/Evilander/Audrey"
|
|
42
|
+
Repository = "https://github.com/Evilander/Audrey"
|
|
43
|
+
Issues = "https://github.com/Evilander/Audrey/issues"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.dynamic]
|
|
46
|
+
version = { attr = "audrey_memory._version.__version__" }
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
include-package-data = true
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["."]
|
|
53
|
+
include = ["audrey_memory*"]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
audrey_memory = ["py.typed"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
PYTHON_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
REPO_ROOT = PYTHON_ROOT.parent
|
|
18
|
+
|
|
19
|
+
if str(PYTHON_ROOT) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(PYTHON_ROOT))
|
|
21
|
+
|
|
22
|
+
from audrey_memory import AsyncAudrey, Audrey, AudreyAPIError, MemorySnapshot, __version__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _free_port() -> int:
|
|
26
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
27
|
+
sock.bind(("127.0.0.1", 0))
|
|
28
|
+
return int(sock.getsockname()[1])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AudreyClientUnitTests(unittest.TestCase):
|
|
32
|
+
def test_sync_client_sends_auth_and_agent_headers(self) -> None:
|
|
33
|
+
seen: dict[str, object] = {}
|
|
34
|
+
|
|
35
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
36
|
+
seen["authorization"] = request.headers.get("Authorization")
|
|
37
|
+
seen["agent"] = request.headers.get("X-Audrey-Agent")
|
|
38
|
+
seen["body"] = json.loads(request.content.decode("utf-8"))
|
|
39
|
+
return httpx.Response(201, json={"id": "mem_123"})
|
|
40
|
+
|
|
41
|
+
client = Audrey(
|
|
42
|
+
base_url="http://audrey.test",
|
|
43
|
+
api_key="secret-token",
|
|
44
|
+
agent="python-sdk",
|
|
45
|
+
transport=httpx.MockTransport(handler),
|
|
46
|
+
)
|
|
47
|
+
self.addCleanup(client.close)
|
|
48
|
+
|
|
49
|
+
memory_id = client.encode(
|
|
50
|
+
"Stripe returns HTTP 429 above 100 req/s",
|
|
51
|
+
source="direct-observation",
|
|
52
|
+
tags=["stripe"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.assertEqual(memory_id, "mem_123")
|
|
56
|
+
self.assertEqual(seen["authorization"], "Bearer secret-token")
|
|
57
|
+
self.assertEqual(seen["agent"], "python-sdk")
|
|
58
|
+
self.assertEqual(
|
|
59
|
+
seen["body"],
|
|
60
|
+
{
|
|
61
|
+
"content": "Stripe returns HTTP 429 above 100 req/s",
|
|
62
|
+
"source": "direct-observation",
|
|
63
|
+
"tags": ["stripe"],
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_sync_client_raises_structured_api_error(self) -> None:
|
|
68
|
+
def handler(_: httpx.Request) -> httpx.Response:
|
|
69
|
+
return httpx.Response(400, json={"error": "content is required"})
|
|
70
|
+
|
|
71
|
+
client = Audrey(
|
|
72
|
+
base_url="http://audrey.test",
|
|
73
|
+
transport=httpx.MockTransport(handler),
|
|
74
|
+
)
|
|
75
|
+
self.addCleanup(client.close)
|
|
76
|
+
|
|
77
|
+
with self.assertRaises(AudreyAPIError) as exc:
|
|
78
|
+
client.encode("", source="direct-observation")
|
|
79
|
+
|
|
80
|
+
self.assertEqual(exc.exception.status_code, 400)
|
|
81
|
+
self.assertEqual(str(exc.exception), "content is required")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AudreyAsyncClientUnitTests(unittest.IsolatedAsyncioTestCase):
|
|
85
|
+
async def test_async_client_parses_recall_response(self) -> None:
|
|
86
|
+
# The TS server returns an envelope so degraded-recall diagnostics survive JSON.
|
|
87
|
+
# this test handler returned a {results: [...]} object — that matched
|
|
88
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
89
|
+
payload = json.loads(request.content.decode("utf-8"))
|
|
90
|
+
self.assertEqual(payload["query"], "stripe rate limits")
|
|
91
|
+
self.assertEqual(payload["limit"], 2)
|
|
92
|
+
return httpx.Response(
|
|
93
|
+
200,
|
|
94
|
+
json={
|
|
95
|
+
"results": [
|
|
96
|
+
{
|
|
97
|
+
"id": "mem_1",
|
|
98
|
+
"content": "Stripe returns HTTP 429 above 100 req/s",
|
|
99
|
+
"type": "episodic",
|
|
100
|
+
"confidence": 0.92,
|
|
101
|
+
"score": 0.88,
|
|
102
|
+
"source": "direct-observation",
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"partial_failure": True,
|
|
106
|
+
"errors": [{"type": "fts", "stage": "recall.fts_lookup", "message": "missing FTS table"}],
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
client = AsyncAudrey(
|
|
111
|
+
base_url="http://audrey.test",
|
|
112
|
+
transport=httpx.MockTransport(handler),
|
|
113
|
+
)
|
|
114
|
+
self.addAsyncCleanup(client.aclose)
|
|
115
|
+
|
|
116
|
+
response = await client.recall_response("stripe rate limits", limit=2)
|
|
117
|
+
|
|
118
|
+
self.assertTrue(response.partialFailure)
|
|
119
|
+
self.assertEqual(len(response.results), 1)
|
|
120
|
+
self.assertEqual(response.results[0].id, "mem_1")
|
|
121
|
+
self.assertEqual(response.errors[0].stage, "recall.fts_lookup")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Spins up the real TS REST sidecar via `node dist/mcp-server/index.js serve`
|
|
125
|
+
# and exercises the Python client end-to-end. Requires the build artifacts
|
|
126
|
+
# under dist/, so run `npm run build` before invoking this suite.
|
|
127
|
+
class AudreyClientIntegrationTests(unittest.TestCase):
|
|
128
|
+
@classmethod
|
|
129
|
+
def setUpClass(cls) -> None:
|
|
130
|
+
cls.api_key = "integration-secret"
|
|
131
|
+
cls.port = _free_port()
|
|
132
|
+
cls.base_url = f"http://127.0.0.1:{cls.port}"
|
|
133
|
+
cls.temp_dir = tempfile.TemporaryDirectory(prefix="audrey-python-sdk-")
|
|
134
|
+
env = os.environ.copy()
|
|
135
|
+
env.update(
|
|
136
|
+
{
|
|
137
|
+
"AUDREY_DATA_DIR": cls.temp_dir.name,
|
|
138
|
+
"AUDREY_EMBEDDING_PROVIDER": "mock",
|
|
139
|
+
"AUDREY_LLM_PROVIDER": "mock",
|
|
140
|
+
"AUDREY_API_KEY": cls.api_key,
|
|
141
|
+
# mcp-server/index.ts parses port from env, not argv.
|
|
142
|
+
"AUDREY_PORT": str(cls.port),
|
|
143
|
+
# snapshot()/restore() in the integration test exercise the
|
|
144
|
+
# admin-only /v1/export and /v1/import routes.
|
|
145
|
+
"AUDREY_ENABLE_ADMIN_TOOLS": "1",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
cls.process = subprocess.Popen(
|
|
149
|
+
["node", "dist/mcp-server/index.js", "serve"],
|
|
150
|
+
cwd=REPO_ROOT,
|
|
151
|
+
env=env,
|
|
152
|
+
stdout=subprocess.PIPE,
|
|
153
|
+
stderr=subprocess.STDOUT,
|
|
154
|
+
text=True,
|
|
155
|
+
)
|
|
156
|
+
cls._wait_for_ready()
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def tearDownClass(cls) -> None:
|
|
160
|
+
if hasattr(cls, "process") and cls.process.poll() is None:
|
|
161
|
+
cls.process.terminate()
|
|
162
|
+
try:
|
|
163
|
+
cls.process.wait(timeout=10)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
cls.process.kill()
|
|
166
|
+
cls.process.wait(timeout=10)
|
|
167
|
+
if hasattr(cls, "temp_dir"):
|
|
168
|
+
cls.temp_dir.cleanup()
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def _wait_for_ready(cls) -> None:
|
|
172
|
+
deadline = time.time() + 30
|
|
173
|
+
last_error: Exception | None = None
|
|
174
|
+
while time.time() < deadline:
|
|
175
|
+
if cls.process.poll() is not None:
|
|
176
|
+
output = ""
|
|
177
|
+
if cls.process.stdout is not None:
|
|
178
|
+
output = cls.process.stdout.read()
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"Audrey server exited before becoming ready (code {cls.process.returncode}):\n{output}"
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
response = httpx.get(
|
|
184
|
+
f"{cls.base_url}/health",
|
|
185
|
+
headers={"Authorization": f"Bearer {cls.api_key}"},
|
|
186
|
+
timeout=1.0,
|
|
187
|
+
)
|
|
188
|
+
if response.status_code == 200:
|
|
189
|
+
return
|
|
190
|
+
except Exception as exc: # pragma: no cover - readiness race
|
|
191
|
+
last_error = exc
|
|
192
|
+
time.sleep(0.25)
|
|
193
|
+
raise RuntimeError(f"Timed out waiting for Audrey server readiness: {last_error}")
|
|
194
|
+
|
|
195
|
+
def test_sync_end_to_end_against_real_server(self) -> None:
|
|
196
|
+
with Audrey(
|
|
197
|
+
base_url=self.base_url,
|
|
198
|
+
api_key=self.api_key,
|
|
199
|
+
agent="python-sync-test",
|
|
200
|
+
) as client:
|
|
201
|
+
health = client.health()
|
|
202
|
+
self.assertTrue(health.ok)
|
|
203
|
+
|
|
204
|
+
memory_id = client.encode(
|
|
205
|
+
"Python SDK integration remembers Stripe rate limits",
|
|
206
|
+
source="direct-observation",
|
|
207
|
+
tags=["python", "stripe"],
|
|
208
|
+
)
|
|
209
|
+
self.assertTrue(memory_id)
|
|
210
|
+
|
|
211
|
+
client.mark_used(memory_id)
|
|
212
|
+
|
|
213
|
+
results = client.recall("stripe rate limits", limit=5, scope="agent")
|
|
214
|
+
self.assertGreaterEqual(len(results), 1)
|
|
215
|
+
self.assertIn("Stripe", results[0].content)
|
|
216
|
+
|
|
217
|
+
impact = client.impact(window_days=7, limit=3)
|
|
218
|
+
self.assertIn("validatedTotal", impact)
|
|
219
|
+
self.assertGreaterEqual(impact["validatedTotal"], 1)
|
|
220
|
+
|
|
221
|
+
snapshot = client.snapshot()
|
|
222
|
+
self.assertIsInstance(snapshot, MemorySnapshot)
|
|
223
|
+
self.assertEqual(snapshot.version, __version__)
|
|
224
|
+
# restore() refuses to import into a non-empty store on purpose.
|
|
225
|
+
# The round-trip test would need to spin up a second server with
|
|
226
|
+
# a fresh data dir; we cover the empty-store import path in unit
|
|
227
|
+
# tests on the TS side and don't replicate the harness here.
|
|
228
|
+
with self.assertRaises(Exception):
|
|
229
|
+
client.restore(snapshot)
|
|
230
|
+
|
|
231
|
+
def test_async_end_to_end_against_real_server(self) -> None:
|
|
232
|
+
async def run() -> None:
|
|
233
|
+
async with AsyncAudrey(
|
|
234
|
+
base_url=self.base_url,
|
|
235
|
+
api_key=self.api_key,
|
|
236
|
+
agent="python-async-test",
|
|
237
|
+
) as client:
|
|
238
|
+
health = await client.health()
|
|
239
|
+
self.assertTrue(health.ok)
|
|
240
|
+
memory_id = await client.encode(
|
|
241
|
+
"Async Python SDK remembers deployment failures",
|
|
242
|
+
source="direct-observation",
|
|
243
|
+
)
|
|
244
|
+
self.assertTrue(memory_id)
|
|
245
|
+
results = await client.recall("deployment failures", limit=5, scope="agent")
|
|
246
|
+
self.assertGreaterEqual(len(results), 1)
|
|
247
|
+
|
|
248
|
+
asyncio.run(run())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
unittest.main()
|