atomicmemory 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.
- atomicmemory/__init__.py +166 -0
- atomicmemory/_version.py +3 -0
- atomicmemory/client/__init__.py +22 -0
- atomicmemory/client/async_memory_client.py +202 -0
- atomicmemory/client/atomic_memory_client.py +181 -0
- atomicmemory/client/memory_client.py +292 -0
- atomicmemory/core/__init__.py +34 -0
- atomicmemory/core/errors.py +122 -0
- atomicmemory/core/events.py +65 -0
- atomicmemory/core/logging.py +37 -0
- atomicmemory/core/retry.py +124 -0
- atomicmemory/core/validation.py +22 -0
- atomicmemory/embeddings/__init__.py +16 -0
- atomicmemory/embeddings/base.py +39 -0
- atomicmemory/embeddings/sentence_transformers.py +104 -0
- atomicmemory/kv_cache/__init__.py +17 -0
- atomicmemory/kv_cache/adapter.py +50 -0
- atomicmemory/kv_cache/memory_storage.py +98 -0
- atomicmemory/kv_cache/sqlite_storage.py +122 -0
- atomicmemory/memory/__init__.py +82 -0
- atomicmemory/memory/filters.py +68 -0
- atomicmemory/memory/pipeline.py +42 -0
- atomicmemory/memory/provider.py +397 -0
- atomicmemory/memory/registry.py +95 -0
- atomicmemory/memory/service.py +199 -0
- atomicmemory/memory/types.py +398 -0
- atomicmemory/providers/__init__.py +5 -0
- atomicmemory/providers/atomicmemory/__init__.py +43 -0
- atomicmemory/providers/atomicmemory/agents.py +156 -0
- atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
- atomicmemory/providers/atomicmemory/async_provider.py +245 -0
- atomicmemory/providers/atomicmemory/audit.py +74 -0
- atomicmemory/providers/atomicmemory/config.py +38 -0
- atomicmemory/providers/atomicmemory/config_handle.py +123 -0
- atomicmemory/providers/atomicmemory/handle.py +513 -0
- atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
- atomicmemory/providers/atomicmemory/http.py +255 -0
- atomicmemory/providers/atomicmemory/lessons.py +133 -0
- atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
- atomicmemory/providers/atomicmemory/mappers.py +125 -0
- atomicmemory/providers/atomicmemory/path.py +20 -0
- atomicmemory/providers/atomicmemory/provider.py +300 -0
- atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
- atomicmemory/providers/mem0/__init__.py +41 -0
- atomicmemory/providers/mem0/async_provider.py +191 -0
- atomicmemory/providers/mem0/config.py +51 -0
- atomicmemory/providers/mem0/http.py +195 -0
- atomicmemory/providers/mem0/mappers.py +145 -0
- atomicmemory/providers/mem0/provider.py +202 -0
- atomicmemory/py.typed +0 -0
- atomicmemory/search/__init__.py +47 -0
- atomicmemory/search/chunking.py +161 -0
- atomicmemory/search/ranking.py +94 -0
- atomicmemory/search/semantic_search.py +130 -0
- atomicmemory/search/similarity.py +110 -0
- atomicmemory/storage/__init__.py +63 -0
- atomicmemory/storage/_mapping.py +305 -0
- atomicmemory/storage/async_client.py +208 -0
- atomicmemory/storage/client.py +339 -0
- atomicmemory/storage/errors.py +115 -0
- atomicmemory/storage/types.py +305 -0
- atomicmemory/utils/__init__.py +5 -0
- atomicmemory/utils/environment.py +23 -0
- atomicmemory-1.0.0.dist-info/METADATA +146 -0
- atomicmemory-1.0.0.dist-info/RECORD +67 -0
- atomicmemory-1.0.0.dist-info/WHEEL +4 -0
- atomicmemory-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""HTTP transport for the Mem0 provider — provider-tagged sync + async helpers.
|
|
2
|
+
|
|
3
|
+
Port of the Mem0-bound layer in `atomicmemory-sdk/src/memory/mem0-provider/http.ts`
|
|
4
|
+
(which itself wraps the shared `shared/http-client.ts`). Same error-mapping
|
|
5
|
+
policy as the AtomicMemory transport, but errors are tagged with
|
|
6
|
+
``provider="mem0"`` so callers can route them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from atomicmemory.core.errors import NetworkError, ProviderError, RateLimitError
|
|
17
|
+
|
|
18
|
+
_PROVIDER_NAME = "mem0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class HttpOptions:
|
|
23
|
+
api_url: str
|
|
24
|
+
api_key: str | None
|
|
25
|
+
timeout_seconds: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _headers(options: HttpOptions, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
29
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
30
|
+
if options.api_key:
|
|
31
|
+
headers["Authorization"] = f"Bearer {options.api_key}"
|
|
32
|
+
if extra:
|
|
33
|
+
headers.update(extra)
|
|
34
|
+
return headers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
38
|
+
if not value:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return float(value)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _raise_for_status(response: httpx.Response, path: str) -> None:
|
|
47
|
+
if response.status_code == 429:
|
|
48
|
+
raise RateLimitError(
|
|
49
|
+
"Rate limited",
|
|
50
|
+
provider=_PROVIDER_NAME,
|
|
51
|
+
retry_after_seconds=_parse_retry_after(response.headers.get("Retry-After")),
|
|
52
|
+
context={"path": path},
|
|
53
|
+
)
|
|
54
|
+
if response.is_success:
|
|
55
|
+
return
|
|
56
|
+
body_text = response.text
|
|
57
|
+
body_decoded: Any = body_text
|
|
58
|
+
try:
|
|
59
|
+
body_decoded = response.json()
|
|
60
|
+
except (ValueError, httpx.DecodingError):
|
|
61
|
+
body_decoded = body_text
|
|
62
|
+
raise ProviderError(
|
|
63
|
+
f"HTTP {response.status_code}: {body_text or response.reason_phrase}",
|
|
64
|
+
provider=_PROVIDER_NAME,
|
|
65
|
+
status_code=response.status_code,
|
|
66
|
+
response_body=body_decoded,
|
|
67
|
+
context={"path": path},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _request(
|
|
72
|
+
client: httpx.Client,
|
|
73
|
+
options: HttpOptions,
|
|
74
|
+
method: str,
|
|
75
|
+
path: str,
|
|
76
|
+
*,
|
|
77
|
+
json: Any | None = None,
|
|
78
|
+
) -> httpx.Response:
|
|
79
|
+
url = f"{options.api_url}{path}"
|
|
80
|
+
try:
|
|
81
|
+
return client.request(method, url, headers=_headers(options), json=json, timeout=options.timeout_seconds)
|
|
82
|
+
except httpx.TimeoutException as exc:
|
|
83
|
+
raise NetworkError(
|
|
84
|
+
f"Timeout after {options.timeout_seconds}s",
|
|
85
|
+
provider=_PROVIDER_NAME,
|
|
86
|
+
cause=exc,
|
|
87
|
+
context={"path": path, "method": method},
|
|
88
|
+
) from exc
|
|
89
|
+
except httpx.RequestError as exc:
|
|
90
|
+
raise NetworkError(
|
|
91
|
+
f"Transport error: {exc}",
|
|
92
|
+
provider=_PROVIDER_NAME,
|
|
93
|
+
cause=exc,
|
|
94
|
+
context={"path": path, "method": method},
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch_json(
|
|
99
|
+
client: httpx.Client,
|
|
100
|
+
options: HttpOptions,
|
|
101
|
+
path: str,
|
|
102
|
+
*,
|
|
103
|
+
method: str = "GET",
|
|
104
|
+
json: Any | None = None,
|
|
105
|
+
) -> Any:
|
|
106
|
+
response = _request(client, options, method, path, json=json)
|
|
107
|
+
_raise_for_status(response, path)
|
|
108
|
+
return response.json()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fetch_json_or_none(
|
|
112
|
+
client: httpx.Client,
|
|
113
|
+
options: HttpOptions,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
method: str = "GET",
|
|
117
|
+
json: Any | None = None,
|
|
118
|
+
) -> Any | None:
|
|
119
|
+
response = _request(client, options, method, path, json=json)
|
|
120
|
+
if response.status_code == 404:
|
|
121
|
+
return None
|
|
122
|
+
_raise_for_status(response, path)
|
|
123
|
+
return response.json()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def delete_ignore_404(client: httpx.Client, options: HttpOptions, path: str) -> None:
|
|
127
|
+
response = _request(client, options, "DELETE", path)
|
|
128
|
+
if response.status_code == 404:
|
|
129
|
+
return
|
|
130
|
+
_raise_for_status(response, path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---- async ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _arequest(
|
|
137
|
+
client: httpx.AsyncClient,
|
|
138
|
+
options: HttpOptions,
|
|
139
|
+
method: str,
|
|
140
|
+
path: str,
|
|
141
|
+
*,
|
|
142
|
+
json: Any | None = None,
|
|
143
|
+
) -> httpx.Response:
|
|
144
|
+
url = f"{options.api_url}{path}"
|
|
145
|
+
try:
|
|
146
|
+
return await client.request(method, url, headers=_headers(options), json=json, timeout=options.timeout_seconds)
|
|
147
|
+
except httpx.TimeoutException as exc:
|
|
148
|
+
raise NetworkError(
|
|
149
|
+
f"Timeout after {options.timeout_seconds}s",
|
|
150
|
+
provider=_PROVIDER_NAME,
|
|
151
|
+
cause=exc,
|
|
152
|
+
context={"path": path, "method": method},
|
|
153
|
+
) from exc
|
|
154
|
+
except httpx.RequestError as exc:
|
|
155
|
+
raise NetworkError(
|
|
156
|
+
f"Transport error: {exc}",
|
|
157
|
+
provider=_PROVIDER_NAME,
|
|
158
|
+
cause=exc,
|
|
159
|
+
context={"path": path, "method": method},
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def afetch_json(
|
|
164
|
+
client: httpx.AsyncClient,
|
|
165
|
+
options: HttpOptions,
|
|
166
|
+
path: str,
|
|
167
|
+
*,
|
|
168
|
+
method: str = "GET",
|
|
169
|
+
json: Any | None = None,
|
|
170
|
+
) -> Any:
|
|
171
|
+
response = await _arequest(client, options, method, path, json=json)
|
|
172
|
+
_raise_for_status(response, path)
|
|
173
|
+
return response.json()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def afetch_json_or_none(
|
|
177
|
+
client: httpx.AsyncClient,
|
|
178
|
+
options: HttpOptions,
|
|
179
|
+
path: str,
|
|
180
|
+
*,
|
|
181
|
+
method: str = "GET",
|
|
182
|
+
json: Any | None = None,
|
|
183
|
+
) -> Any | None:
|
|
184
|
+
response = await _arequest(client, options, method, path, json=json)
|
|
185
|
+
if response.status_code == 404:
|
|
186
|
+
return None
|
|
187
|
+
_raise_for_status(response, path)
|
|
188
|
+
return response.json()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def adelete_ignore_404(client: httpx.AsyncClient, options: HttpOptions, path: str) -> None:
|
|
192
|
+
response = await _arequest(client, options, "DELETE", path)
|
|
193
|
+
if response.status_code == 404:
|
|
194
|
+
return
|
|
195
|
+
_raise_for_status(response, path)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Wire-format mappers + body builders for the Mem0 provider.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/mem0-provider/mappers.ts`. Pure
|
|
4
|
+
functions — shared by the sync and async providers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from atomicmemory.memory.types import (
|
|
13
|
+
IngestInput,
|
|
14
|
+
IngestResult,
|
|
15
|
+
Memory,
|
|
16
|
+
Scope,
|
|
17
|
+
SearchResult,
|
|
18
|
+
)
|
|
19
|
+
from atomicmemory.providers.mem0.config import Mem0ProviderConfig
|
|
20
|
+
|
|
21
|
+
_MetadataDict = dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unwrap_mem0_array(raw: Any) -> list[_MetadataDict]:
|
|
25
|
+
"""Return a list of memory dicts from either a bare array or ``{results: [...]}``."""
|
|
26
|
+
if isinstance(raw, list):
|
|
27
|
+
return list(raw)
|
|
28
|
+
if isinstance(raw, dict) and "results" in raw:
|
|
29
|
+
results = raw["results"]
|
|
30
|
+
if isinstance(results, list):
|
|
31
|
+
return list(results)
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_iso(value: str | None) -> datetime | None:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
text = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
|
39
|
+
return datetime.fromisoformat(text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_memory_text(raw: dict[str, Any]) -> str:
|
|
43
|
+
"""Pick the memory text from either flat ``memory`` or nested ``data.memory``."""
|
|
44
|
+
if raw.get("memory") is not None:
|
|
45
|
+
return str(raw["memory"])
|
|
46
|
+
data = raw.get("data")
|
|
47
|
+
if isinstance(data, dict) and data.get("memory") is not None:
|
|
48
|
+
return str(data["memory"])
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_memory(raw: dict[str, Any], scope: Scope) -> Memory:
|
|
53
|
+
return Memory(
|
|
54
|
+
id=str(raw["id"]),
|
|
55
|
+
content=_extract_memory_text(raw),
|
|
56
|
+
scope=scope,
|
|
57
|
+
created_at=_parse_iso(raw.get("created_at")) or datetime.now().astimezone(),
|
|
58
|
+
updated_at=_parse_iso(raw.get("updated_at")),
|
|
59
|
+
metadata=raw.get("metadata"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def to_search_result(raw: dict[str, Any], scope: Scope) -> SearchResult:
|
|
64
|
+
raw_score = raw.get("score")
|
|
65
|
+
score: float = float(raw_score) if raw_score is not None else 0.0
|
|
66
|
+
return SearchResult(memory=to_memory(raw, scope), score=score)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_ingest_result(raw_memories: list[dict[str, Any]]) -> IngestResult:
|
|
70
|
+
created: list[str] = []
|
|
71
|
+
updated: list[str] = []
|
|
72
|
+
unchanged: list[str] = []
|
|
73
|
+
for mem in raw_memories:
|
|
74
|
+
event = (mem.get("event") or "ADD").upper()
|
|
75
|
+
if event == "ADD":
|
|
76
|
+
created.append(str(mem.get("id", "")))
|
|
77
|
+
elif event == "UPDATE":
|
|
78
|
+
updated.append(str(mem.get("id", "")))
|
|
79
|
+
elif event in {"NONE", "NOOP"}:
|
|
80
|
+
unchanged.append(str(mem.get("id", "")))
|
|
81
|
+
else:
|
|
82
|
+
created.append(str(mem.get("id", "")))
|
|
83
|
+
return IngestResult(created=created, updated=updated, unchanged=unchanged)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_infer_flag(input: IngestInput, config: Mem0ProviderConfig) -> bool:
|
|
87
|
+
metadata = input.metadata or {}
|
|
88
|
+
metadata_infer = metadata.get("infer") if isinstance(metadata, dict) else None
|
|
89
|
+
if isinstance(metadata_infer, bool):
|
|
90
|
+
return metadata_infer
|
|
91
|
+
return config.default_infer
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_ingest_body(input: IngestInput, user_id: str, config: Mem0ProviderConfig) -> dict[str, Any]:
|
|
95
|
+
"""Compose Mem0's ``POST /v1/memories/`` request body."""
|
|
96
|
+
metadata = input.metadata or {}
|
|
97
|
+
clean_metadata = {k: v for k, v in metadata.items() if k != "infer"} if isinstance(metadata, dict) else {}
|
|
98
|
+
body: dict[str, Any] = {
|
|
99
|
+
"user_id": user_id,
|
|
100
|
+
"infer": resolve_infer_flag(input, config),
|
|
101
|
+
}
|
|
102
|
+
if clean_metadata:
|
|
103
|
+
body["metadata"] = clean_metadata
|
|
104
|
+
if input.mode == "text":
|
|
105
|
+
body["messages"] = [{"role": "user", "content": input.content}]
|
|
106
|
+
elif input.mode == "messages":
|
|
107
|
+
body["messages"] = [{"role": m.role, "content": m.content} for m in input.messages]
|
|
108
|
+
_apply_enterprise_fields(body, config)
|
|
109
|
+
_apply_scope_identifiers(body, input.scope)
|
|
110
|
+
return body
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_search_body(
|
|
114
|
+
query: str,
|
|
115
|
+
scope: Scope,
|
|
116
|
+
config: Mem0ProviderConfig,
|
|
117
|
+
limit: int | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Compose Mem0 v2's ``POST /v2/memories/search/`` body with nested filters."""
|
|
120
|
+
filters: dict[str, Any] = {}
|
|
121
|
+
if scope.user:
|
|
122
|
+
filters["user_id"] = scope.user
|
|
123
|
+
if scope.agent:
|
|
124
|
+
filters["agent_id"] = scope.agent
|
|
125
|
+
if scope.thread:
|
|
126
|
+
filters["run_id"] = scope.thread
|
|
127
|
+
body: dict[str, Any] = {"query": query, "filters": filters}
|
|
128
|
+
if limit is not None:
|
|
129
|
+
body["limit"] = limit
|
|
130
|
+
_apply_enterprise_fields(body, config)
|
|
131
|
+
return body
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_enterprise_fields(body: dict[str, Any], config: Mem0ProviderConfig) -> None:
|
|
135
|
+
if config.org_id:
|
|
136
|
+
body["org_id"] = config.org_id
|
|
137
|
+
if config.project_id:
|
|
138
|
+
body["project_id"] = config.project_id
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _apply_scope_identifiers(body: dict[str, Any], scope: Scope) -> None:
|
|
142
|
+
if scope.agent:
|
|
143
|
+
body["agent_id"] = scope.agent
|
|
144
|
+
if scope.thread:
|
|
145
|
+
body["run_id"] = scope.thread
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Sync Mem0Provider — V3 core + Health.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/mem0-provider/mem0-provider.ts`.
|
|
4
|
+
Mem0's ``/memories`` endpoint always runs server-side extraction, so
|
|
5
|
+
``verbatim`` ingest is rejected — capabilities advertise only
|
|
6
|
+
``text`` + ``messages`` modes, and ``do_ingest`` raises
|
|
7
|
+
``ProviderError("Unsupported")`` if a verbatim input slips through.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from atomicmemory.core.errors import ProviderError
|
|
19
|
+
from atomicmemory.memory.provider import BaseMemoryProvider
|
|
20
|
+
from atomicmemory.memory.types import (
|
|
21
|
+
Capabilities,
|
|
22
|
+
CapabilitiesExtensions,
|
|
23
|
+
CapabilitiesRequiredScope,
|
|
24
|
+
HealthStatus,
|
|
25
|
+
IngestInput,
|
|
26
|
+
IngestResult,
|
|
27
|
+
ListRequest,
|
|
28
|
+
ListResultPage,
|
|
29
|
+
Memory,
|
|
30
|
+
MemoryRef,
|
|
31
|
+
SearchRequest,
|
|
32
|
+
SearchResultPage,
|
|
33
|
+
)
|
|
34
|
+
from atomicmemory.providers.mem0.config import Mem0ProviderConfig
|
|
35
|
+
from atomicmemory.providers.mem0.http import (
|
|
36
|
+
HttpOptions,
|
|
37
|
+
delete_ignore_404,
|
|
38
|
+
fetch_json,
|
|
39
|
+
fetch_json_or_none,
|
|
40
|
+
)
|
|
41
|
+
from atomicmemory.providers.mem0.mappers import (
|
|
42
|
+
build_ingest_body,
|
|
43
|
+
build_search_body,
|
|
44
|
+
resolve_infer_flag,
|
|
45
|
+
to_ingest_result,
|
|
46
|
+
to_memory,
|
|
47
|
+
to_search_result,
|
|
48
|
+
unwrap_mem0_array,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_logger = logging.getLogger("atomicmemory.providers.mem0")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Mem0Provider(BaseMemoryProvider):
|
|
55
|
+
"""Sync HTTP-backed V3 provider for Mem0 (OSS + hosted)."""
|
|
56
|
+
|
|
57
|
+
name = "mem0"
|
|
58
|
+
|
|
59
|
+
def __init__(self, config: Mem0ProviderConfig) -> None:
|
|
60
|
+
self._config = config
|
|
61
|
+
self._http_options = HttpOptions(
|
|
62
|
+
api_url=config.api_url.rstrip("/"),
|
|
63
|
+
api_key=config.api_key,
|
|
64
|
+
timeout_seconds=config.timeout_seconds,
|
|
65
|
+
)
|
|
66
|
+
self._prefix = config.path_prefix
|
|
67
|
+
self._client: httpx.Client | None = None
|
|
68
|
+
self._initialized = False
|
|
69
|
+
|
|
70
|
+
def initialize(self) -> None:
|
|
71
|
+
if self._client is None:
|
|
72
|
+
self._client = httpx.Client()
|
|
73
|
+
self._initialized = True
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
if self._client is not None:
|
|
77
|
+
self._client.close()
|
|
78
|
+
self._client = None
|
|
79
|
+
self._initialized = False
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# V3 core methods
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def do_ingest(self, input: IngestInput) -> IngestResult:
|
|
86
|
+
if input.mode == "verbatim":
|
|
87
|
+
raise ProviderError(
|
|
88
|
+
"mem0 does not support verbatim ingest; use the atomicmemory provider for "
|
|
89
|
+
"deterministic one-input-equals-one-memory storage.",
|
|
90
|
+
provider=self.name,
|
|
91
|
+
context={"operation": "ingest", "mode": "verbatim"},
|
|
92
|
+
)
|
|
93
|
+
user_id = input.scope.user or ""
|
|
94
|
+
body = build_ingest_body(input, user_id, self._config)
|
|
95
|
+
should_defer = self._config.defer_inference and resolve_infer_flag(input, self._config)
|
|
96
|
+
if should_defer:
|
|
97
|
+
body["infer"] = False
|
|
98
|
+
raw = fetch_json(
|
|
99
|
+
self._require_client(),
|
|
100
|
+
self._http_options,
|
|
101
|
+
self._path("/memories/"),
|
|
102
|
+
method="POST",
|
|
103
|
+
json=body,
|
|
104
|
+
)
|
|
105
|
+
memories = unwrap_mem0_array(raw)
|
|
106
|
+
if should_defer:
|
|
107
|
+
self._fire_background_inference(body)
|
|
108
|
+
return to_ingest_result(memories)
|
|
109
|
+
|
|
110
|
+
def do_search(self, request: SearchRequest) -> SearchResultPage:
|
|
111
|
+
body = build_search_body(request.query, request.scope, self._config, request.limit)
|
|
112
|
+
raw = fetch_json(
|
|
113
|
+
self._require_client(),
|
|
114
|
+
self._http_options,
|
|
115
|
+
self._search_path(),
|
|
116
|
+
method="POST",
|
|
117
|
+
json=body,
|
|
118
|
+
)
|
|
119
|
+
results = [to_search_result(m, request.scope) for m in unwrap_mem0_array(raw)]
|
|
120
|
+
return SearchResultPage(results=results)
|
|
121
|
+
|
|
122
|
+
def do_get(self, ref: MemoryRef) -> Memory | None:
|
|
123
|
+
raw = fetch_json_or_none(self._require_client(), self._http_options, self._path(f"/memories/{ref.id}/"))
|
|
124
|
+
if raw is None:
|
|
125
|
+
return None
|
|
126
|
+
return to_memory(raw, ref.scope)
|
|
127
|
+
|
|
128
|
+
def do_delete(self, ref: MemoryRef) -> None:
|
|
129
|
+
delete_ignore_404(self._require_client(), self._http_options, self._path(f"/memories/{ref.id}/"))
|
|
130
|
+
|
|
131
|
+
def do_list(self, request: ListRequest) -> ListResultPage:
|
|
132
|
+
limit = request.limit if request.limit is not None else 20
|
|
133
|
+
offset = int(request.cursor) if request.cursor else 0
|
|
134
|
+
page = (offset // limit) + 1 if offset > 0 else None
|
|
135
|
+
path = self._path(f"/memories/?user_id={request.scope.user or ''}&page_size={limit}")
|
|
136
|
+
if page is not None:
|
|
137
|
+
path += f"&page={page}"
|
|
138
|
+
raw = fetch_json(self._require_client(), self._http_options, path)
|
|
139
|
+
memories = [to_memory(m, request.scope) for m in unwrap_mem0_array(raw)]
|
|
140
|
+
next_offset = offset + len(memories)
|
|
141
|
+
cursor = str(next_offset) if len(memories) == limit else None
|
|
142
|
+
return ListResultPage(memories=memories, cursor=cursor)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Capabilities + extensions
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def capabilities(self) -> Capabilities:
|
|
149
|
+
return Capabilities(
|
|
150
|
+
ingest_modes=["text", "messages"],
|
|
151
|
+
required_scope=CapabilitiesRequiredScope(default=["user"]),
|
|
152
|
+
extensions=CapabilitiesExtensions(health=True),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def get_extension(self, name: str) -> Any | None:
|
|
156
|
+
if name == "health":
|
|
157
|
+
return self
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def health(self) -> HealthStatus:
|
|
161
|
+
start = time.monotonic()
|
|
162
|
+
try:
|
|
163
|
+
fetch_json(
|
|
164
|
+
self._require_client(),
|
|
165
|
+
self._http_options,
|
|
166
|
+
self._path("/memories/?user_id=health-check&page_size=1"),
|
|
167
|
+
)
|
|
168
|
+
return HealthStatus(ok=True, latency_ms=(time.monotonic() - start) * 1000.0)
|
|
169
|
+
except (ProviderError, ValueError):
|
|
170
|
+
return HealthStatus(ok=False, latency_ms=(time.monotonic() - start) * 1000.0)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Internals
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _path(self, endpoint: str) -> str:
|
|
177
|
+
return f"{self._prefix}{endpoint}"
|
|
178
|
+
|
|
179
|
+
def _search_path(self) -> str:
|
|
180
|
+
# mem0 2.0 split search out of the v1 family.
|
|
181
|
+
return "/memories/search/" if self._prefix == "" else "/v2/memories/search/"
|
|
182
|
+
|
|
183
|
+
def _require_client(self) -> httpx.Client:
|
|
184
|
+
if self._client is None:
|
|
185
|
+
raise ProviderError(
|
|
186
|
+
"Mem0Provider is not initialized. Call initialize() first.",
|
|
187
|
+
provider=self.name,
|
|
188
|
+
)
|
|
189
|
+
return self._client
|
|
190
|
+
|
|
191
|
+
def _fire_background_inference(self, body: dict[str, Any]) -> None:
|
|
192
|
+
"""Best-effort re-ingest with infer=true. Errors are logged, never raised."""
|
|
193
|
+
try:
|
|
194
|
+
fetch_json(
|
|
195
|
+
self._require_client(),
|
|
196
|
+
self._http_options,
|
|
197
|
+
self._path("/memories/"),
|
|
198
|
+
method="POST",
|
|
199
|
+
json={**body, "infer": True},
|
|
200
|
+
)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
_logger.warning("[mem0] deferred AUDN failed: %s", exc)
|
atomicmemory/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Local search primitives — similarity, chunking, ranking, and orchestration.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/search/` and the chunking helpers at
|
|
4
|
+
`src/utils/chunking.ts`. These are pure Python (numpy + stdlib) and work
|
|
5
|
+
without any provider configured — useful for offline experiments,
|
|
6
|
+
fixture-driven benchmarks, and embedding the SDK in non-Atomicmem
|
|
7
|
+
contexts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from atomicmemory.search.chunking import (
|
|
11
|
+
ChunkOptions,
|
|
12
|
+
ChunkResult,
|
|
13
|
+
chunk_by_paragraphs,
|
|
14
|
+
chunk_by_sentences,
|
|
15
|
+
chunk_text,
|
|
16
|
+
chunk_text_with_metadata,
|
|
17
|
+
)
|
|
18
|
+
from atomicmemory.search.ranking import RankingConfig, rerank
|
|
19
|
+
from atomicmemory.search.semantic_search import (
|
|
20
|
+
SemanticSearch,
|
|
21
|
+
SemanticSearchConfig,
|
|
22
|
+
SemanticSearchResult,
|
|
23
|
+
StoredContext,
|
|
24
|
+
)
|
|
25
|
+
from atomicmemory.search.similarity import (
|
|
26
|
+
cosine_similarity,
|
|
27
|
+
find_top_k,
|
|
28
|
+
rank_by_similarity,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ChunkOptions",
|
|
33
|
+
"ChunkResult",
|
|
34
|
+
"RankingConfig",
|
|
35
|
+
"SemanticSearch",
|
|
36
|
+
"SemanticSearchConfig",
|
|
37
|
+
"SemanticSearchResult",
|
|
38
|
+
"StoredContext",
|
|
39
|
+
"chunk_by_paragraphs",
|
|
40
|
+
"chunk_by_sentences",
|
|
41
|
+
"chunk_text",
|
|
42
|
+
"chunk_text_with_metadata",
|
|
43
|
+
"cosine_similarity",
|
|
44
|
+
"find_top_k",
|
|
45
|
+
"rank_by_similarity",
|
|
46
|
+
"rerank",
|
|
47
|
+
]
|