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.
Files changed (67) hide show
  1. atomicmemory/__init__.py +166 -0
  2. atomicmemory/_version.py +3 -0
  3. atomicmemory/client/__init__.py +22 -0
  4. atomicmemory/client/async_memory_client.py +202 -0
  5. atomicmemory/client/atomic_memory_client.py +181 -0
  6. atomicmemory/client/memory_client.py +292 -0
  7. atomicmemory/core/__init__.py +34 -0
  8. atomicmemory/core/errors.py +122 -0
  9. atomicmemory/core/events.py +65 -0
  10. atomicmemory/core/logging.py +37 -0
  11. atomicmemory/core/retry.py +124 -0
  12. atomicmemory/core/validation.py +22 -0
  13. atomicmemory/embeddings/__init__.py +16 -0
  14. atomicmemory/embeddings/base.py +39 -0
  15. atomicmemory/embeddings/sentence_transformers.py +104 -0
  16. atomicmemory/kv_cache/__init__.py +17 -0
  17. atomicmemory/kv_cache/adapter.py +50 -0
  18. atomicmemory/kv_cache/memory_storage.py +98 -0
  19. atomicmemory/kv_cache/sqlite_storage.py +122 -0
  20. atomicmemory/memory/__init__.py +82 -0
  21. atomicmemory/memory/filters.py +68 -0
  22. atomicmemory/memory/pipeline.py +42 -0
  23. atomicmemory/memory/provider.py +397 -0
  24. atomicmemory/memory/registry.py +95 -0
  25. atomicmemory/memory/service.py +199 -0
  26. atomicmemory/memory/types.py +398 -0
  27. atomicmemory/providers/__init__.py +5 -0
  28. atomicmemory/providers/atomicmemory/__init__.py +43 -0
  29. atomicmemory/providers/atomicmemory/agents.py +156 -0
  30. atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
  31. atomicmemory/providers/atomicmemory/async_provider.py +245 -0
  32. atomicmemory/providers/atomicmemory/audit.py +74 -0
  33. atomicmemory/providers/atomicmemory/config.py +38 -0
  34. atomicmemory/providers/atomicmemory/config_handle.py +123 -0
  35. atomicmemory/providers/atomicmemory/handle.py +513 -0
  36. atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
  37. atomicmemory/providers/atomicmemory/http.py +255 -0
  38. atomicmemory/providers/atomicmemory/lessons.py +133 -0
  39. atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
  40. atomicmemory/providers/atomicmemory/mappers.py +125 -0
  41. atomicmemory/providers/atomicmemory/path.py +20 -0
  42. atomicmemory/providers/atomicmemory/provider.py +300 -0
  43. atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
  44. atomicmemory/providers/mem0/__init__.py +41 -0
  45. atomicmemory/providers/mem0/async_provider.py +191 -0
  46. atomicmemory/providers/mem0/config.py +51 -0
  47. atomicmemory/providers/mem0/http.py +195 -0
  48. atomicmemory/providers/mem0/mappers.py +145 -0
  49. atomicmemory/providers/mem0/provider.py +202 -0
  50. atomicmemory/py.typed +0 -0
  51. atomicmemory/search/__init__.py +47 -0
  52. atomicmemory/search/chunking.py +161 -0
  53. atomicmemory/search/ranking.py +94 -0
  54. atomicmemory/search/semantic_search.py +130 -0
  55. atomicmemory/search/similarity.py +110 -0
  56. atomicmemory/storage/__init__.py +63 -0
  57. atomicmemory/storage/_mapping.py +305 -0
  58. atomicmemory/storage/async_client.py +208 -0
  59. atomicmemory/storage/client.py +339 -0
  60. atomicmemory/storage/errors.py +115 -0
  61. atomicmemory/storage/types.py +305 -0
  62. atomicmemory/utils/__init__.py +5 -0
  63. atomicmemory/utils/environment.py +23 -0
  64. atomicmemory-1.0.0.dist-info/METADATA +146 -0
  65. atomicmemory-1.0.0.dist-info/RECORD +67 -0
  66. atomicmemory-1.0.0.dist-info/WHEEL +4 -0
  67. 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")