elevenlabs-memorysync 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elevenlabs_memorysync/__init__.py +39 -0
- elevenlabs_memorysync/_api.py +291 -0
- elevenlabs_memorysync/_version.py +1 -0
- elevenlabs_memorysync/proxy.py +569 -0
- elevenlabs_memorysync/variables.py +107 -0
- elevenlabs_memorysync/webhook.py +296 -0
- elevenlabs_memorysync-1.0.0.dist-info/METADATA +174 -0
- elevenlabs_memorysync-1.0.0.dist-info/RECORD +9 -0
- elevenlabs_memorysync-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""MemorySync for ElevenLabs Agents.
|
|
2
|
+
|
|
3
|
+
Three integration tiers, one package:
|
|
4
|
+
|
|
5
|
+
* :func:`create_proxy_app` — the flagship: an OpenAI-compatible LLM
|
|
6
|
+
proxy with a hard recall budget, phone-caller identity via prompt
|
|
7
|
+
tags, byte-faithful SSE relay, and idempotent live capture.
|
|
8
|
+
* :func:`create_webhook_app` / :func:`ingest_transcription_event` —
|
|
9
|
+
HMAC-verified post-call transcript capture that converges with the
|
|
10
|
+
proxy's live writes (zero duplicates).
|
|
11
|
+
* :func:`fetch_memory_variables` — session-start context as ElevenLabs
|
|
12
|
+
dynamic variables, for deployments without a proxy.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._api import MemorySyncAPIError, fnv1a64
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .proxy import DEFAULT_MEMORY_HEADER, GUARD_LINE, create_proxy_app
|
|
18
|
+
from .variables import fetch_memory_variables, fetch_memory_variables_sync
|
|
19
|
+
from .webhook import (
|
|
20
|
+
WebhookVerificationError,
|
|
21
|
+
create_webhook_app,
|
|
22
|
+
ingest_transcription_event,
|
|
23
|
+
verify_signature,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"create_proxy_app",
|
|
29
|
+
"create_webhook_app",
|
|
30
|
+
"ingest_transcription_event",
|
|
31
|
+
"verify_signature",
|
|
32
|
+
"WebhookVerificationError",
|
|
33
|
+
"fetch_memory_variables",
|
|
34
|
+
"fetch_memory_variables_sync",
|
|
35
|
+
"MemorySyncAPIError",
|
|
36
|
+
"fnv1a64",
|
|
37
|
+
"DEFAULT_MEMORY_HEADER",
|
|
38
|
+
"GUARD_LINE",
|
|
39
|
+
]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Async client for the MemorySync v1 data plane used by this integration.
|
|
2
|
+
|
|
3
|
+
Conversation turns persist through the *episodic* ingestion path
|
|
4
|
+
(``POST /v1/memory/add_turn``), which stores text verbatim — no fact
|
|
5
|
+
extraction, no low-value-chatter gate, no rewriting. A voice transcript
|
|
6
|
+
must round-trip byte-for-byte; a plane that second-guessed it would
|
|
7
|
+
corrupt the caller's history.
|
|
8
|
+
|
|
9
|
+
Everything here is async-native because both the LLM proxy and the
|
|
10
|
+
webhook receiver run inside an ASGI event loop — a blocking HTTP client
|
|
11
|
+
would stall every concurrent call in flight.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.memorysync.io"
|
|
24
|
+
_USER_AGENT = f"elevenlabs-memorysync/{__version__}"
|
|
25
|
+
|
|
26
|
+
#: Namespace used when the key cannot list projects (see resolve_tenant_id).
|
|
27
|
+
FALLBACK_TENANT = "default"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MemorySyncAPIError(Exception):
|
|
31
|
+
"""A MemorySync call failed. Carries the status code and server detail."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
39
|
+
key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
|
|
40
|
+
if not key or not key.strip():
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"A MemorySync API key is required. Pass api_key=... or set the "
|
|
43
|
+
"MEMORYSYNC_API_KEY environment variable."
|
|
44
|
+
)
|
|
45
|
+
return key.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
49
|
+
url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
|
|
50
|
+
return url.rstrip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fnv1a64(value: str) -> str:
|
|
54
|
+
"""FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
|
|
55
|
+
|
|
56
|
+
Over UTF-16 code units — not code points, not UTF-8 bytes — so the
|
|
57
|
+
output matches the JavaScript adapters character for character.
|
|
58
|
+
Identical seeds across languages mean a turn persisted by a Python
|
|
59
|
+
surface and again by a JS surface converge on one stored row.
|
|
60
|
+
"""
|
|
61
|
+
prime = 0x100000001B3
|
|
62
|
+
mask = 0xFFFFFFFFFFFFFFFF
|
|
63
|
+
h = 0xCBF29CE484222325
|
|
64
|
+
data = value.encode("utf-16-le")
|
|
65
|
+
for i in range(0, len(data), 2):
|
|
66
|
+
unit = data[i] | (data[i + 1] << 8)
|
|
67
|
+
h ^= unit
|
|
68
|
+
h = (h * prime) & mask
|
|
69
|
+
return format(h, "016x")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncV1Api:
|
|
73
|
+
"""Minimal asynchronous v1 client: add_turn, recall, query, list."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
api_key: str,
|
|
79
|
+
base_url: str,
|
|
80
|
+
project_id: Optional[str] = None,
|
|
81
|
+
timeout: float = 30.0,
|
|
82
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._api_key = api_key
|
|
85
|
+
self._base_url = base_url.rstrip("/")
|
|
86
|
+
self._project_id = project_id
|
|
87
|
+
self._timeout = timeout
|
|
88
|
+
self._transport = transport
|
|
89
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
90
|
+
self._tenant_id: Optional[str] = None
|
|
91
|
+
self._tenant_is_fallback = False
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._http.aclose()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def api_key(self) -> str:
|
|
98
|
+
return self._api_key
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def base_url(self) -> str:
|
|
102
|
+
return self._base_url
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def project_id(self) -> Optional[str]:
|
|
106
|
+
return self._project_id
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def timeout(self) -> float:
|
|
110
|
+
return self._timeout
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def transport(self) -> Optional[httpx.AsyncBaseTransport]:
|
|
114
|
+
return self._transport
|
|
115
|
+
|
|
116
|
+
# ── plumbing ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def _headers(self) -> Dict[str, str]:
|
|
119
|
+
h = {
|
|
120
|
+
"X-API-Key": self._api_key,
|
|
121
|
+
"Accept": "application/json",
|
|
122
|
+
"User-Agent": _USER_AGENT,
|
|
123
|
+
}
|
|
124
|
+
if self._project_id:
|
|
125
|
+
h["X-Project-ID"] = self._project_id
|
|
126
|
+
return h
|
|
127
|
+
|
|
128
|
+
async def _request(
|
|
129
|
+
self,
|
|
130
|
+
method: str,
|
|
131
|
+
path: str,
|
|
132
|
+
*,
|
|
133
|
+
json: Optional[Dict[str, Any]] = None,
|
|
134
|
+
params: Optional[Dict[str, Any]] = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
url = f"{self._base_url}{path}"
|
|
137
|
+
try:
|
|
138
|
+
response = await self._http.request(
|
|
139
|
+
method, url, headers=self._headers(), json=json, params=params
|
|
140
|
+
)
|
|
141
|
+
except httpx.TimeoutException as e:
|
|
142
|
+
raise MemorySyncAPIError(f"Request timed out: {e}") from e
|
|
143
|
+
except httpx.HTTPError as e:
|
|
144
|
+
raise MemorySyncAPIError(f"Network error: {e}") from e
|
|
145
|
+
|
|
146
|
+
if response.status_code == 204:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
body: Any = response.json()
|
|
150
|
+
except ValueError:
|
|
151
|
+
body = response.text or None
|
|
152
|
+
if response.status_code >= 400:
|
|
153
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
154
|
+
raise MemorySyncAPIError(
|
|
155
|
+
f"{method} {path} failed with HTTP {response.status_code}: {detail}",
|
|
156
|
+
status_code=response.status_code,
|
|
157
|
+
)
|
|
158
|
+
return body
|
|
159
|
+
|
|
160
|
+
# ── calls ────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async def resolve_tenant_id(self) -> str:
|
|
163
|
+
"""The tenant id, which the v1 routes need in path or body.
|
|
164
|
+
|
|
165
|
+
Derived from the project listing rather than asked for. Cached for
|
|
166
|
+
the lifetime of this client. Keys without the ``projects:read``
|
|
167
|
+
scope (evaluation keys) fall back to the fixed namespace
|
|
168
|
+
``"default"`` — deterministic. Only a definite 401/403 triggers the
|
|
169
|
+
fallback; a transient server error re-raises rather than silently
|
|
170
|
+
switching namespaces.
|
|
171
|
+
"""
|
|
172
|
+
if self._tenant_id:
|
|
173
|
+
return self._tenant_id
|
|
174
|
+
try:
|
|
175
|
+
projects = await self._request("GET", "/org/projects")
|
|
176
|
+
except MemorySyncAPIError as exc:
|
|
177
|
+
if exc.status_code in (401, 403):
|
|
178
|
+
self._tenant_id = FALLBACK_TENANT
|
|
179
|
+
self._tenant_is_fallback = True
|
|
180
|
+
return self._tenant_id
|
|
181
|
+
raise
|
|
182
|
+
first = projects[0] if isinstance(projects, list) and projects else None
|
|
183
|
+
tenant = first.get("tenant_id") if isinstance(first, dict) else None
|
|
184
|
+
if not tenant:
|
|
185
|
+
raise MemorySyncAPIError(
|
|
186
|
+
"Could not determine the tenant for this API key."
|
|
187
|
+
)
|
|
188
|
+
self._tenant_id = str(tenant)
|
|
189
|
+
return self._tenant_id
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def tenant_is_fallback(self) -> bool:
|
|
193
|
+
"""True when the namespace came from the 401/403 fallback."""
|
|
194
|
+
return self._tenant_is_fallback
|
|
195
|
+
|
|
196
|
+
def set_tenant_id(self, tenant_id: str) -> None:
|
|
197
|
+
self._tenant_id = tenant_id
|
|
198
|
+
|
|
199
|
+
async def add_turn(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
tenant_id: str,
|
|
203
|
+
user_id: str,
|
|
204
|
+
text: str,
|
|
205
|
+
speaker: Optional[str] = None,
|
|
206
|
+
occurred_at: Optional[str] = None,
|
|
207
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
208
|
+
source: str = "elevenlabs",
|
|
209
|
+
sync_embed: bool = False,
|
|
210
|
+
) -> Dict[str, Any]:
|
|
211
|
+
"""Store one item verbatim (episodic ingestion).
|
|
212
|
+
|
|
213
|
+
``speaker`` participates in the server's idempotency seed, so
|
|
214
|
+
retrying an identical payload is recognised
|
|
215
|
+
(``already_exists: true``) instead of stored twice. This package
|
|
216
|
+
deliberately does NOT send ``occurred_at``: the proxy and the
|
|
217
|
+
post-call webhook both capture the same utterance at different
|
|
218
|
+
wall-clock moments, and only a time-free seed lets those two
|
|
219
|
+
writes converge on one stored row.
|
|
220
|
+
"""
|
|
221
|
+
body: Dict[str, Any] = {
|
|
222
|
+
"tenant_id": tenant_id,
|
|
223
|
+
"user_id": user_id,
|
|
224
|
+
"source": source,
|
|
225
|
+
"text": text,
|
|
226
|
+
"sync_embed": sync_embed,
|
|
227
|
+
}
|
|
228
|
+
if speaker is not None:
|
|
229
|
+
body["speaker"] = speaker
|
|
230
|
+
if occurred_at is not None:
|
|
231
|
+
body["occurred_at"] = occurred_at
|
|
232
|
+
if metadata is not None:
|
|
233
|
+
body["metadata"] = metadata
|
|
234
|
+
return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
|
|
235
|
+
|
|
236
|
+
async def recall(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
tenant_id: str,
|
|
240
|
+
user_id: str,
|
|
241
|
+
prompt: str,
|
|
242
|
+
k: Optional[int] = None,
|
|
243
|
+
types: Optional[List[str]] = None,
|
|
244
|
+
) -> Dict[str, Any]:
|
|
245
|
+
"""Hierarchical recall: grouped, prompt-ready context block."""
|
|
246
|
+
body: Dict[str, Any] = {
|
|
247
|
+
"tenant_id": tenant_id,
|
|
248
|
+
"user_id": user_id,
|
|
249
|
+
"prompt": prompt,
|
|
250
|
+
}
|
|
251
|
+
if k is not None:
|
|
252
|
+
body["k"] = k
|
|
253
|
+
if types is not None:
|
|
254
|
+
body["types"] = types
|
|
255
|
+
return await self._request("POST", "/v1/memory/recall", json=body) or {}
|
|
256
|
+
|
|
257
|
+
async def query(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
tenant_id: str,
|
|
261
|
+
user_id: str,
|
|
262
|
+
prompt: str,
|
|
263
|
+
k: Optional[int] = None,
|
|
264
|
+
) -> Dict[str, Any]:
|
|
265
|
+
"""Plain semantic search over the pair's memories (episodic included)."""
|
|
266
|
+
body: Dict[str, Any] = {
|
|
267
|
+
"tenant_id": tenant_id,
|
|
268
|
+
"user_id": user_id,
|
|
269
|
+
"prompt": prompt,
|
|
270
|
+
}
|
|
271
|
+
if k is not None:
|
|
272
|
+
body["k"] = k
|
|
273
|
+
return await self._request("POST", "/v1/memory/query", json=body) or {}
|
|
274
|
+
|
|
275
|
+
async def list_memories(
|
|
276
|
+
self,
|
|
277
|
+
*,
|
|
278
|
+
tenant_id: str,
|
|
279
|
+
user_id: str,
|
|
280
|
+
limit: int = 0,
|
|
281
|
+
) -> List[Dict[str, Any]]:
|
|
282
|
+
"""Every memory for the tenant/user pair, newest first."""
|
|
283
|
+
from urllib.parse import quote
|
|
284
|
+
|
|
285
|
+
raw = await self._request(
|
|
286
|
+
"GET",
|
|
287
|
+
f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
|
|
288
|
+
params={"limit": limit},
|
|
289
|
+
)
|
|
290
|
+
memories = raw.get("memories") if isinstance(raw, dict) else None
|
|
291
|
+
return list(memories) if isinstance(memories, list) else []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|