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,300 @@
|
|
|
1
|
+
"""Sync AtomicMemoryProvider — V3 core methods + Packager + TemporalSearch + Versioner + Health.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/atomicmemory-provider.ts`.
|
|
4
|
+
The handle namespace (lifecycle/audit/lessons/config/agents) is wired in
|
|
5
|
+
Phase 3 via :mod:`atomicmemory.providers.atomicmemory.handle_impl`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from atomicmemory.core.errors import ProviderError
|
|
17
|
+
from atomicmemory.memory.provider import BaseMemoryProvider
|
|
18
|
+
from atomicmemory.memory.types import (
|
|
19
|
+
Capabilities,
|
|
20
|
+
CapabilitiesExtensions,
|
|
21
|
+
CapabilitiesRequiredScope,
|
|
22
|
+
ContextPackage,
|
|
23
|
+
CustomExtensionMeta,
|
|
24
|
+
HealthStatus,
|
|
25
|
+
IngestInput,
|
|
26
|
+
IngestResult,
|
|
27
|
+
ListRequest,
|
|
28
|
+
ListResultPage,
|
|
29
|
+
Memory,
|
|
30
|
+
MemoryRef,
|
|
31
|
+
MemoryVersion,
|
|
32
|
+
PackageFormat,
|
|
33
|
+
PackageRequest,
|
|
34
|
+
SearchRequest,
|
|
35
|
+
SearchResult,
|
|
36
|
+
SearchResultPage,
|
|
37
|
+
)
|
|
38
|
+
from atomicmemory.providers.atomicmemory.config import AtomicMemoryProviderConfig
|
|
39
|
+
from atomicmemory.providers.atomicmemory.handle import ATOMICMEMORY_EXTENSION_NAMES
|
|
40
|
+
from atomicmemory.providers.atomicmemory.handle_impl import AtomicMemoryHandle
|
|
41
|
+
from atomicmemory.providers.atomicmemory.http import (
|
|
42
|
+
HttpOptions,
|
|
43
|
+
delete_ignore_404,
|
|
44
|
+
fetch_json,
|
|
45
|
+
fetch_json_or_none,
|
|
46
|
+
)
|
|
47
|
+
from atomicmemory.providers.atomicmemory.mappers import (
|
|
48
|
+
to_ingest_result,
|
|
49
|
+
to_memory,
|
|
50
|
+
to_memory_version,
|
|
51
|
+
to_search_result,
|
|
52
|
+
)
|
|
53
|
+
from atomicmemory.providers.atomicmemory.path import normalize_api_version
|
|
54
|
+
|
|
55
|
+
_ATOMICMEMORY_CUSTOM_EXTENSIONS: dict[str, CustomExtensionMeta] = {
|
|
56
|
+
name: CustomExtensionMeta(version="1.0.0") for name in ATOMICMEMORY_EXTENSION_NAMES
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AtomicMemoryProvider(BaseMemoryProvider):
|
|
61
|
+
"""Sync HTTP-backed V3 provider for atomicmemory-core."""
|
|
62
|
+
|
|
63
|
+
name = "atomicmemory"
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: AtomicMemoryProviderConfig) -> None:
|
|
66
|
+
self._config = config
|
|
67
|
+
self._http_options = HttpOptions(
|
|
68
|
+
api_url=config.api_url.rstrip("/"),
|
|
69
|
+
api_key=config.api_key,
|
|
70
|
+
timeout_seconds=config.timeout_seconds,
|
|
71
|
+
)
|
|
72
|
+
self._api_prefix = normalize_api_version(config.api_version)
|
|
73
|
+
self._client: httpx.Client | None = None
|
|
74
|
+
self._handle: AtomicMemoryHandle | None = None
|
|
75
|
+
self._initialized = False
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Lifecycle
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def initialize(self) -> None:
|
|
82
|
+
if self._client is None:
|
|
83
|
+
self._client = httpx.Client()
|
|
84
|
+
if self._handle is None:
|
|
85
|
+
self._handle = AtomicMemoryHandle(self._client, self._http_options, self._route)
|
|
86
|
+
self._initialized = True
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
self._handle = None
|
|
90
|
+
if self._client is not None:
|
|
91
|
+
self._client.close()
|
|
92
|
+
self._client = None
|
|
93
|
+
self._initialized = False
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# V3 core methods
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def do_ingest(self, input: IngestInput) -> IngestResult:
|
|
100
|
+
body = _build_ingest_body(input)
|
|
101
|
+
path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest")
|
|
102
|
+
raw = fetch_json(self._require_client(), self._http_options, path, method="POST", json=body)
|
|
103
|
+
return to_ingest_result(raw)
|
|
104
|
+
|
|
105
|
+
def do_search(self, request: SearchRequest) -> SearchResultPage:
|
|
106
|
+
body = _build_search_body(request)
|
|
107
|
+
raw = fetch_json(
|
|
108
|
+
self._require_client(),
|
|
109
|
+
self._http_options,
|
|
110
|
+
self._route("/memories/search/fast"),
|
|
111
|
+
method="POST",
|
|
112
|
+
json=body,
|
|
113
|
+
)
|
|
114
|
+
return SearchResultPage(
|
|
115
|
+
results=[to_search_result(m, request.scope) for m in raw.get("memories", [])],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def do_get(self, ref: MemoryRef) -> Memory | None:
|
|
119
|
+
path = self._route(f"/memories/{ref.id}?user_id={_qs(ref.scope.user)}")
|
|
120
|
+
raw = fetch_json_or_none(self._require_client(), self._http_options, path)
|
|
121
|
+
if raw is None:
|
|
122
|
+
return None
|
|
123
|
+
return to_memory(raw, ref.scope)
|
|
124
|
+
|
|
125
|
+
def do_delete(self, ref: MemoryRef) -> None:
|
|
126
|
+
path = self._route(f"/memories/{ref.id}?user_id={_qs(ref.scope.user)}")
|
|
127
|
+
delete_ignore_404(self._require_client(), self._http_options, path)
|
|
128
|
+
|
|
129
|
+
def do_list(self, request: ListRequest) -> ListResultPage:
|
|
130
|
+
offset = int(request.cursor) if request.cursor else 0
|
|
131
|
+
limit = request.limit if request.limit is not None else 20
|
|
132
|
+
path = self._route(f"/memories/list?user_id={_qs(request.scope.user)}&limit={limit}&offset={offset}")
|
|
133
|
+
raw = fetch_json(self._require_client(), self._http_options, path)
|
|
134
|
+
memories = [to_memory(m, request.scope) for m in raw.get("memories", [])]
|
|
135
|
+
next_offset = offset + len(memories)
|
|
136
|
+
cursor = str(next_offset) if len(memories) == limit else None
|
|
137
|
+
return ListResultPage(memories=memories, cursor=cursor)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# Capabilities + extension dispatch
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def capabilities(self) -> Capabilities:
|
|
144
|
+
return Capabilities(
|
|
145
|
+
ingest_modes=["text", "messages", "verbatim"],
|
|
146
|
+
required_scope=CapabilitiesRequiredScope(default=["user"]),
|
|
147
|
+
extensions=CapabilitiesExtensions(
|
|
148
|
+
package=True,
|
|
149
|
+
temporal=True,
|
|
150
|
+
versioning=True,
|
|
151
|
+
health=True,
|
|
152
|
+
),
|
|
153
|
+
custom_extensions=_ATOMICMEMORY_CUSTOM_EXTENSIONS,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def get_extension(self, name: str) -> Any | None:
|
|
157
|
+
if name in {"package", "temporal", "versioning", "health"}:
|
|
158
|
+
return self
|
|
159
|
+
if name == "atomicmemory.base":
|
|
160
|
+
return self._handle
|
|
161
|
+
if name == "atomicmemory.lifecycle" and self._handle is not None:
|
|
162
|
+
return self._handle.lifecycle
|
|
163
|
+
if name == "atomicmemory.audit" and self._handle is not None:
|
|
164
|
+
return self._handle.audit
|
|
165
|
+
if name == "atomicmemory.lessons" and self._handle is not None:
|
|
166
|
+
return self._handle.lessons
|
|
167
|
+
if name == "atomicmemory.config" and self._handle is not None:
|
|
168
|
+
return self._handle.config
|
|
169
|
+
if name == "atomicmemory.agents" and self._handle is not None:
|
|
170
|
+
return self._handle.agents
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# V3 extensions implemented inline (`Packager`, `TemporalSearch`,
|
|
175
|
+
# `Versioner`, `Health`)
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def package(self, request: PackageRequest) -> ContextPackage:
|
|
179
|
+
body = _build_package_body(request)
|
|
180
|
+
raw = fetch_json(
|
|
181
|
+
self._require_client(),
|
|
182
|
+
self._http_options,
|
|
183
|
+
self._route("/memories/search"),
|
|
184
|
+
method="POST",
|
|
185
|
+
json=body,
|
|
186
|
+
)
|
|
187
|
+
results: list[SearchResult] = [to_search_result(m, request.scope) for m in raw.get("memories", [])]
|
|
188
|
+
budget_constrained = raw.get("budget_constrained")
|
|
189
|
+
if not isinstance(budget_constrained, bool):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
"atomicmemory provider.package: backend response missing required boolean field `budget_constrained`"
|
|
192
|
+
)
|
|
193
|
+
return ContextPackage(
|
|
194
|
+
text=raw.get("injection_text") or "",
|
|
195
|
+
results=results,
|
|
196
|
+
tokens=raw.get("estimated_context_tokens") or 0,
|
|
197
|
+
budget_constrained=budget_constrained,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def search_as_of(self, request: SearchRequest, as_of: datetime) -> SearchResultPage:
|
|
201
|
+
body = _build_search_body(request)
|
|
202
|
+
body["as_of"] = as_of.isoformat()
|
|
203
|
+
raw = fetch_json(
|
|
204
|
+
self._require_client(),
|
|
205
|
+
self._http_options,
|
|
206
|
+
self._route("/memories/search"),
|
|
207
|
+
method="POST",
|
|
208
|
+
json=body,
|
|
209
|
+
)
|
|
210
|
+
return SearchResultPage(
|
|
211
|
+
results=[to_search_result(m, request.scope) for m in raw.get("memories", [])],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def history(self, ref: MemoryRef) -> list[MemoryVersion]:
|
|
215
|
+
path = self._route(f"/memories/{ref.id}/audit?user_id={_qs(ref.scope.user)}")
|
|
216
|
+
raw = fetch_json(self._require_client(), self._http_options, path)
|
|
217
|
+
return [to_memory_version(entry) for entry in raw.get("trail", [])]
|
|
218
|
+
|
|
219
|
+
def health(self) -> HealthStatus:
|
|
220
|
+
path = self._route("/memories/health")
|
|
221
|
+
raw = fetch_json(self._require_client(), self._http_options, path)
|
|
222
|
+
return HealthStatus(ok=raw.get("status") == "ok")
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Internals
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def _route(self, path: str) -> str:
|
|
229
|
+
return f"{self._api_prefix}{path}"
|
|
230
|
+
|
|
231
|
+
def _require_client(self) -> httpx.Client:
|
|
232
|
+
if self._client is None:
|
|
233
|
+
raise ProviderError(
|
|
234
|
+
"AtomicMemoryProvider is not initialized. Call initialize() first.",
|
|
235
|
+
provider=self.name,
|
|
236
|
+
)
|
|
237
|
+
return self._client
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Body builders — pure functions, shared with the async provider in Phase 4.
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_PACKAGE_FORMAT_TO_RETRIEVAL_MODE: dict[PackageFormat, str] = {
|
|
246
|
+
"flat": "flat",
|
|
247
|
+
"tiered": "tiered",
|
|
248
|
+
"structured": "abstract-aware",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _ingest_input_to_conversation(input: IngestInput) -> str:
|
|
253
|
+
match input.mode:
|
|
254
|
+
case "text" | "verbatim":
|
|
255
|
+
return input.content
|
|
256
|
+
case "messages":
|
|
257
|
+
return "\n".join(f"{m.role}: {m.content}" for m in input.messages)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _build_ingest_body(input: IngestInput) -> dict[str, Any]:
|
|
261
|
+
body: dict[str, Any] = {
|
|
262
|
+
"user_id": input.scope.user,
|
|
263
|
+
"conversation": _ingest_input_to_conversation(input),
|
|
264
|
+
"source_site": input.provenance.source if input.provenance and input.provenance.source else "sdk",
|
|
265
|
+
"source_url": input.provenance.source_url if input.provenance and input.provenance.source_url else "",
|
|
266
|
+
}
|
|
267
|
+
if input.mode == "verbatim":
|
|
268
|
+
body["skip_extraction"] = True
|
|
269
|
+
if input.metadata:
|
|
270
|
+
body["metadata"] = input.metadata
|
|
271
|
+
return body
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
|
|
275
|
+
body: dict[str, Any] = {
|
|
276
|
+
"user_id": request.scope.user,
|
|
277
|
+
"query": request.query,
|
|
278
|
+
}
|
|
279
|
+
if request.limit is not None:
|
|
280
|
+
body["limit"] = request.limit
|
|
281
|
+
if request.threshold is not None:
|
|
282
|
+
body["threshold"] = request.threshold
|
|
283
|
+
if request.scope.namespace is not None:
|
|
284
|
+
body["namespace_scope"] = request.scope.namespace
|
|
285
|
+
return body
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _build_package_body(request: PackageRequest) -> dict[str, Any]:
|
|
289
|
+
body = _build_search_body(request)
|
|
290
|
+
if request.format is not None:
|
|
291
|
+
body["retrieval_mode"] = _PACKAGE_FORMAT_TO_RETRIEVAL_MODE[request.format]
|
|
292
|
+
if request.token_budget is not None:
|
|
293
|
+
body["token_budget"] = request.token_budget
|
|
294
|
+
body["skip_repair"] = True
|
|
295
|
+
return body
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _qs(value: str | None) -> str:
|
|
299
|
+
"""URL-encode a query-string value; empty string when falsy."""
|
|
300
|
+
return quote(value, safe="") if value else ""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Scope mapper for the AtomicMemory namespace.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/scope-mapper.ts`.
|
|
4
|
+
Serializes the AtomicMemory-specific :class:`MemoryScope` discriminated
|
|
5
|
+
union to the body/query fields atomicmemory-core expects, with the same
|
|
6
|
+
"agent_scope only on POST search routes" policy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from atomicmemory.core.errors import ValidationError
|
|
14
|
+
from atomicmemory.providers.atomicmemory.handle import MemoryScope, WorkspaceScope
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def scope_to_fields(
|
|
18
|
+
scope: MemoryScope,
|
|
19
|
+
*,
|
|
20
|
+
include_agent_scope: bool = False,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""Translate a `MemoryScope` to wire-format request fields.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
scope: The scope discriminated union.
|
|
26
|
+
include_agent_scope: Emit ``agent_scope`` on the wire. Defaults
|
|
27
|
+
to ``False``; only the search routes opt in (core ignores
|
|
28
|
+
``agent_scope`` on expand/list/get/delete).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A dict with ``user_id`` always set, plus ``workspace_id`` /
|
|
32
|
+
``agent_id`` (and optionally ``agent_scope``) for workspace
|
|
33
|
+
scopes.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(scope, WorkspaceScope):
|
|
36
|
+
return {"user_id": scope.user_id}
|
|
37
|
+
fields: dict[str, Any] = {
|
|
38
|
+
"user_id": scope.user_id,
|
|
39
|
+
"workspace_id": scope.workspace_id,
|
|
40
|
+
"agent_id": scope.agent_id,
|
|
41
|
+
}
|
|
42
|
+
if include_agent_scope and scope.agent_scope is not None:
|
|
43
|
+
fields["agent_scope"] = scope.agent_scope
|
|
44
|
+
return fields
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scope_to_query_pairs(
|
|
48
|
+
scope: MemoryScope,
|
|
49
|
+
*,
|
|
50
|
+
include_agent_scope: bool = False,
|
|
51
|
+
) -> list[tuple[str, str]]:
|
|
52
|
+
"""Translate a scope to ``[(key, value)]`` pairs for query strings.
|
|
53
|
+
|
|
54
|
+
httpx's ``params=`` accepts a list of pairs, which lets us repeat a
|
|
55
|
+
key (``agent_scope``) for list values without joining with commas.
|
|
56
|
+
Defaults to **not** sending ``agent_scope`` — only POST search
|
|
57
|
+
routes honor it, and they use bodies, not query strings.
|
|
58
|
+
"""
|
|
59
|
+
pairs: list[tuple[str, str]] = [("user_id", scope.user_id)]
|
|
60
|
+
if isinstance(scope, WorkspaceScope):
|
|
61
|
+
pairs.append(("workspace_id", scope.workspace_id))
|
|
62
|
+
pairs.append(("agent_id", scope.agent_id))
|
|
63
|
+
if include_agent_scope and scope.agent_scope is not None:
|
|
64
|
+
value = scope.agent_scope
|
|
65
|
+
if isinstance(value, list):
|
|
66
|
+
pairs.extend(("agent_scope", v) for v in value)
|
|
67
|
+
else:
|
|
68
|
+
pairs.append(("agent_scope", value))
|
|
69
|
+
return pairs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def assert_scope_allows_visibility(scope: MemoryScope, visibility: str | None) -> None:
|
|
73
|
+
"""Raise if a user-scope ingest tries to set workspace-only `visibility`.
|
|
74
|
+
|
|
75
|
+
Visibility is a workspace-only write-time label. Sending it on
|
|
76
|
+
user-scope ingest is silently dropped by core; the SDK fails closed.
|
|
77
|
+
"""
|
|
78
|
+
if visibility is not None and not isinstance(scope, WorkspaceScope):
|
|
79
|
+
raise ValidationError(
|
|
80
|
+
"ingest `visibility` is only valid with workspace scope; omit it or use a workspace scope variant.",
|
|
81
|
+
context={"scope_kind": scope.kind, "visibility": visibility},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def strip_agent_scope(scope: MemoryScope) -> MemoryScope:
|
|
86
|
+
"""Drop ``agent_scope`` from a workspace scope before echoing it back.
|
|
87
|
+
|
|
88
|
+
Used on routes that don't honor ``agent_scope`` (expand/list/get/
|
|
89
|
+
delete) so returned memories don't lie about the filter that wasn't
|
|
90
|
+
applied.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(scope, WorkspaceScope):
|
|
93
|
+
return scope
|
|
94
|
+
return WorkspaceScope(
|
|
95
|
+
user_id=scope.user_id,
|
|
96
|
+
workspace_id=scope.workspace_id,
|
|
97
|
+
agent_id=scope.agent_id,
|
|
98
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Mem0 provider — HTTP client for Mem0 OSS or hosted instances.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/mem0-provider/`. Importing this
|
|
4
|
+
package registers `Mem0Provider` on the sync default registry and
|
|
5
|
+
`AsyncMem0Provider` on the async registry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from atomicmemory.memory.registry import (
|
|
9
|
+
AsyncProviderRegistration,
|
|
10
|
+
ProviderRegistration,
|
|
11
|
+
default_async_registry,
|
|
12
|
+
default_registry,
|
|
13
|
+
)
|
|
14
|
+
from atomicmemory.providers.mem0.async_provider import AsyncMem0Provider
|
|
15
|
+
from atomicmemory.providers.mem0.config import Mem0ProviderConfig
|
|
16
|
+
from atomicmemory.providers.mem0.provider import Mem0Provider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _coerce_config(config: object) -> Mem0ProviderConfig:
|
|
20
|
+
if isinstance(config, Mem0ProviderConfig):
|
|
21
|
+
return config
|
|
22
|
+
return Mem0ProviderConfig.model_validate(config)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _factory(config: object) -> ProviderRegistration:
|
|
26
|
+
return ProviderRegistration(provider=Mem0Provider(_coerce_config(config)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _async_factory(config: object) -> AsyncProviderRegistration:
|
|
30
|
+
return AsyncProviderRegistration(provider=AsyncMem0Provider(_coerce_config(config)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
default_registry.register("mem0", _factory)
|
|
34
|
+
default_async_registry.register("mem0", _async_factory)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"AsyncMem0Provider",
|
|
39
|
+
"Mem0Provider",
|
|
40
|
+
"Mem0ProviderConfig",
|
|
41
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Async Mem0Provider — V3 core + Health.
|
|
2
|
+
|
|
3
|
+
Async counterpart of :class:`Mem0Provider`. Same capabilities; same
|
|
4
|
+
verbatim-rejection invariant; same body builders.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from atomicmemory.core.errors import ProviderError
|
|
17
|
+
from atomicmemory.memory.provider import BaseAsyncMemoryProvider
|
|
18
|
+
from atomicmemory.memory.types import (
|
|
19
|
+
Capabilities,
|
|
20
|
+
CapabilitiesExtensions,
|
|
21
|
+
CapabilitiesRequiredScope,
|
|
22
|
+
HealthStatus,
|
|
23
|
+
IngestInput,
|
|
24
|
+
IngestResult,
|
|
25
|
+
ListRequest,
|
|
26
|
+
ListResultPage,
|
|
27
|
+
Memory,
|
|
28
|
+
MemoryRef,
|
|
29
|
+
SearchRequest,
|
|
30
|
+
SearchResultPage,
|
|
31
|
+
)
|
|
32
|
+
from atomicmemory.providers.mem0.config import Mem0ProviderConfig
|
|
33
|
+
from atomicmemory.providers.mem0.http import (
|
|
34
|
+
HttpOptions,
|
|
35
|
+
adelete_ignore_404,
|
|
36
|
+
afetch_json,
|
|
37
|
+
afetch_json_or_none,
|
|
38
|
+
)
|
|
39
|
+
from atomicmemory.providers.mem0.mappers import (
|
|
40
|
+
build_ingest_body,
|
|
41
|
+
build_search_body,
|
|
42
|
+
resolve_infer_flag,
|
|
43
|
+
to_ingest_result,
|
|
44
|
+
to_memory,
|
|
45
|
+
to_search_result,
|
|
46
|
+
unwrap_mem0_array,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger("atomicmemory.providers.mem0")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AsyncMem0Provider(BaseAsyncMemoryProvider):
|
|
53
|
+
"""Async HTTP-backed V3 provider for Mem0."""
|
|
54
|
+
|
|
55
|
+
name = "mem0"
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: Mem0ProviderConfig) -> None:
|
|
58
|
+
self._config = config
|
|
59
|
+
self._http_options = HttpOptions(
|
|
60
|
+
api_url=config.api_url.rstrip("/"),
|
|
61
|
+
api_key=config.api_key,
|
|
62
|
+
timeout_seconds=config.timeout_seconds,
|
|
63
|
+
)
|
|
64
|
+
self._prefix = config.path_prefix
|
|
65
|
+
self._client: httpx.AsyncClient | None = None
|
|
66
|
+
self._initialized = False
|
|
67
|
+
# Hold strong refs to background tasks so the event loop's weak
|
|
68
|
+
# reference doesn't garbage-collect them mid-flight (RUF006).
|
|
69
|
+
self._background_tasks: set[asyncio.Task[None]] = set()
|
|
70
|
+
|
|
71
|
+
async def initialize(self) -> None:
|
|
72
|
+
if self._client is None:
|
|
73
|
+
self._client = httpx.AsyncClient()
|
|
74
|
+
self._initialized = True
|
|
75
|
+
|
|
76
|
+
async def close(self) -> None:
|
|
77
|
+
if self._client is not None:
|
|
78
|
+
await self._client.aclose()
|
|
79
|
+
self._client = None
|
|
80
|
+
self._initialized = False
|
|
81
|
+
|
|
82
|
+
async def do_ingest(self, input: IngestInput) -> IngestResult:
|
|
83
|
+
if input.mode == "verbatim":
|
|
84
|
+
raise ProviderError(
|
|
85
|
+
"mem0 does not support verbatim ingest; use the atomicmemory provider for "
|
|
86
|
+
"deterministic one-input-equals-one-memory storage.",
|
|
87
|
+
provider=self.name,
|
|
88
|
+
context={"operation": "ingest", "mode": "verbatim"},
|
|
89
|
+
)
|
|
90
|
+
user_id = input.scope.user or ""
|
|
91
|
+
body = build_ingest_body(input, user_id, self._config)
|
|
92
|
+
should_defer = self._config.defer_inference and resolve_infer_flag(input, self._config)
|
|
93
|
+
if should_defer:
|
|
94
|
+
body["infer"] = False
|
|
95
|
+
raw = await afetch_json(
|
|
96
|
+
self._require_client(),
|
|
97
|
+
self._http_options,
|
|
98
|
+
self._path("/memories/"),
|
|
99
|
+
method="POST",
|
|
100
|
+
json=body,
|
|
101
|
+
)
|
|
102
|
+
memories = unwrap_mem0_array(raw)
|
|
103
|
+
if should_defer:
|
|
104
|
+
task = asyncio.create_task(self._fire_background_inference(body))
|
|
105
|
+
self._background_tasks.add(task)
|
|
106
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
107
|
+
return to_ingest_result(memories)
|
|
108
|
+
|
|
109
|
+
async def do_search(self, request: SearchRequest) -> SearchResultPage:
|
|
110
|
+
body = build_search_body(request.query, request.scope, self._config, request.limit)
|
|
111
|
+
raw = await afetch_json(
|
|
112
|
+
self._require_client(),
|
|
113
|
+
self._http_options,
|
|
114
|
+
self._search_path(),
|
|
115
|
+
method="POST",
|
|
116
|
+
json=body,
|
|
117
|
+
)
|
|
118
|
+
results = [to_search_result(m, request.scope) for m in unwrap_mem0_array(raw)]
|
|
119
|
+
return SearchResultPage(results=results)
|
|
120
|
+
|
|
121
|
+
async def do_get(self, ref: MemoryRef) -> Memory | None:
|
|
122
|
+
raw = await afetch_json_or_none(self._require_client(), self._http_options, self._path(f"/memories/{ref.id}/"))
|
|
123
|
+
if raw is None:
|
|
124
|
+
return None
|
|
125
|
+
return to_memory(raw, ref.scope)
|
|
126
|
+
|
|
127
|
+
async def do_delete(self, ref: MemoryRef) -> None:
|
|
128
|
+
await adelete_ignore_404(self._require_client(), self._http_options, self._path(f"/memories/{ref.id}/"))
|
|
129
|
+
|
|
130
|
+
async def do_list(self, request: ListRequest) -> ListResultPage:
|
|
131
|
+
limit = request.limit if request.limit is not None else 20
|
|
132
|
+
offset = int(request.cursor) if request.cursor else 0
|
|
133
|
+
page = (offset // limit) + 1 if offset > 0 else None
|
|
134
|
+
path = self._path(f"/memories/?user_id={request.scope.user or ''}&page_size={limit}")
|
|
135
|
+
if page is not None:
|
|
136
|
+
path += f"&page={page}"
|
|
137
|
+
raw = await afetch_json(self._require_client(), self._http_options, path)
|
|
138
|
+
memories = [to_memory(m, request.scope) for m in unwrap_mem0_array(raw)]
|
|
139
|
+
next_offset = offset + len(memories)
|
|
140
|
+
cursor = str(next_offset) if len(memories) == limit else None
|
|
141
|
+
return ListResultPage(memories=memories, cursor=cursor)
|
|
142
|
+
|
|
143
|
+
def capabilities(self) -> Capabilities:
|
|
144
|
+
return Capabilities(
|
|
145
|
+
ingest_modes=["text", "messages"],
|
|
146
|
+
required_scope=CapabilitiesRequiredScope(default=["user"]),
|
|
147
|
+
extensions=CapabilitiesExtensions(health=True),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def get_extension(self, name: str) -> Any | None:
|
|
151
|
+
if name == "health":
|
|
152
|
+
return self
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
async def health(self) -> HealthStatus:
|
|
156
|
+
start = time.monotonic()
|
|
157
|
+
try:
|
|
158
|
+
await afetch_json(
|
|
159
|
+
self._require_client(),
|
|
160
|
+
self._http_options,
|
|
161
|
+
self._path("/memories/?user_id=health-check&page_size=1"),
|
|
162
|
+
)
|
|
163
|
+
return HealthStatus(ok=True, latency_ms=(time.monotonic() - start) * 1000.0)
|
|
164
|
+
except (ProviderError, ValueError):
|
|
165
|
+
return HealthStatus(ok=False, latency_ms=(time.monotonic() - start) * 1000.0)
|
|
166
|
+
|
|
167
|
+
def _path(self, endpoint: str) -> str:
|
|
168
|
+
return f"{self._prefix}{endpoint}"
|
|
169
|
+
|
|
170
|
+
def _search_path(self) -> str:
|
|
171
|
+
return "/memories/search/" if self._prefix == "" else "/v2/memories/search/"
|
|
172
|
+
|
|
173
|
+
def _require_client(self) -> httpx.AsyncClient:
|
|
174
|
+
if self._client is None:
|
|
175
|
+
raise ProviderError(
|
|
176
|
+
"AsyncMem0Provider is not initialized. Call await initialize() first.",
|
|
177
|
+
provider=self.name,
|
|
178
|
+
)
|
|
179
|
+
return self._client
|
|
180
|
+
|
|
181
|
+
async def _fire_background_inference(self, body: dict[str, Any]) -> None:
|
|
182
|
+
try:
|
|
183
|
+
await afetch_json(
|
|
184
|
+
self._require_client(),
|
|
185
|
+
self._http_options,
|
|
186
|
+
self._path("/memories/"),
|
|
187
|
+
method="POST",
|
|
188
|
+
json={**body, "infer": True},
|
|
189
|
+
)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
_logger.warning("[mem0] deferred AUDN failed: %s", exc)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Mem0 provider configuration.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/mem0-provider/types.ts`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
MEM0_DEFAULT_TIMEOUT_SECONDS: float = 30.0
|
|
11
|
+
MEM0_DEFAULT_PATH_PREFIX: str = "/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Mem0ProviderConfig(BaseModel):
|
|
15
|
+
"""Inputs to construct a Mem0 provider."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
18
|
+
|
|
19
|
+
api_url: str = Field(alias="apiUrl")
|
|
20
|
+
"""Mem0 API base URL.
|
|
21
|
+
|
|
22
|
+
Hosted: ``https://api.mem0.ai``. OSS self-hosted:
|
|
23
|
+
``http://localhost:8888``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
api_key: str | None = Field(default=None, alias="apiKey")
|
|
27
|
+
"""API key for hosted Mem0 instances. Sent as ``Authorization: Bearer …``."""
|
|
28
|
+
|
|
29
|
+
timeout_seconds: float = Field(
|
|
30
|
+
default=MEM0_DEFAULT_TIMEOUT_SECONDS,
|
|
31
|
+
alias="timeoutSeconds",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
default_infer: bool = Field(default=True, alias="defaultInfer")
|
|
35
|
+
"""Whether to enable LLM inference on ingest by default."""
|
|
36
|
+
|
|
37
|
+
defer_inference: bool = Field(default=False, alias="deferInference")
|
|
38
|
+
"""When True, ingest sends ``infer=false`` synchronously and fires a
|
|
39
|
+
background re-ingest with ``infer=true`` (deferred AUDN extraction).
|
|
40
|
+
Only applies when the effective infer value would be True. Default
|
|
41
|
+
False (single-call behaviour)."""
|
|
42
|
+
|
|
43
|
+
path_prefix: str = Field(default=MEM0_DEFAULT_PATH_PREFIX, alias="pathPrefix")
|
|
44
|
+
"""Path prefix for memory-identifier endpoints.
|
|
45
|
+
|
|
46
|
+
``/v1`` (default) for hosted Mem0; ``''`` for OSS self-hosted. Note
|
|
47
|
+
that search uses the v2 endpoint regardless of this prefix.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
org_id: str | None = Field(default=None, alias="orgId")
|
|
51
|
+
project_id: str | None = Field(default=None, alias="projectId")
|