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,198 @@
1
+ """Async AtomicMemoryHandle root implementation.
2
+
3
+ Async counterpart of :mod:`atomicmemory.providers.atomicmemory.handle_impl`.
4
+ Reuses the same scope mappers, body construction, and response shaping
5
+ helpers; the only difference is awaiting the HTTP transport.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+ from urllib.parse import quote, urlencode
13
+
14
+ import httpx
15
+
16
+ from atomicmemory.core.errors import ProviderError
17
+ from atomicmemory.providers.atomicmemory.agents import AsyncAtomicMemoryAgents
18
+ from atomicmemory.providers.atomicmemory.audit import AsyncAtomicMemoryAudit
19
+ from atomicmemory.providers.atomicmemory.config_handle import AsyncAtomicMemoryConfig
20
+ from atomicmemory.providers.atomicmemory.handle import (
21
+ AtomicMemoryIngestInput,
22
+ AtomicMemoryIngestResult,
23
+ AtomicMemoryListOptions,
24
+ AtomicMemoryListResultPage,
25
+ AtomicMemoryMemory,
26
+ AtomicMemorySearchRequest,
27
+ AtomicMemorySearchResultPage,
28
+ MemoryScope,
29
+ WorkspaceScope,
30
+ )
31
+ from atomicmemory.providers.atomicmemory.handle_impl import (
32
+ _assert_list_options_scope_compat,
33
+ _coerce_list_options,
34
+ _map_search_response,
35
+ _to_atomic_memory,
36
+ )
37
+ from atomicmemory.providers.atomicmemory.http import (
38
+ HttpOptions,
39
+ afetch_json,
40
+ afetch_json_or_none,
41
+ afetch_void,
42
+ )
43
+ from atomicmemory.providers.atomicmemory.lessons import AsyncAtomicMemoryLessons
44
+ from atomicmemory.providers.atomicmemory.lifecycle import AsyncAtomicMemoryLifecycle
45
+ from atomicmemory.providers.atomicmemory.scope_mapper import (
46
+ assert_scope_allows_visibility,
47
+ scope_to_fields,
48
+ scope_to_query_pairs,
49
+ strip_agent_scope,
50
+ )
51
+
52
+ Route = Callable[[str], str]
53
+
54
+
55
+ class AsyncAtomicMemoryHandle:
56
+ """Async typed handle for AtomicMemory-specific routes."""
57
+
58
+ def __init__(self, client: httpx.AsyncClient, http: HttpOptions, route: Route) -> None:
59
+ self._client = client
60
+ self._http = http
61
+ self._route = route
62
+ self.lifecycle = AsyncAtomicMemoryLifecycle(client, http, route)
63
+ self.audit = AsyncAtomicMemoryAudit(client, http, route)
64
+ self.lessons = AsyncAtomicMemoryLessons(client, http, route)
65
+ self.config = AsyncAtomicMemoryConfig(client, http, route)
66
+ self.agents = AsyncAtomicMemoryAgents(client, http, route)
67
+
68
+ async def ingest_full(self, input: AtomicMemoryIngestInput, scope: MemoryScope) -> AtomicMemoryIngestResult:
69
+ return await self._post_ingest(self._route("/memories/ingest"), input, scope)
70
+
71
+ async def ingest_quick(
72
+ self,
73
+ input: AtomicMemoryIngestInput,
74
+ scope: MemoryScope,
75
+ skip_extraction: bool = False,
76
+ ) -> AtomicMemoryIngestResult:
77
+ return await self._post_ingest(
78
+ self._route("/memories/ingest/quick"), input, scope, skip_extraction=skip_extraction
79
+ )
80
+
81
+ async def search(self, request: AtomicMemorySearchRequest, scope: MemoryScope) -> AtomicMemorySearchResultPage:
82
+ return await self._post_search(self._route("/memories/search"), request, scope)
83
+
84
+ async def search_fast(self, request: AtomicMemorySearchRequest, scope: MemoryScope) -> AtomicMemorySearchResultPage:
85
+ return await self._post_search(self._route("/memories/search/fast"), request, scope)
86
+
87
+ async def expand(self, refs: list[str], scope: MemoryScope) -> list[AtomicMemoryMemory]:
88
+ body: dict[str, Any] = {**scope_to_fields(scope), "memory_ids": refs}
89
+ raw = await afetch_json(
90
+ self._client,
91
+ self._http,
92
+ self._route("/memories/expand"),
93
+ method="POST",
94
+ json=body,
95
+ )
96
+ echoed = strip_agent_scope(scope)
97
+ return [_to_atomic_memory(m, echoed) for m in raw.get("memories", [])]
98
+
99
+ async def list(
100
+ self,
101
+ scope: MemoryScope,
102
+ options: AtomicMemoryListOptions | dict[str, Any] | None = None,
103
+ ) -> AtomicMemoryListResultPage:
104
+ opts = _coerce_list_options(options)
105
+ _assert_list_options_scope_compat(scope, opts)
106
+ pairs: list[tuple[str, str]] = scope_to_query_pairs(scope)
107
+ if opts.limit is not None:
108
+ pairs.append(("limit", str(opts.limit)))
109
+ if opts.offset is not None:
110
+ pairs.append(("offset", str(opts.offset)))
111
+ if opts.source_site:
112
+ pairs.append(("source_site", opts.source_site))
113
+ if opts.episode_id:
114
+ pairs.append(("episode_id", opts.episode_id))
115
+ path = self._route(f"/memories/list?{urlencode(pairs)}")
116
+ raw = await afetch_json(self._client, self._http, path)
117
+ limit = opts.limit if opts.limit is not None else 20
118
+ offset = opts.offset if opts.offset is not None else 0
119
+ memories_raw = raw.get("memories", [])
120
+ next_offset = offset + len(memories_raw)
121
+ has_more = len(memories_raw) == limit
122
+ echoed = strip_agent_scope(scope)
123
+ return AtomicMemoryListResultPage(
124
+ memories=[_to_atomic_memory(m, echoed) for m in memories_raw],
125
+ count=raw.get("count", len(memories_raw)),
126
+ cursor=str(next_offset) if has_more else None,
127
+ )
128
+
129
+ async def get(self, id: str, scope: MemoryScope) -> AtomicMemoryMemory | None:
130
+ path = self._route(f"/memories/{quote(id, safe='')}?{urlencode(scope_to_query_pairs(scope))}")
131
+ raw = await afetch_json_or_none(self._client, self._http, path)
132
+ if raw is None:
133
+ return None
134
+ return _to_atomic_memory(raw, strip_agent_scope(scope))
135
+
136
+ async def delete(self, id: str, scope: MemoryScope) -> None:
137
+ path = self._route(f"/memories/{quote(id, safe='')}?{urlencode(scope_to_query_pairs(scope))}")
138
+ try:
139
+ await afetch_void(self._client, self._http, path, method="DELETE")
140
+ except ProviderError as exc:
141
+ if exc.status_code == 404:
142
+ return
143
+ raise
144
+
145
+ async def _post_ingest(
146
+ self,
147
+ path: str,
148
+ input: AtomicMemoryIngestInput,
149
+ scope: MemoryScope,
150
+ *,
151
+ skip_extraction: bool = False,
152
+ ) -> AtomicMemoryIngestResult:
153
+ assert_scope_allows_visibility(scope, input.visibility)
154
+ body: dict[str, Any] = {
155
+ **scope_to_fields(scope),
156
+ "conversation": input.conversation,
157
+ "source_site": input.source_site,
158
+ "source_url": input.source_url or "",
159
+ }
160
+ if isinstance(scope, WorkspaceScope) and input.visibility:
161
+ body["visibility"] = input.visibility
162
+ if input.config_override is not None:
163
+ body["config_override"] = input.config_override
164
+ if skip_extraction:
165
+ body["skip_extraction"] = True
166
+ raw = await afetch_json(self._client, self._http, path, method="POST", json=body)
167
+ return AtomicMemoryIngestResult.model_validate(raw)
168
+
169
+ async def _post_search(
170
+ self,
171
+ path: str,
172
+ request: AtomicMemorySearchRequest,
173
+ scope: MemoryScope,
174
+ ) -> AtomicMemorySearchResultPage:
175
+ body: dict[str, Any] = {
176
+ **scope_to_fields(scope, include_agent_scope=True),
177
+ "query": request.query,
178
+ }
179
+ if request.limit is not None:
180
+ body["limit"] = request.limit
181
+ if request.threshold is not None:
182
+ body["threshold"] = request.threshold
183
+ if request.as_of is not None:
184
+ body["as_of"] = request.as_of.isoformat()
185
+ if request.retrieval_mode is not None:
186
+ body["retrieval_mode"] = request.retrieval_mode
187
+ if request.token_budget is not None:
188
+ body["token_budget"] = request.token_budget
189
+ if request.namespace_scope is not None:
190
+ body["namespace_scope"] = request.namespace_scope
191
+ if request.source_site is not None:
192
+ body["source_site"] = request.source_site
193
+ if request.skip_repair:
194
+ body["skip_repair"] = True
195
+ if request.config_override is not None:
196
+ body["config_override"] = request.config_override
197
+ raw = await afetch_json(self._client, self._http, path, method="POST", json=body)
198
+ return _map_search_response(raw, scope)
@@ -0,0 +1,245 @@
1
+ """Async AtomicMemoryProvider — V3 core + Packager + TemporalSearch + Versioner + Health.
2
+
3
+ Async counterpart of `provider.py`. Uses :class:`httpx.AsyncClient` and
4
+ the ``a*`` HTTP helpers; body construction is delegated to the shared
5
+ private builders defined alongside the sync provider.
6
+
7
+ The ``atomicmemory.*`` handle namespace is wired in
8
+ ``async_handle_impl.py``; this module owns provider lifecycle + the
9
+ ``do_*`` overrides on :class:`BaseAsyncMemoryProvider`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from atomicmemory.core.errors import ProviderError
20
+ from atomicmemory.memory.provider import BaseAsyncMemoryProvider
21
+ from atomicmemory.memory.types import (
22
+ Capabilities,
23
+ CapabilitiesExtensions,
24
+ CapabilitiesRequiredScope,
25
+ ContextPackage,
26
+ CustomExtensionMeta,
27
+ HealthStatus,
28
+ IngestInput,
29
+ IngestResult,
30
+ ListRequest,
31
+ ListResultPage,
32
+ Memory,
33
+ MemoryRef,
34
+ MemoryVersion,
35
+ PackageRequest,
36
+ SearchRequest,
37
+ SearchResult,
38
+ SearchResultPage,
39
+ )
40
+ from atomicmemory.providers.atomicmemory.async_handle_impl import AsyncAtomicMemoryHandle
41
+ from atomicmemory.providers.atomicmemory.config import AtomicMemoryProviderConfig
42
+ from atomicmemory.providers.atomicmemory.handle import ATOMICMEMORY_EXTENSION_NAMES
43
+ from atomicmemory.providers.atomicmemory.http import (
44
+ HttpOptions,
45
+ adelete_ignore_404,
46
+ afetch_json,
47
+ afetch_json_or_none,
48
+ )
49
+ from atomicmemory.providers.atomicmemory.mappers import (
50
+ to_ingest_result,
51
+ to_memory,
52
+ to_memory_version,
53
+ to_search_result,
54
+ )
55
+ from atomicmemory.providers.atomicmemory.path import normalize_api_version
56
+ from atomicmemory.providers.atomicmemory.provider import (
57
+ _build_ingest_body,
58
+ _build_package_body,
59
+ _build_search_body,
60
+ _qs,
61
+ )
62
+
63
+ _ATOMICMEMORY_CUSTOM_EXTENSIONS: dict[str, CustomExtensionMeta] = {
64
+ name: CustomExtensionMeta(version="1.0.0") for name in ATOMICMEMORY_EXTENSION_NAMES
65
+ }
66
+
67
+
68
+ class AsyncAtomicMemoryProvider(BaseAsyncMemoryProvider):
69
+ """Async HTTP-backed V3 provider for atomicmemory-core."""
70
+
71
+ name = "atomicmemory"
72
+
73
+ def __init__(self, config: AtomicMemoryProviderConfig) -> None:
74
+ self._config = config
75
+ self._http_options = HttpOptions(
76
+ api_url=config.api_url.rstrip("/"),
77
+ api_key=config.api_key,
78
+ timeout_seconds=config.timeout_seconds,
79
+ )
80
+ self._api_prefix = normalize_api_version(config.api_version)
81
+ self._client: httpx.AsyncClient | None = None
82
+ self._handle: AsyncAtomicMemoryHandle | None = None
83
+ self._initialized = False
84
+
85
+ # ------------------------------------------------------------------
86
+ # Lifecycle
87
+ # ------------------------------------------------------------------
88
+
89
+ async def initialize(self) -> None:
90
+ if self._client is None:
91
+ self._client = httpx.AsyncClient()
92
+ if self._handle is None:
93
+ self._handle = AsyncAtomicMemoryHandle(self._client, self._http_options, self._route)
94
+ self._initialized = True
95
+
96
+ async def close(self) -> None:
97
+ self._handle = None
98
+ if self._client is not None:
99
+ await self._client.aclose()
100
+ self._client = None
101
+ self._initialized = False
102
+
103
+ # ------------------------------------------------------------------
104
+ # V3 core methods
105
+ # ------------------------------------------------------------------
106
+
107
+ async def do_ingest(self, input: IngestInput) -> IngestResult:
108
+ body = _build_ingest_body(input)
109
+ path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest")
110
+ raw = await afetch_json(self._require_client(), self._http_options, path, method="POST", json=body)
111
+ return to_ingest_result(raw)
112
+
113
+ async def do_search(self, request: SearchRequest) -> SearchResultPage:
114
+ body = _build_search_body(request)
115
+ raw = await afetch_json(
116
+ self._require_client(),
117
+ self._http_options,
118
+ self._route("/memories/search/fast"),
119
+ method="POST",
120
+ json=body,
121
+ )
122
+ return SearchResultPage(
123
+ results=[to_search_result(m, request.scope) for m in raw.get("memories", [])],
124
+ )
125
+
126
+ async def do_get(self, ref: MemoryRef) -> Memory | None:
127
+ path = self._route(f"/memories/{ref.id}?user_id={_qs(ref.scope.user)}")
128
+ raw = await afetch_json_or_none(self._require_client(), self._http_options, path)
129
+ if raw is None:
130
+ return None
131
+ return to_memory(raw, ref.scope)
132
+
133
+ async def do_delete(self, ref: MemoryRef) -> None:
134
+ path = self._route(f"/memories/{ref.id}?user_id={_qs(ref.scope.user)}")
135
+ await adelete_ignore_404(self._require_client(), self._http_options, path)
136
+
137
+ async def do_list(self, request: ListRequest) -> ListResultPage:
138
+ offset = int(request.cursor) if request.cursor else 0
139
+ limit = request.limit if request.limit is not None else 20
140
+ path = self._route(f"/memories/list?user_id={_qs(request.scope.user)}&limit={limit}&offset={offset}")
141
+ raw = await afetch_json(self._require_client(), self._http_options, path)
142
+ memories = [to_memory(m, request.scope) for m in raw.get("memories", [])]
143
+ next_offset = offset + len(memories)
144
+ cursor = str(next_offset) if len(memories) == limit else None
145
+ return ListResultPage(memories=memories, cursor=cursor)
146
+
147
+ # ------------------------------------------------------------------
148
+ # Capabilities + extension dispatch
149
+ # ------------------------------------------------------------------
150
+
151
+ def capabilities(self) -> Capabilities:
152
+ return Capabilities(
153
+ ingest_modes=["text", "messages", "verbatim"],
154
+ required_scope=CapabilitiesRequiredScope(default=["user"]),
155
+ extensions=CapabilitiesExtensions(
156
+ package=True,
157
+ temporal=True,
158
+ versioning=True,
159
+ health=True,
160
+ ),
161
+ custom_extensions=_ATOMICMEMORY_CUSTOM_EXTENSIONS,
162
+ )
163
+
164
+ def get_extension(self, name: str) -> Any | None:
165
+ if name in {"package", "temporal", "versioning", "health"}:
166
+ return self
167
+ if name == "atomicmemory.base":
168
+ return self._handle
169
+ if name == "atomicmemory.lifecycle" and self._handle is not None:
170
+ return self._handle.lifecycle
171
+ if name == "atomicmemory.audit" and self._handle is not None:
172
+ return self._handle.audit
173
+ if name == "atomicmemory.lessons" and self._handle is not None:
174
+ return self._handle.lessons
175
+ if name == "atomicmemory.config" and self._handle is not None:
176
+ return self._handle.config
177
+ if name == "atomicmemory.agents" and self._handle is not None:
178
+ return self._handle.agents
179
+ return None
180
+
181
+ # ------------------------------------------------------------------
182
+ # V3 extensions implemented inline
183
+ # ------------------------------------------------------------------
184
+
185
+ async def package(self, request: PackageRequest) -> ContextPackage:
186
+ body = _build_package_body(request)
187
+ raw = await afetch_json(
188
+ self._require_client(),
189
+ self._http_options,
190
+ self._route("/memories/search"),
191
+ method="POST",
192
+ json=body,
193
+ )
194
+ results: list[SearchResult] = [to_search_result(m, request.scope) for m in raw.get("memories", [])]
195
+ budget_constrained = raw.get("budget_constrained")
196
+ if not isinstance(budget_constrained, bool):
197
+ raise ValueError(
198
+ "atomicmemory async provider.package: backend response missing required boolean "
199
+ "field `budget_constrained`"
200
+ )
201
+ return ContextPackage(
202
+ text=raw.get("injection_text") or "",
203
+ results=results,
204
+ tokens=raw.get("estimated_context_tokens") or 0,
205
+ budget_constrained=budget_constrained,
206
+ )
207
+
208
+ async def search_as_of(self, request: SearchRequest, as_of: datetime) -> SearchResultPage:
209
+ body = _build_search_body(request)
210
+ body["as_of"] = as_of.isoformat()
211
+ raw = await afetch_json(
212
+ self._require_client(),
213
+ self._http_options,
214
+ self._route("/memories/search"),
215
+ method="POST",
216
+ json=body,
217
+ )
218
+ return SearchResultPage(
219
+ results=[to_search_result(m, request.scope) for m in raw.get("memories", [])],
220
+ )
221
+
222
+ async def history(self, ref: MemoryRef) -> list[MemoryVersion]:
223
+ path = self._route(f"/memories/{ref.id}/audit?user_id={_qs(ref.scope.user)}")
224
+ raw = await afetch_json(self._require_client(), self._http_options, path)
225
+ return [to_memory_version(entry) for entry in raw.get("trail", [])]
226
+
227
+ async def health(self) -> HealthStatus:
228
+ path = self._route("/memories/health")
229
+ raw = await afetch_json(self._require_client(), self._http_options, path)
230
+ return HealthStatus(ok=raw.get("status") == "ok")
231
+
232
+ # ------------------------------------------------------------------
233
+ # Internals
234
+ # ------------------------------------------------------------------
235
+
236
+ def _route(self, path: str) -> str:
237
+ return f"{self._api_prefix}{path}"
238
+
239
+ def _require_client(self) -> httpx.AsyncClient:
240
+ if self._client is None:
241
+ raise ProviderError(
242
+ "AsyncAtomicMemoryProvider is not initialized. Call initialize() first.",
243
+ provider=self.name,
244
+ )
245
+ return self._client
@@ -0,0 +1,74 @@
1
+ """AtomicMemoryAudit — mutation audit category.
2
+
3
+ Port of the audit section of
4
+ `atomicmemory-sdk/src/memory/atomicmemory-provider/handle-impl.ts:720-880`.
5
+ All routes are user-scoped.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from atomicmemory.providers.atomicmemory.handle import (
16
+ AuditTrailResult,
17
+ MutationSummary,
18
+ RecentMutationsResult,
19
+ )
20
+ from atomicmemory.providers.atomicmemory.http import HttpOptions, afetch_json, fetch_json
21
+
22
+ Route = Callable[[str], str]
23
+
24
+
25
+ class AtomicMemoryAudit:
26
+ """Audit-trail accessors for a user's claim-version history."""
27
+
28
+ def __init__(self, client: httpx.Client, http: HttpOptions, route: Route) -> None:
29
+ self._client = client
30
+ self._http = http
31
+ self._route = route
32
+
33
+ def summary(self, user_id: str) -> MutationSummary:
34
+ path = self._route(f"/memories/audit/summary?user_id={quote(user_id, safe='')}")
35
+ raw = fetch_json(self._client, self._http, path)
36
+ return MutationSummary.model_validate(raw)
37
+
38
+ def recent(self, user_id: str, limit: int | None = None) -> RecentMutationsResult:
39
+ url = f"/memories/audit/recent?user_id={quote(user_id, safe='')}"
40
+ if limit is not None:
41
+ url += f"&limit={limit}"
42
+ raw = fetch_json(self._client, self._http, self._route(url))
43
+ return RecentMutationsResult.model_validate(raw)
44
+
45
+ def trail(self, memory_id: str, user_id: str) -> AuditTrailResult:
46
+ path = self._route(f"/memories/{quote(memory_id, safe='')}/audit?user_id={quote(user_id, safe='')}")
47
+ raw = fetch_json(self._client, self._http, path)
48
+ return AuditTrailResult.model_validate(raw)
49
+
50
+
51
+ class AsyncAtomicMemoryAudit:
52
+ """Async counterpart of :class:`AtomicMemoryAudit`."""
53
+
54
+ def __init__(self, client: httpx.AsyncClient, http: HttpOptions, route: Route) -> None:
55
+ self._client = client
56
+ self._http = http
57
+ self._route = route
58
+
59
+ async def summary(self, user_id: str) -> MutationSummary:
60
+ path = self._route(f"/memories/audit/summary?user_id={quote(user_id, safe='')}")
61
+ raw = await afetch_json(self._client, self._http, path)
62
+ return MutationSummary.model_validate(raw)
63
+
64
+ async def recent(self, user_id: str, limit: int | None = None) -> RecentMutationsResult:
65
+ url = f"/memories/audit/recent?user_id={quote(user_id, safe='')}"
66
+ if limit is not None:
67
+ url += f"&limit={limit}"
68
+ raw = await afetch_json(self._client, self._http, self._route(url))
69
+ return RecentMutationsResult.model_validate(raw)
70
+
71
+ async def trail(self, memory_id: str, user_id: str) -> AuditTrailResult:
72
+ path = self._route(f"/memories/{quote(memory_id, safe='')}/audit?user_id={quote(user_id, safe='')}")
73
+ raw = await afetch_json(self._client, self._http, path)
74
+ return AuditTrailResult.model_validate(raw)
@@ -0,0 +1,38 @@
1
+ """AtomicMemory provider configuration.
2
+
3
+ Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/types.ts:1-36`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ ATOMICMEMORY_DEFAULT_TIMEOUT_SECONDS: float = 30.0
11
+ """Default request timeout (seconds). Mirrors TS ``ATOMICMEMORY_DEFAULT_TIMEOUT`` (ms)."""
12
+
13
+ ATOMICMEMORY_DEFAULT_API_VERSION: str = "v1"
14
+ """Matches core's mount at `atomicmemory-core/src/app/create-app.ts:31-32`."""
15
+
16
+
17
+ class AtomicMemoryProviderConfig(BaseModel):
18
+ """Inputs to construct an AtomicMemoryProvider."""
19
+
20
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
21
+
22
+ api_url: str = Field(alias="apiUrl")
23
+ """Base URL of the atomicmemory-core instance, e.g. ``http://localhost:3050``."""
24
+
25
+ api_key: str | None = Field(default=None, alias="apiKey")
26
+ """Optional bearer token forwarded as ``Authorization: Bearer <api_key>``."""
27
+
28
+ timeout_seconds: float = Field(
29
+ default=ATOMICMEMORY_DEFAULT_TIMEOUT_SECONDS,
30
+ alias="timeoutSeconds",
31
+ )
32
+ """Per-request timeout (seconds). Default 30s. Pre-port note: TS uses ms."""
33
+
34
+ api_version: str = Field(
35
+ default=ATOMICMEMORY_DEFAULT_API_VERSION,
36
+ alias="apiVersion",
37
+ )
38
+ """API-version segment prepended to every route path (e.g. ``v1`` → ``/v1/...``)."""
@@ -0,0 +1,123 @@
1
+ """AtomicMemoryConfig — runtime config category.
2
+
3
+ Port of the config section of
4
+ `atomicmemory-sdk/src/memory/atomicmemory-provider/handle-impl.ts:1008-1098`.
5
+ ``health`` is global; ``update_config`` is gated on
6
+ ``CORE_RUNTIME_CONFIG_MUTATION_ENABLED`` server-side.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from atomicmemory.providers.atomicmemory.handle import (
18
+ AtomicMemoryHealthStatus,
19
+ ConfigUpdateResult,
20
+ ConfigUpdates,
21
+ HealthConfig,
22
+ )
23
+ from atomicmemory.providers.atomicmemory.http import HttpOptions, afetch_json, fetch_json
24
+
25
+ Route = Callable[[str], str]
26
+
27
+ _SNAKE_TO_CAMEL_RE = re.compile(r"_([a-z])")
28
+
29
+
30
+ def _snake_to_camel(value: str) -> str:
31
+ """Convert a snake_case identifier to camelCase.
32
+
33
+ Mirrors TS handle-impl.ts: applied field names are echoed back to
34
+ Python in the same camelCase the TS SDK uses. Pure cosmetic; the
35
+ actual config payload is already mapped via Pydantic.
36
+ """
37
+ return _SNAKE_TO_CAMEL_RE.sub(lambda m: m.group(1).upper(), value)
38
+
39
+
40
+ class AtomicMemoryConfig:
41
+ """Runtime configuration accessors."""
42
+
43
+ def __init__(self, client: httpx.Client, http: HttpOptions, route: Route) -> None:
44
+ self._client = client
45
+ self._http = http
46
+ self._route = route
47
+
48
+ def health(self) -> AtomicMemoryHealthStatus:
49
+ raw = fetch_json(self._client, self._http, self._route("/memories/health"))
50
+ return AtomicMemoryHealthStatus.model_validate(
51
+ {"status": raw.get("status", "ok"), "config": HealthConfig.model_validate(raw["config"])}
52
+ )
53
+
54
+ def update_config(self, updates: ConfigUpdates | dict[str, Any]) -> ConfigUpdateResult:
55
+ if isinstance(updates, dict):
56
+ updates = ConfigUpdates.model_validate(updates)
57
+ body: dict[str, Any] = {}
58
+ if updates.similarity_threshold is not None:
59
+ body["similarity_threshold"] = updates.similarity_threshold
60
+ if updates.audn_candidate_threshold is not None:
61
+ body["audn_candidate_threshold"] = updates.audn_candidate_threshold
62
+ if updates.clarification_conflict_threshold is not None:
63
+ body["clarification_conflict_threshold"] = updates.clarification_conflict_threshold
64
+ if updates.max_search_results is not None:
65
+ body["max_search_results"] = updates.max_search_results
66
+ raw = fetch_json(
67
+ self._client,
68
+ self._http,
69
+ self._route("/memories/config"),
70
+ method="PUT",
71
+ json=body,
72
+ )
73
+ applied = [_snake_to_camel(name) for name in raw.get("applied", [])]
74
+ return ConfigUpdateResult.model_validate(
75
+ {
76
+ "applied": applied,
77
+ "config": HealthConfig.model_validate(raw["config"]),
78
+ "note": raw.get("note", ""),
79
+ }
80
+ )
81
+
82
+
83
+ class AsyncAtomicMemoryConfig:
84
+ """Async counterpart of :class:`AtomicMemoryConfig`."""
85
+
86
+ def __init__(self, client: httpx.AsyncClient, http: HttpOptions, route: Route) -> None:
87
+ self._client = client
88
+ self._http = http
89
+ self._route = route
90
+
91
+ async def health(self) -> AtomicMemoryHealthStatus:
92
+ raw = await afetch_json(self._client, self._http, self._route("/memories/health"))
93
+ return AtomicMemoryHealthStatus.model_validate(
94
+ {"status": raw.get("status", "ok"), "config": HealthConfig.model_validate(raw["config"])}
95
+ )
96
+
97
+ async def update_config(self, updates: ConfigUpdates | dict[str, Any]) -> ConfigUpdateResult:
98
+ if isinstance(updates, dict):
99
+ updates = ConfigUpdates.model_validate(updates)
100
+ body: dict[str, Any] = {}
101
+ if updates.similarity_threshold is not None:
102
+ body["similarity_threshold"] = updates.similarity_threshold
103
+ if updates.audn_candidate_threshold is not None:
104
+ body["audn_candidate_threshold"] = updates.audn_candidate_threshold
105
+ if updates.clarification_conflict_threshold is not None:
106
+ body["clarification_conflict_threshold"] = updates.clarification_conflict_threshold
107
+ if updates.max_search_results is not None:
108
+ body["max_search_results"] = updates.max_search_results
109
+ raw = await afetch_json(
110
+ self._client,
111
+ self._http,
112
+ self._route("/memories/config"),
113
+ method="PUT",
114
+ json=body,
115
+ )
116
+ applied = [_snake_to_camel(name) for name in raw.get("applied", [])]
117
+ return ConfigUpdateResult.model_validate(
118
+ {
119
+ "applied": applied,
120
+ "config": HealthConfig.model_validate(raw["config"]),
121
+ "note": raw.get("note", ""),
122
+ }
123
+ )