memcode-sdk 2.3.1__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.
- memcode_sdk/__init__.py +104 -0
- memcode_sdk/_http.py +162 -0
- memcode_sdk/async_client.py +277 -0
- memcode_sdk/async_v2_client.py +147 -0
- memcode_sdk/client.py +576 -0
- memcode_sdk/errors.py +64 -0
- memcode_sdk/py.typed +1 -0
- memcode_sdk/types.py +172 -0
- memcode_sdk/v2_client.py +214 -0
- memcode_sdk/v2_types.py +116 -0
- memcode_sdk-2.3.1.dist-info/METADATA +387 -0
- memcode_sdk-2.3.1.dist-info/RECORD +14 -0
- memcode_sdk-2.3.1.dist-info/WHEEL +5 -0
- memcode_sdk-2.3.1.dist-info/top_level.txt +1 -0
memcode_sdk/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memcode Python SDK — client library for the Memcode long-term memory API.
|
|
3
|
+
|
|
4
|
+
Quickstart::
|
|
5
|
+
|
|
6
|
+
from memcode_sdk import MemcodeClient
|
|
7
|
+
|
|
8
|
+
client = MemcodeClient(api_key="sk-...")
|
|
9
|
+
result = client.ingest(user_query="I love Python", user_id="u1")
|
|
10
|
+
answer = client.retrieve(query="hobbies", user_id="u1")
|
|
11
|
+
client.close()
|
|
12
|
+
|
|
13
|
+
Async::
|
|
14
|
+
|
|
15
|
+
from memcode_sdk import AsyncMemcodeClient
|
|
16
|
+
|
|
17
|
+
async with AsyncMemcodeClient(api_key="sk-...") as client:
|
|
18
|
+
result = await client.ingest(user_query="I love Python", user_id="u1")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .client import MemcodeClient
|
|
22
|
+
from .async_client import AsyncMemcodeClient
|
|
23
|
+
from .v2_client import MemoryV2Client, MemcodeV2Client
|
|
24
|
+
from .async_v2_client import AsyncMemoryV2Client, AsyncMemcodeV2Client
|
|
25
|
+
from .types import (
|
|
26
|
+
DomainResult,
|
|
27
|
+
HealthStatus,
|
|
28
|
+
HybridSearchResult,
|
|
29
|
+
IngestResult,
|
|
30
|
+
OperationDetail,
|
|
31
|
+
PersonalV2IngestResult,
|
|
32
|
+
PersonalV2IngestStatus,
|
|
33
|
+
PersonalV2RetrieveResult,
|
|
34
|
+
RetrieveResult,
|
|
35
|
+
SearchResult,
|
|
36
|
+
SourceRecord,
|
|
37
|
+
WeaverSummary,
|
|
38
|
+
)
|
|
39
|
+
from .errors import (
|
|
40
|
+
MemcodeSDKError,
|
|
41
|
+
AuthenticationError,
|
|
42
|
+
ConnectionError,
|
|
43
|
+
NotReadyError,
|
|
44
|
+
RateLimitError,
|
|
45
|
+
ServerError,
|
|
46
|
+
ValidationError,
|
|
47
|
+
)
|
|
48
|
+
from .v2_types import (
|
|
49
|
+
V2IngestResult,
|
|
50
|
+
V2IngestStatus,
|
|
51
|
+
V2MemorySource,
|
|
52
|
+
V2OperationCounts,
|
|
53
|
+
V2OriginalStorageStatus,
|
|
54
|
+
V2RetrieveResult,
|
|
55
|
+
V2SearchResult,
|
|
56
|
+
V2SourceLineage,
|
|
57
|
+
V2SourceProvenance,
|
|
58
|
+
V2SourceSpace,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__author__ = "Memcode Team"
|
|
62
|
+
__version__ = "2.3.1"
|
|
63
|
+
__license__ = "Apache-2.0"
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Clients
|
|
67
|
+
"MemcodeClient",
|
|
68
|
+
"AsyncMemcodeClient",
|
|
69
|
+
"MemcodeV2Client",
|
|
70
|
+
"MemoryV2Client",
|
|
71
|
+
"AsyncMemcodeV2Client",
|
|
72
|
+
"AsyncMemoryV2Client",
|
|
73
|
+
# Result types
|
|
74
|
+
"DomainResult",
|
|
75
|
+
"HealthStatus",
|
|
76
|
+
"HybridSearchResult",
|
|
77
|
+
"IngestResult",
|
|
78
|
+
"OperationDetail",
|
|
79
|
+
"PersonalV2IngestResult",
|
|
80
|
+
"PersonalV2IngestStatus",
|
|
81
|
+
"PersonalV2RetrieveResult",
|
|
82
|
+
"RetrieveResult",
|
|
83
|
+
"SearchResult",
|
|
84
|
+
"SourceRecord",
|
|
85
|
+
"WeaverSummary",
|
|
86
|
+
"V2IngestResult",
|
|
87
|
+
"V2IngestStatus",
|
|
88
|
+
"V2MemorySource",
|
|
89
|
+
"V2OperationCounts",
|
|
90
|
+
"V2OriginalStorageStatus",
|
|
91
|
+
"V2RetrieveResult",
|
|
92
|
+
"V2SearchResult",
|
|
93
|
+
"V2SourceLineage",
|
|
94
|
+
"V2SourceProvenance",
|
|
95
|
+
"V2SourceSpace",
|
|
96
|
+
# Errors
|
|
97
|
+
"MemcodeSDKError",
|
|
98
|
+
"AuthenticationError",
|
|
99
|
+
"ConnectionError",
|
|
100
|
+
"NotReadyError",
|
|
101
|
+
"RateLimitError",
|
|
102
|
+
"ServerError",
|
|
103
|
+
"ValidationError",
|
|
104
|
+
]
|
memcode_sdk/_http.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP transport layer for the Memcode SDK.
|
|
3
|
+
|
|
4
|
+
Handles request construction, response unpacking, and error mapping
|
|
5
|
+
for both sync (httpx.Client) and async (httpx.AsyncClient) transports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConnectionError,
|
|
18
|
+
NotReadyError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
ServerError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
MemcodeSDKError,
|
|
23
|
+
)
|
|
24
|
+
from .types import APIEnvelope, Status
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("memcode.sdk.http")
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT = 120
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_headers(api_key: Optional[str]) -> Dict[str, str]:
|
|
32
|
+
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
33
|
+
if api_key:
|
|
34
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
35
|
+
return headers
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _map_error(status_code: int, body: Dict[str, Any]) -> MemcodeSDKError:
|
|
39
|
+
"""Convert an API error response into a typed SDK exception."""
|
|
40
|
+
raw_message = body.get("error") or body.get("detail") or body.get("message")
|
|
41
|
+
if isinstance(raw_message, dict):
|
|
42
|
+
raw_message = raw_message.get("message") or raw_message.get("code")
|
|
43
|
+
if isinstance(raw_message, list):
|
|
44
|
+
raw_message = "; ".join(
|
|
45
|
+
str(item.get("msg", item)) if isinstance(item, dict) else str(item)
|
|
46
|
+
for item in raw_message
|
|
47
|
+
)
|
|
48
|
+
msg = str(raw_message or f"HTTP {status_code}")
|
|
49
|
+
request_id = body.get("request_id")
|
|
50
|
+
|
|
51
|
+
if status_code == 401:
|
|
52
|
+
return AuthenticationError(msg, status_code=status_code, request_id=request_id)
|
|
53
|
+
if status_code == 403:
|
|
54
|
+
return AuthenticationError(msg, status_code=status_code, request_id=request_id)
|
|
55
|
+
if status_code in {400, 422}:
|
|
56
|
+
return ValidationError(msg, status_code=status_code, request_id=request_id, details=body)
|
|
57
|
+
if status_code == 429:
|
|
58
|
+
return RateLimitError(
|
|
59
|
+
msg, status_code=status_code, request_id=request_id,
|
|
60
|
+
retry_after=int(body.get("retry_after", 60)),
|
|
61
|
+
)
|
|
62
|
+
if status_code == 503:
|
|
63
|
+
return NotReadyError(msg, status_code=status_code, request_id=request_id)
|
|
64
|
+
if status_code >= 500:
|
|
65
|
+
return ServerError(msg, status_code=status_code, request_id=request_id)
|
|
66
|
+
|
|
67
|
+
return MemcodeSDKError(msg, status_code=status_code, request_id=request_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unpack(response: httpx.Response) -> APIEnvelope:
|
|
71
|
+
"""Parse and validate a raw HTTP response into an ``APIEnvelope``."""
|
|
72
|
+
try:
|
|
73
|
+
body = response.json()
|
|
74
|
+
except Exception:
|
|
75
|
+
if response.status_code >= 400:
|
|
76
|
+
raise _map_error(response.status_code, {"error": response.text[:500]})
|
|
77
|
+
raise MemcodeSDKError(f"Unparseable response (HTTP {response.status_code})")
|
|
78
|
+
|
|
79
|
+
if response.status_code >= 400:
|
|
80
|
+
raise _map_error(response.status_code, body)
|
|
81
|
+
|
|
82
|
+
status_raw = body.get("status", "ok")
|
|
83
|
+
status = Status.ERROR if status_raw == "error" else Status.OK
|
|
84
|
+
|
|
85
|
+
if status == Status.ERROR:
|
|
86
|
+
raise _map_error(response.status_code or 500, body)
|
|
87
|
+
|
|
88
|
+
return APIEnvelope(
|
|
89
|
+
status=status,
|
|
90
|
+
data=body.get("data"),
|
|
91
|
+
error=body.get("error"),
|
|
92
|
+
request_id=body.get("request_id"),
|
|
93
|
+
elapsed_ms=body.get("elapsed_ms"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
98
|
+
# Sync transport
|
|
99
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
|
|
101
|
+
class SyncTransport:
|
|
102
|
+
"""Thin wrapper around ``httpx.Client``."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, base_url: str, api_key: Optional[str], timeout: int) -> None:
|
|
105
|
+
self._client = httpx.Client(
|
|
106
|
+
base_url=base_url.rstrip("/"),
|
|
107
|
+
headers=_build_headers(api_key),
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get(self, path: str, **kwargs) -> APIEnvelope:
|
|
112
|
+
try:
|
|
113
|
+
return _unpack(self._client.get(path, **kwargs))
|
|
114
|
+
except httpx.ConnectError as exc:
|
|
115
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
116
|
+
except httpx.TimeoutException as exc:
|
|
117
|
+
raise ConnectionError(f"Request timed out: {exc}") from exc
|
|
118
|
+
|
|
119
|
+
def post(self, path: str, *, json: Any = None, **kwargs) -> APIEnvelope:
|
|
120
|
+
try:
|
|
121
|
+
return _unpack(self._client.post(path, json=json, **kwargs))
|
|
122
|
+
except httpx.ConnectError as exc:
|
|
123
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
124
|
+
except httpx.TimeoutException as exc:
|
|
125
|
+
raise ConnectionError(f"Request timed out: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
self._client.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
132
|
+
# Async transport
|
|
133
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
134
|
+
|
|
135
|
+
class AsyncTransport:
|
|
136
|
+
"""Thin wrapper around ``httpx.AsyncClient``."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, base_url: str, api_key: Optional[str], timeout: int) -> None:
|
|
139
|
+
self._client = httpx.AsyncClient(
|
|
140
|
+
base_url=base_url.rstrip("/"),
|
|
141
|
+
headers=_build_headers(api_key),
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def get(self, path: str, **kwargs) -> APIEnvelope:
|
|
146
|
+
try:
|
|
147
|
+
return _unpack(await self._client.get(path, **kwargs))
|
|
148
|
+
except httpx.ConnectError as exc:
|
|
149
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
150
|
+
except httpx.TimeoutException as exc:
|
|
151
|
+
raise ConnectionError(f"Request timed out: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
async def post(self, path: str, *, json: Any = None, **kwargs) -> APIEnvelope:
|
|
154
|
+
try:
|
|
155
|
+
return _unpack(await self._client.post(path, json=json, **kwargs))
|
|
156
|
+
except httpx.ConnectError as exc:
|
|
157
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
158
|
+
except httpx.TimeoutException as exc:
|
|
159
|
+
raise ConnectionError(f"Request timed out: {exc}") from exc
|
|
160
|
+
|
|
161
|
+
async def close(self) -> None:
|
|
162
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous Memcode client.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from memcode_sdk import AsyncMemcodeClient
|
|
7
|
+
|
|
8
|
+
async with AsyncMemcodeClient(api_url="http://localhost:8000") as client:
|
|
9
|
+
health = await client.ping()
|
|
10
|
+
result = await client.ingest(
|
|
11
|
+
user_query="I love hiking in the Rockies.",
|
|
12
|
+
user_id="user_42",
|
|
13
|
+
)
|
|
14
|
+
answer = await client.retrieve(query="hobbies", user_id="user_42")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from ._http import DEFAULT_TIMEOUT, AsyncTransport
|
|
23
|
+
from .client import (
|
|
24
|
+
_append_legacy_user_id,
|
|
25
|
+
_optional_idempotency_headers,
|
|
26
|
+
_personal_v2_search_payload,
|
|
27
|
+
_parse_personal_v2_ingest,
|
|
28
|
+
_parse_personal_v2_retrieve,
|
|
29
|
+
_parse_personal_v2_status,
|
|
30
|
+
_parse_hybrid_search,
|
|
31
|
+
_parse_ingest,
|
|
32
|
+
_parse_retrieve,
|
|
33
|
+
_parse_search,
|
|
34
|
+
_personal_v2_ingest_payload,
|
|
35
|
+
_required_personal,
|
|
36
|
+
)
|
|
37
|
+
from .types import (
|
|
38
|
+
HealthStatus,
|
|
39
|
+
HybridSearchResult,
|
|
40
|
+
IngestResult,
|
|
41
|
+
PersonalV2IngestResult,
|
|
42
|
+
PersonalV2IngestStatus,
|
|
43
|
+
PersonalV2RetrieveResult,
|
|
44
|
+
RetrieveResult,
|
|
45
|
+
SearchResult,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncMemcodeClient:
|
|
50
|
+
"""Asynchronous client for compatible v1 and advanced personal v2 APIs."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
api_url: Optional[str] = None,
|
|
55
|
+
api_key: Optional[str] = None,
|
|
56
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._api_url = (
|
|
59
|
+
api_url
|
|
60
|
+
or os.getenv("MEMCODE_API_URL")
|
|
61
|
+
or "http://localhost:8000"
|
|
62
|
+
)
|
|
63
|
+
self._api_key = api_key or os.getenv("MEMCODE_API_KEY") or ""
|
|
64
|
+
self._transport = AsyncTransport(self._api_url, self._api_key, timeout)
|
|
65
|
+
|
|
66
|
+
# ── Health ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async def ping(self) -> HealthStatus:
|
|
69
|
+
try:
|
|
70
|
+
env = await self._transport.get("/health")
|
|
71
|
+
d = env.data or {}
|
|
72
|
+
except Exception:
|
|
73
|
+
return HealthStatus(status="unreachable", pipelines_ready=False)
|
|
74
|
+
|
|
75
|
+
return HealthStatus(
|
|
76
|
+
status=d.get("status", "unknown"),
|
|
77
|
+
pipelines_ready=d.get("pipelines_ready", False),
|
|
78
|
+
version=d.get("version", ""),
|
|
79
|
+
uptime_seconds=d.get("uptime_seconds"),
|
|
80
|
+
error=d.get("error"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def is_ready(self) -> bool:
|
|
84
|
+
return (await self.ping()).pipelines_ready
|
|
85
|
+
|
|
86
|
+
# ── Ingest ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async def ingest(
|
|
89
|
+
self,
|
|
90
|
+
user_query: str,
|
|
91
|
+
user_id: str,
|
|
92
|
+
agent_response: str = "",
|
|
93
|
+
session_datetime: str = "",
|
|
94
|
+
image_url: str = "",
|
|
95
|
+
) -> IngestResult:
|
|
96
|
+
"""Ingest a conversation turn into long-term memory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user_query: The user's message to memorize.
|
|
100
|
+
user_id: Unique user identifier.
|
|
101
|
+
agent_response: Assistant reply (improves summary extraction).
|
|
102
|
+
session_datetime: ISO-8601 datetime for temporal extraction.
|
|
103
|
+
image_url: URL or base64 data-URI of an attached image.
|
|
104
|
+
"""
|
|
105
|
+
payload: Dict[str, Any] = {
|
|
106
|
+
"user_query": user_query,
|
|
107
|
+
"user_id": user_id,
|
|
108
|
+
}
|
|
109
|
+
if agent_response:
|
|
110
|
+
payload["agent_response"] = agent_response
|
|
111
|
+
if session_datetime:
|
|
112
|
+
payload["session_datetime"] = session_datetime
|
|
113
|
+
if image_url:
|
|
114
|
+
payload["image_url"] = image_url
|
|
115
|
+
|
|
116
|
+
env = await self._transport.post("/v1/memory/ingest", json=payload)
|
|
117
|
+
return _parse_ingest(env.data or {}, env.request_id, env.elapsed_ms)
|
|
118
|
+
|
|
119
|
+
async def ingest_v2(
|
|
120
|
+
self,
|
|
121
|
+
user_query: str,
|
|
122
|
+
user_id: Optional[str] = None,
|
|
123
|
+
agent_response: str = "",
|
|
124
|
+
session_datetime: str = "",
|
|
125
|
+
image_url: str = "",
|
|
126
|
+
effort_level: str = "low",
|
|
127
|
+
forget: bool = False,
|
|
128
|
+
idempotency_key: str = "",
|
|
129
|
+
) -> PersonalV2IngestResult:
|
|
130
|
+
payload = _personal_v2_ingest_payload(
|
|
131
|
+
user_query,
|
|
132
|
+
user_id,
|
|
133
|
+
agent_response,
|
|
134
|
+
session_datetime,
|
|
135
|
+
image_url,
|
|
136
|
+
effort_level,
|
|
137
|
+
forget,
|
|
138
|
+
)
|
|
139
|
+
env = await self._transport.post(
|
|
140
|
+
"/v2/memory/ingest",
|
|
141
|
+
json=payload,
|
|
142
|
+
headers=_optional_idempotency_headers(idempotency_key),
|
|
143
|
+
)
|
|
144
|
+
return _parse_personal_v2_ingest(env.data or {}, env.request_id, env.elapsed_ms)
|
|
145
|
+
|
|
146
|
+
async def get_ingest_status_v2(self, job_id: str) -> PersonalV2IngestStatus:
|
|
147
|
+
from urllib.parse import quote
|
|
148
|
+
|
|
149
|
+
normalized = _required_personal("job_id", job_id)
|
|
150
|
+
env = await self._transport.get(
|
|
151
|
+
f"/v2/memory/ingest/{quote(normalized, safe='')}/status"
|
|
152
|
+
)
|
|
153
|
+
return _parse_personal_v2_status(env.data or {}, env.request_id, env.elapsed_ms)
|
|
154
|
+
|
|
155
|
+
# ── Retrieve ───────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async def retrieve(
|
|
158
|
+
self,
|
|
159
|
+
query: str,
|
|
160
|
+
user_id: str,
|
|
161
|
+
top_k: int = 5,
|
|
162
|
+
) -> RetrieveResult:
|
|
163
|
+
"""Answer a question using stored memories.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
query: The question to answer.
|
|
167
|
+
user_id: User identifier.
|
|
168
|
+
top_k: Number of source records to consider.
|
|
169
|
+
"""
|
|
170
|
+
env = await self._transport.post("/v1/memory/retrieve", json={
|
|
171
|
+
"query": query, "user_id": user_id, "top_k": top_k,
|
|
172
|
+
})
|
|
173
|
+
return _parse_retrieve(env.data or {}, env.request_id, env.elapsed_ms)
|
|
174
|
+
|
|
175
|
+
async def retrieve_v2(
|
|
176
|
+
self,
|
|
177
|
+
query: str,
|
|
178
|
+
user_id: Optional[str] = None,
|
|
179
|
+
top_k: int = 5,
|
|
180
|
+
) -> PersonalV2RetrieveResult:
|
|
181
|
+
if isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 50:
|
|
182
|
+
raise ValueError("MemcodeClient: top_k must be an integer between 1 and 50")
|
|
183
|
+
payload: Dict[str, Any] = {
|
|
184
|
+
"query": _required_personal("query", query),
|
|
185
|
+
"top_k": top_k,
|
|
186
|
+
}
|
|
187
|
+
_append_legacy_user_id(payload, user_id)
|
|
188
|
+
env = await self._transport.post("/v2/memory/retrieve", json=payload)
|
|
189
|
+
return _parse_personal_v2_retrieve(env.data or {}, env.request_id, env.elapsed_ms)
|
|
190
|
+
|
|
191
|
+
# ── Search ─────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async def search(
|
|
194
|
+
self,
|
|
195
|
+
query: str,
|
|
196
|
+
user_id: str,
|
|
197
|
+
domains: Optional[List[str]] = None,
|
|
198
|
+
top_k: int = 10,
|
|
199
|
+
) -> SearchResult:
|
|
200
|
+
"""Raw semantic search across memory domains.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
query: Natural-language search query.
|
|
204
|
+
user_id: User identifier.
|
|
205
|
+
domains: Subset of ``["profile", "temporal", "summary"]``.
|
|
206
|
+
top_k: Max results per domain.
|
|
207
|
+
"""
|
|
208
|
+
payload: Dict[str, Any] = {
|
|
209
|
+
"query": query, "user_id": user_id, "top_k": top_k,
|
|
210
|
+
}
|
|
211
|
+
if domains is not None:
|
|
212
|
+
payload["domains"] = domains
|
|
213
|
+
|
|
214
|
+
env = await self._transport.post("/v1/memory/search", json=payload)
|
|
215
|
+
return _parse_search(env.data or {}, env.request_id, env.elapsed_ms)
|
|
216
|
+
|
|
217
|
+
async def hybrid_search(
|
|
218
|
+
self,
|
|
219
|
+
query: str,
|
|
220
|
+
user_id: Optional[str] = None,
|
|
221
|
+
domains: Optional[List[str]] = None,
|
|
222
|
+
memory_top_k: Optional[int] = None,
|
|
223
|
+
original_top_k: Optional[int] = None,
|
|
224
|
+
include_original_chunks: bool = True,
|
|
225
|
+
search_mode: str = "default",
|
|
226
|
+
top_k: Optional[int] = None,
|
|
227
|
+
minimum_score: float = 0.0,
|
|
228
|
+
) -> HybridSearchResult:
|
|
229
|
+
"""Deprecated alias for :meth:`search_v2`."""
|
|
230
|
+
return await self.search_v2(
|
|
231
|
+
query=query,
|
|
232
|
+
user_id=user_id,
|
|
233
|
+
domains=domains,
|
|
234
|
+
memory_top_k=memory_top_k,
|
|
235
|
+
original_top_k=original_top_k,
|
|
236
|
+
include_original_chunks=include_original_chunks,
|
|
237
|
+
search_mode=search_mode,
|
|
238
|
+
top_k=top_k,
|
|
239
|
+
minimum_score=minimum_score,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def search_v2(
|
|
243
|
+
self,
|
|
244
|
+
query: str,
|
|
245
|
+
user_id: Optional[str] = None,
|
|
246
|
+
domains: Optional[List[str]] = None,
|
|
247
|
+
memory_top_k: Optional[int] = None,
|
|
248
|
+
original_top_k: Optional[int] = None,
|
|
249
|
+
include_original_chunks: bool = True,
|
|
250
|
+
search_mode: str = "default",
|
|
251
|
+
top_k: Optional[int] = None,
|
|
252
|
+
minimum_score: float = 0.0,
|
|
253
|
+
) -> HybridSearchResult:
|
|
254
|
+
payload = _personal_v2_search_payload(
|
|
255
|
+
query=query,
|
|
256
|
+
user_id=user_id,
|
|
257
|
+
domains=domains,
|
|
258
|
+
memory_top_k=memory_top_k,
|
|
259
|
+
original_top_k=original_top_k,
|
|
260
|
+
include_original_chunks=include_original_chunks,
|
|
261
|
+
search_mode=search_mode,
|
|
262
|
+
top_k=top_k,
|
|
263
|
+
minimum_score=minimum_score,
|
|
264
|
+
)
|
|
265
|
+
env = await self._transport.post("/v2/memory/search", json=payload)
|
|
266
|
+
return _parse_hybrid_search(env.data or {}, env.request_id, env.elapsed_ms)
|
|
267
|
+
|
|
268
|
+
# ── Lifecycle ──────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
async def close(self) -> None:
|
|
271
|
+
await self._transport.close()
|
|
272
|
+
|
|
273
|
+
async def __aenter__(self) -> "AsyncMemcodeClient":
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(self, *exc) -> None:
|
|
277
|
+
await self.close()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Asynchronous client for tenant-bound Memcode v2 memory operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from ._http import DEFAULT_TIMEOUT, AsyncTransport
|
|
10
|
+
from .v2_client import (
|
|
11
|
+
_parse_ingest,
|
|
12
|
+
_parse_ingest_status,
|
|
13
|
+
_parse_retrieve,
|
|
14
|
+
_parse_search,
|
|
15
|
+
_required,
|
|
16
|
+
_scope,
|
|
17
|
+
_search_mode,
|
|
18
|
+
_top_k,
|
|
19
|
+
)
|
|
20
|
+
from .v2_types import V2IngestResult, V2IngestStatus, V2RetrieveResult, V2SearchResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncMemcodeV2Client:
|
|
24
|
+
"""Asynchronous tenant-bound v2 client."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_url: Optional[str] = None,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
31
|
+
) -> None:
|
|
32
|
+
resolved_url = api_url or os.getenv("MEMCODE_API_URL") or "http://localhost:8000"
|
|
33
|
+
resolved_key = _required("api_key", api_key or os.getenv("MEMCODE_API_KEY") or "")
|
|
34
|
+
self._transport = AsyncTransport(resolved_url, resolved_key, timeout)
|
|
35
|
+
|
|
36
|
+
async def ingest(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
space_id: str,
|
|
40
|
+
content: str,
|
|
41
|
+
idempotency_key: str,
|
|
42
|
+
actor_id: Optional[str] = None,
|
|
43
|
+
title: Optional[str] = None,
|
|
44
|
+
occurred_at: Optional[str] = None,
|
|
45
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
46
|
+
tags: Optional[List[str]] = None,
|
|
47
|
+
) -> V2IngestResult:
|
|
48
|
+
key = _required("idempotency_key", idempotency_key)
|
|
49
|
+
if len(key) > 256:
|
|
50
|
+
raise ValueError("MemcodeV2Client: idempotency_key cannot exceed 256 characters")
|
|
51
|
+
payload: Dict[str, Any] = {
|
|
52
|
+
"space_id": _required("space_id", space_id),
|
|
53
|
+
"content": _required("content", content),
|
|
54
|
+
}
|
|
55
|
+
for name, value in (("actor_id", actor_id), ("title", title), ("occurred_at", occurred_at)):
|
|
56
|
+
if value is not None:
|
|
57
|
+
payload[name] = _required(name, value)
|
|
58
|
+
if metadata is not None:
|
|
59
|
+
payload["metadata"] = metadata
|
|
60
|
+
if tags is not None:
|
|
61
|
+
payload["tags"] = list(dict.fromkeys(_required("tag", tag) for tag in tags))
|
|
62
|
+
env = await self._transport.post(
|
|
63
|
+
"/v2/memory/ingest",
|
|
64
|
+
json=payload,
|
|
65
|
+
headers={"Idempotency-Key": key},
|
|
66
|
+
)
|
|
67
|
+
return _parse_ingest(env.data or {}, env.request_id, env.elapsed_ms)
|
|
68
|
+
|
|
69
|
+
async def get_ingest_status(self, job_id: str) -> V2IngestStatus:
|
|
70
|
+
encoded = quote(_required("job_id", job_id), safe="")
|
|
71
|
+
env = await self._transport.get(f"/v2/memory/ingest/{encoded}")
|
|
72
|
+
return _parse_ingest_status(env.data or {}, env.request_id, env.elapsed_ms)
|
|
73
|
+
|
|
74
|
+
async def search(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
context_space_id: str,
|
|
78
|
+
query: str,
|
|
79
|
+
actor_id: Optional[str] = None,
|
|
80
|
+
scope: str = "inherited",
|
|
81
|
+
search_mode: str = "default",
|
|
82
|
+
top_k: int = 10,
|
|
83
|
+
minimum_score: float = 0.0,
|
|
84
|
+
domains: Optional[List[str]] = None,
|
|
85
|
+
include_original_chunks: Optional[bool] = None,
|
|
86
|
+
original_top_k: Optional[int] = None,
|
|
87
|
+
) -> V2SearchResult:
|
|
88
|
+
if isinstance(minimum_score, bool) or not 0 <= minimum_score <= 1:
|
|
89
|
+
raise ValueError("MemcodeV2Client: minimum_score must be between 0 and 1")
|
|
90
|
+
payload: Dict[str, Any] = {
|
|
91
|
+
"context_space_id": _required("context_space_id", context_space_id),
|
|
92
|
+
"query": _required("query", query),
|
|
93
|
+
"scope": _scope(scope),
|
|
94
|
+
"search_mode": _search_mode(search_mode),
|
|
95
|
+
"top_k": _top_k(top_k),
|
|
96
|
+
"minimum_score": minimum_score,
|
|
97
|
+
}
|
|
98
|
+
if actor_id is not None:
|
|
99
|
+
payload["actor_id"] = _required("actor_id", actor_id)
|
|
100
|
+
if domains is not None:
|
|
101
|
+
if not domains:
|
|
102
|
+
raise ValueError("MemcodeV2Client: domains cannot be empty")
|
|
103
|
+
payload["domains"] = list(
|
|
104
|
+
dict.fromkeys(_required("domain", domain) for domain in domains)
|
|
105
|
+
)
|
|
106
|
+
if include_original_chunks is not None:
|
|
107
|
+
if not isinstance(include_original_chunks, bool):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"MemcodeV2Client: include_original_chunks must be a boolean"
|
|
110
|
+
)
|
|
111
|
+
payload["include_original_chunks"] = include_original_chunks
|
|
112
|
+
if original_top_k is not None:
|
|
113
|
+
payload["original_top_k"] = _top_k(original_top_k)
|
|
114
|
+
env = await self._transport.post("/v2/memory/search", json=payload)
|
|
115
|
+
return _parse_search(env.data or {}, env.request_id, env.elapsed_ms)
|
|
116
|
+
|
|
117
|
+
async def retrieve(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
context_space_id: str,
|
|
121
|
+
query: str,
|
|
122
|
+
actor_id: Optional[str] = None,
|
|
123
|
+
scope: str = "inherited",
|
|
124
|
+
top_k: int = 5,
|
|
125
|
+
) -> V2RetrieveResult:
|
|
126
|
+
payload: Dict[str, Any] = {
|
|
127
|
+
"context_space_id": _required("context_space_id", context_space_id),
|
|
128
|
+
"query": _required("query", query),
|
|
129
|
+
"scope": _scope(scope),
|
|
130
|
+
"top_k": _top_k(top_k),
|
|
131
|
+
}
|
|
132
|
+
if actor_id is not None:
|
|
133
|
+
payload["actor_id"] = _required("actor_id", actor_id)
|
|
134
|
+
env = await self._transport.post("/v2/memory/retrieve", json=payload)
|
|
135
|
+
return _parse_retrieve(env.data or {}, env.request_id, env.elapsed_ms)
|
|
136
|
+
|
|
137
|
+
async def close(self) -> None:
|
|
138
|
+
await self._transport.close()
|
|
139
|
+
|
|
140
|
+
async def __aenter__(self) -> "AsyncMemcodeV2Client":
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
144
|
+
await self.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
AsyncMemoryV2Client = AsyncMemcodeV2Client
|