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.
@@ -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,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,2 @@
1
+ httpx<1,>=0.27
2
+ pydantic<3,>=2.7
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()