@atomicmemory/hermes-plugin 0.1.10

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.
package/config.py ADDED
@@ -0,0 +1,236 @@
1
+ """Config + scope policy for the Hermes provider.
2
+
3
+ Single helper `read_scope()` is the only place that decides whether a read
4
+ includes `source_site=hermes`. Every read tool funnels through it, so the
5
+ v1-round-1 asymmetry (profile vs search) cannot recur by construction.
6
+
7
+ Source attribution on writes always lives in provenance, never in scope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ SOURCE_SITE = "hermes"
24
+ DEFAULT_USER_ID = "hermes-user"
25
+ DEFAULT_AGENT_ID = "hermes"
26
+ DEFAULT_SEARCH_LIMIT = 5
27
+ DEFAULT_TOKEN_BUDGET = 4000
28
+ DEFAULT_MEMORY_MODE = "hybrid"
29
+ DEFAULT_MEMORY_SCOPE = "shared"
30
+ DEFAULT_PREFETCH_METHOD = "context"
31
+
32
+ VALID_MEMORY_MODES = {"hybrid", "context", "tools"}
33
+ VALID_MEMORY_SCOPES = {"shared", "siloed"}
34
+ VALID_PREFETCH_METHODS = {"context", "fast"}
35
+
36
+ CONFIG_FILE_KEYS = {
37
+ "scope_user",
38
+ "scope_agent",
39
+ "search_limit",
40
+ "token_budget",
41
+ "prefetch_enabled",
42
+ "memory_mode",
43
+ "memory_scope",
44
+ "prefetch_method",
45
+ }
46
+ """Keys allowed in $HERMES_HOME/atomicmemory.json.
47
+
48
+ `api_url`/`api_key` are intentionally absent: SDK connection config lives in env.
49
+ No hardcoded service endpoints.
50
+ """
51
+
52
+
53
+ @dataclass
54
+ class ProviderConfig:
55
+ scope_user: str = DEFAULT_USER_ID
56
+ scope_agent: str = DEFAULT_AGENT_ID
57
+ search_limit: int = DEFAULT_SEARCH_LIMIT
58
+ token_budget: int = DEFAULT_TOKEN_BUDGET
59
+ prefetch_enabled: bool = True
60
+ memory_mode: str = DEFAULT_MEMORY_MODE
61
+ memory_scope: str = DEFAULT_MEMORY_SCOPE
62
+ prefetch_method: str = DEFAULT_PREFETCH_METHOD
63
+
64
+
65
+ def load_config(
66
+ *,
67
+ hermes_home: str | Path | None = None,
68
+ env: dict[str, str] | None = None,
69
+ ) -> ProviderConfig:
70
+ env = env if env is not None else os.environ.copy()
71
+ cfg = ProviderConfig(
72
+ scope_user=_clean(env.get("ATOMICMEMORY_SCOPE_USER"))
73
+ or _clean(env.get("USER"))
74
+ or _clean(env.get("USERNAME"))
75
+ or DEFAULT_USER_ID,
76
+ scope_agent=_clean(env.get("ATOMICMEMORY_SCOPE_AGENT")) or DEFAULT_AGENT_ID,
77
+ search_limit=_env_int(env, "ATOMICMEMORY_SEARCH_LIMIT", DEFAULT_SEARCH_LIMIT),
78
+ token_budget=_env_int(env, "ATOMICMEMORY_TOKEN_BUDGET", DEFAULT_TOKEN_BUDGET),
79
+ prefetch_enabled=_env_bool(env, "ATOMICMEMORY_PREFETCH_ENABLED", True),
80
+ memory_mode=_normalized(env.get("ATOMICMEMORY_MEMORY_MODE"), DEFAULT_MEMORY_MODE, VALID_MEMORY_MODES),
81
+ memory_scope=_normalized(
82
+ env.get("ATOMICMEMORY_MEMORY_SCOPE"), DEFAULT_MEMORY_SCOPE, VALID_MEMORY_SCOPES,
83
+ ),
84
+ prefetch_method=_normalized(
85
+ env.get("ATOMICMEMORY_PREFETCH_METHOD"), DEFAULT_PREFETCH_METHOD, VALID_PREFETCH_METHODS,
86
+ ),
87
+ )
88
+ file_overrides = _read_config_file(hermes_home)
89
+ return _apply_file_overrides(cfg, file_overrides)
90
+
91
+
92
+ def save_config(values: dict[str, Any], hermes_home: str | Path) -> None:
93
+ """Persist non-secret advanced config to $HERMES_HOME/atomicmemory.json."""
94
+ path = Path(hermes_home) / "atomicmemory.json"
95
+ existing: dict[str, Any] = {}
96
+ if path.exists():
97
+ try:
98
+ raw = json.loads(path.read_text(encoding="utf-8"))
99
+ if isinstance(raw, dict):
100
+ existing = {k: v for k, v in raw.items() if k in CONFIG_FILE_KEYS}
101
+ except Exception: # noqa: BLE001
102
+ existing = {}
103
+ for key, value in values.items():
104
+ if key not in CONFIG_FILE_KEYS:
105
+ continue
106
+ if value is None or value == "":
107
+ continue
108
+ existing[key] = value
109
+ path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
110
+
111
+
112
+ def get_config_schema() -> list[dict[str, Any]]:
113
+ """Setup-wizard prompts. Keep minimal — advanced knobs go in the JSON file."""
114
+ return [
115
+ {
116
+ "key": "scope_user",
117
+ "description": "User identity for AtomicMemory recall and ingest scope.",
118
+ "default": DEFAULT_USER_ID,
119
+ "env_var": "ATOMICMEMORY_SCOPE_USER",
120
+ },
121
+ {
122
+ "key": "memory_scope",
123
+ "description": (
124
+ "shared (default): cross-tool recall — Hermes sees memories from every "
125
+ "AtomicMemory tool. siloed: Hermes sees only Hermes-ingested memories."
126
+ ),
127
+ "default": DEFAULT_MEMORY_SCOPE,
128
+ "env_var": "ATOMICMEMORY_MEMORY_SCOPE",
129
+ "enum": sorted(VALID_MEMORY_SCOPES),
130
+ },
131
+ ]
132
+
133
+
134
+ def read_scope_kwargs(*, memory_scope: str, user_id: str) -> dict[str, Any]:
135
+ """Build kwargs for AtomicMemoryClient.search/package/list_recent.
136
+
137
+ Single source of truth for whether a read includes `source_site=hermes`.
138
+ Used by `atomicmemory_search`, `atomicmemory_context`, `atomicmemory_profile`,
139
+ and `_prefetch_context`.
140
+ """
141
+ base: dict[str, Any] = {"scope": {"user": user_id}}
142
+ if memory_scope == "siloed":
143
+ base["source_site"] = SOURCE_SITE
144
+ return base
145
+
146
+
147
+ def ingest_scope_dict(*, user_id: str) -> dict[str, Any]:
148
+ """Scope for ingest. Source attribution lives in provenance, not scope."""
149
+ return {"user": user_id}
150
+
151
+
152
+ def _clean(value: Any) -> str | None:
153
+ if value is None:
154
+ return None
155
+ cleaned = str(value).strip()
156
+ return cleaned or None
157
+
158
+
159
+ def _env_int(env: dict[str, str], name: str, default: int) -> int:
160
+ raw = _clean(env.get(name))
161
+ if raw is None:
162
+ return default
163
+ try:
164
+ value = int(raw)
165
+ except ValueError:
166
+ return default
167
+ return value if value > 0 else default
168
+
169
+
170
+ def _env_bool(env: dict[str, str], name: str, default: bool) -> bool:
171
+ raw = _clean(env.get(name))
172
+ if raw is None:
173
+ return default
174
+ return raw.lower() in {"1", "true", "yes", "on"}
175
+
176
+
177
+ def _normalized(value: Any, default: str, allowed: set[str]) -> str:
178
+ cleaned = (_clean(value) or default).lower()
179
+ return cleaned if cleaned in allowed else default
180
+
181
+
182
+ def _read_config_file(hermes_home: str | Path | None) -> dict[str, Any]:
183
+ if hermes_home is None:
184
+ hermes_home = _hermes_home()
185
+ path = Path(hermes_home) / "atomicmemory.json"
186
+ if not path.exists():
187
+ return {}
188
+ try:
189
+ raw = json.loads(path.read_text(encoding="utf-8"))
190
+ except Exception as exc: # noqa: BLE001
191
+ logger.warning("Failed to read AtomicMemory config: %s", exc)
192
+ return {}
193
+ if not isinstance(raw, dict):
194
+ return {}
195
+ return {k: v for k, v in raw.items() if k in CONFIG_FILE_KEYS and v not in (None, "")}
196
+
197
+
198
+ def _apply_file_overrides(cfg: ProviderConfig, file_overrides: dict[str, Any]) -> ProviderConfig:
199
+ if "scope_user" in file_overrides:
200
+ cfg.scope_user = str(file_overrides["scope_user"])
201
+ if "scope_agent" in file_overrides:
202
+ cfg.scope_agent = str(file_overrides["scope_agent"])
203
+ if "search_limit" in file_overrides:
204
+ cfg.search_limit = _coerce_positive_int(file_overrides["search_limit"], cfg.search_limit)
205
+ if "token_budget" in file_overrides:
206
+ cfg.token_budget = _coerce_positive_int(file_overrides["token_budget"], cfg.token_budget, lower=100)
207
+ if "prefetch_enabled" in file_overrides:
208
+ cfg.prefetch_enabled = bool(file_overrides["prefetch_enabled"])
209
+ if "memory_mode" in file_overrides:
210
+ cfg.memory_mode = _normalized(file_overrides["memory_mode"], cfg.memory_mode, VALID_MEMORY_MODES)
211
+ if "memory_scope" in file_overrides:
212
+ cfg.memory_scope = _normalized(file_overrides["memory_scope"], cfg.memory_scope, VALID_MEMORY_SCOPES)
213
+ if "prefetch_method" in file_overrides:
214
+ cfg.prefetch_method = _normalized(
215
+ file_overrides["prefetch_method"], cfg.prefetch_method, VALID_PREFETCH_METHODS,
216
+ )
217
+ cfg.search_limit = max(1, min(cfg.search_limit, 50))
218
+ cfg.token_budget = max(100, cfg.token_budget)
219
+ return cfg
220
+
221
+
222
+ def _coerce_positive_int(value: Any, default: int, *, lower: int = 1) -> int:
223
+ try:
224
+ coerced = int(value)
225
+ except (TypeError, ValueError):
226
+ return default
227
+ return coerced if coerced >= lower else default
228
+
229
+
230
+ def _hermes_home() -> Path:
231
+ try:
232
+ from hermes_constants import get_hermes_home # type: ignore[import-not-found]
233
+
234
+ return Path(get_hermes_home())
235
+ except Exception: # noqa: BLE001 — used only outside Hermes test runs
236
+ return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@atomicmemory/hermes-plugin",
3
+ "version": "0.1.10",
4
+ "description": "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default.",
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
9
+ "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/atomicstrata/atomicmemory-integrations.git",
13
+ "directory": "plugins/hermes"
14
+ },
15
+ "files": [
16
+ "__init__.py",
17
+ "client.py",
18
+ "config.py",
19
+ "python_sdk.py",
20
+ "tools.py",
21
+ "breaker.py",
22
+ "worker.py",
23
+ "plugin.yaml",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "test": "python3 -m unittest discover tests"
28
+ }
29
+ }
package/plugin.yaml ADDED
@@ -0,0 +1,12 @@
1
+ name: atomicmemory
2
+ version: 0.1.10
3
+ description: "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default."
4
+ pip_dependencies:
5
+ - "atomicmemory>=1.0.1,<2.0.0"
6
+ hooks:
7
+ - system_prompt_block
8
+ - prefetch
9
+ - queue_prefetch
10
+ - sync_turn
11
+ - on_session_end
12
+ - shutdown
package/python_sdk.py ADDED
@@ -0,0 +1,392 @@
1
+ """Python SDK client adapter for the Hermes AtomicMemory provider.
2
+
3
+ This module is the only production implementation of the Hermes
4
+ ``AtomicMemoryClient`` protocol. It imports the published ``atomicmemory``
5
+ Python SDK, then routes shared reads through the generic SDK surface and
6
+ siloed reads through the AtomicMemory namespace where ``source_site`` is
7
+ supported.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .client import (
18
+ AtomicMemoryClient,
19
+ BridgeError,
20
+ ContextPackage,
21
+ IngestResult,
22
+ ListPage,
23
+ MemoryItem,
24
+ Message,
25
+ ProviderUnsupportedError,
26
+ Provenance,
27
+ Scope,
28
+ SearchPage,
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PythonSdkConfig:
34
+ """Runtime config needed to construct the Python SDK MemoryClient."""
35
+
36
+ provider: str = "atomicmemory"
37
+ api_url: str | None = None
38
+ api_key: str | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PythonSdkTypes:
43
+ """SDK classes used by this adapter.
44
+
45
+ Tests inject fakes here so adapter behavior stays deterministic without a
46
+ live core server or an installed SDK package.
47
+ """
48
+
49
+ MemoryClient: Any
50
+ UserScope: Any
51
+ AtomicMemorySearchRequest: Any
52
+ AtomicMemoryListOptions: Any
53
+
54
+
55
+ def sdk_is_available() -> bool:
56
+ """Return whether the published AtomicMemory Python SDK can be imported."""
57
+ try:
58
+ _load_sdk_types()
59
+ except ImportError:
60
+ return False
61
+ return True
62
+
63
+
64
+ class PythonSdkAtomicMemoryClient:
65
+ """AtomicMemoryClient implementation backed by the ``atomicmemory`` SDK."""
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ config: PythonSdkConfig,
71
+ sdk_types: PythonSdkTypes | None = None,
72
+ ) -> None:
73
+ self._config = config
74
+ self._types = sdk_types
75
+ self._client: Any | None = None
76
+
77
+ def initialize(self) -> None:
78
+ if self._client is not None:
79
+ return
80
+ if not self._config.api_url:
81
+ raise BridgeError("ATOMICMEMORY_API_URL is required", code="CONFIG_REQUIRED")
82
+ types = self._types or _load_sdk_types()
83
+ providers = {self._config.provider: _provider_config(self._config)}
84
+ self._client = types.MemoryClient(providers=providers, default_provider=self._config.provider)
85
+ self._client.initialize()
86
+ self._types = types
87
+
88
+ def shutdown(self) -> None:
89
+ client = self._client
90
+ self._client = None
91
+ if client is not None:
92
+ client.close()
93
+
94
+ def search(
95
+ self,
96
+ *,
97
+ query: str,
98
+ scope: Scope,
99
+ limit: int,
100
+ source_site: str | None = None,
101
+ ) -> SearchPage:
102
+ if source_site:
103
+ page = self._require_atomic().search(
104
+ self._types.AtomicMemorySearchRequest(query=query, limit=limit, source_site=source_site),
105
+ self._user_scope(scope),
106
+ )
107
+ return _search_page(page)
108
+ page = self._require_client().search({"query": query, "scope": scope, "limit": limit})
109
+ return _search_page(page)
110
+
111
+ def package(
112
+ self,
113
+ *,
114
+ query: str,
115
+ scope: Scope,
116
+ token_budget: int,
117
+ source_site: str | None = None,
118
+ ) -> ContextPackage:
119
+ if source_site:
120
+ page = self._require_atomic().search(
121
+ self._types.AtomicMemorySearchRequest(
122
+ query=query,
123
+ retrieval_mode="tiered",
124
+ token_budget=token_budget,
125
+ source_site=source_site,
126
+ skip_repair=True,
127
+ ),
128
+ self._user_scope(scope),
129
+ )
130
+ return _atomic_context_package(page)
131
+ package = self._require_client().package(
132
+ {"query": query, "scope": scope, "token_budget": token_budget, "format": "tiered"}
133
+ )
134
+ return _generic_context_package(package)
135
+
136
+ def list_recent(
137
+ self,
138
+ *,
139
+ scope: Scope,
140
+ limit: int,
141
+ source_site: str | None = None,
142
+ ) -> ListPage:
143
+ if source_site:
144
+ page = self._require_atomic().list(
145
+ self._user_scope(scope),
146
+ self._types.AtomicMemoryListOptions(limit=limit, source_site=source_site),
147
+ )
148
+ return _list_page(page)
149
+ page = self._require_client().list({"scope": scope, "limit": limit})
150
+ return _list_page(page)
151
+
152
+ def ingest_messages(
153
+ self,
154
+ *,
155
+ messages: list[Message],
156
+ scope: Scope,
157
+ provenance: Provenance,
158
+ metadata: dict[str, Any] | None = None,
159
+ ) -> IngestResult:
160
+ raw = self._require_client().ingest(
161
+ {
162
+ "mode": "messages",
163
+ "messages": [_message_dict(message) for message in messages],
164
+ "scope": scope,
165
+ "provenance": _provenance_dict(provenance),
166
+ "metadata": metadata,
167
+ }
168
+ )
169
+ return _ingest_result(raw)
170
+
171
+ def ingest_verbatim(
172
+ self,
173
+ *,
174
+ content: str,
175
+ scope: Scope,
176
+ provenance: Provenance,
177
+ metadata: dict[str, Any] | None = None,
178
+ ) -> IngestResult:
179
+ raw = self._require_client().ingest(
180
+ {
181
+ "mode": "verbatim",
182
+ "content": content,
183
+ "scope": scope,
184
+ "provenance": _provenance_dict(provenance),
185
+ "metadata": metadata,
186
+ }
187
+ )
188
+ return _ingest_result(raw)
189
+
190
+ def _require_client(self) -> Any:
191
+ if self._client is None:
192
+ raise BridgeError("AtomicMemory Python SDK client is not initialized", code="NOT_INITIALIZED")
193
+ return self._client
194
+
195
+ def _require_atomic(self) -> Any:
196
+ handle = self._require_client().atomicmemory
197
+ if handle is None:
198
+ raise ProviderUnsupportedError(
199
+ "source_site requires the AtomicMemory provider; the active client has no atomicmemory namespace"
200
+ )
201
+ return handle
202
+
203
+ def _user_scope(self, scope: Scope) -> Any:
204
+ user = scope.get("user")
205
+ if not user:
206
+ raise BridgeError("scope.user is required for source_site routing", code="INVALID_SCOPE")
207
+ return self._types.UserScope(user_id=user)
208
+
209
+
210
+ def _load_sdk_types() -> PythonSdkTypes:
211
+ plugin_roots = _plugin_roots()
212
+ removed_path_entries = _remove_plugin_import_roots(plugin_roots)
213
+ try:
214
+ saved_modules = _stash_plugin_atomicmemory_modules(plugin_roots)
215
+ try:
216
+ from atomicmemory import MemoryClient # type: ignore[import-not-found]
217
+ from atomicmemory.providers.atomicmemory.handle import ( # type: ignore[import-not-found]
218
+ AtomicMemoryListOptions,
219
+ AtomicMemorySearchRequest,
220
+ UserScope,
221
+ )
222
+ finally:
223
+ _restore_modules(saved_modules)
224
+ finally:
225
+ _restore_path_entries(removed_path_entries)
226
+
227
+ return PythonSdkTypes(
228
+ MemoryClient=MemoryClient,
229
+ UserScope=UserScope,
230
+ AtomicMemorySearchRequest=AtomicMemorySearchRequest,
231
+ AtomicMemoryListOptions=AtomicMemoryListOptions,
232
+ )
233
+
234
+
235
+ def _stash_plugin_atomicmemory_modules(plugin_roots: set[Path]) -> dict[str, Any]:
236
+ saved: dict[str, Any] = {}
237
+ for name, module in list(sys.modules.items()):
238
+ if not _is_atomicmemory_module(name):
239
+ continue
240
+ if _should_stash_atomicmemory_module(name, module, plugin_roots):
241
+ saved[name] = sys.modules.pop(name)
242
+ return saved
243
+
244
+
245
+ def _restore_modules(saved_modules: dict[str, Any]) -> None:
246
+ for name, module in saved_modules.items():
247
+ sys.modules[name] = module
248
+
249
+
250
+ def _is_atomicmemory_module(name: str) -> bool:
251
+ return name == "atomicmemory" or name.startswith("atomicmemory.")
252
+
253
+
254
+ def _plugin_roots() -> set[Path]:
255
+ roots = {Path(__file__).resolve().parent}
256
+ module = sys.modules.get("atomicmemory")
257
+ file_name = getattr(module, "__file__", None)
258
+ if file_name:
259
+ try:
260
+ roots.add(Path(file_name).resolve().parent)
261
+ except OSError:
262
+ pass
263
+ return roots
264
+
265
+
266
+ def _remove_plugin_import_roots(plugin_roots: set[Path]) -> list[tuple[int, str]]:
267
+ removed: list[tuple[int, str]] = []
268
+ for index, path_entry in reversed(list(enumerate(sys.path))):
269
+ if _path_entry_points_to_plugin(path_entry, plugin_roots):
270
+ removed.append((index, sys.path.pop(index)))
271
+ return list(reversed(removed))
272
+
273
+
274
+ def _restore_path_entries(removed_entries: list[tuple[int, str]]) -> None:
275
+ for index, path_entry in removed_entries:
276
+ sys.path.insert(min(index, len(sys.path)), path_entry)
277
+
278
+
279
+ def _path_entry_points_to_plugin(path_entry: str, plugin_roots: set[Path]) -> bool:
280
+ raw_path = Path(path_entry or ".").expanduser()
281
+ try:
282
+ candidate = (raw_path / "atomicmemory").resolve()
283
+ except OSError:
284
+ return False
285
+ return any(candidate == root for root in plugin_roots)
286
+
287
+
288
+ def _should_stash_atomicmemory_module(name: str, module: Any, plugin_roots: set[Path]) -> bool:
289
+ if any(_module_is_under(module, root) for root in plugin_roots):
290
+ return True
291
+ return name == "atomicmemory" and not hasattr(module, "MemoryClient")
292
+
293
+
294
+ def _module_is_under(module: Any, root: Path) -> bool:
295
+ file_name = getattr(module, "__file__", None)
296
+ if not file_name:
297
+ return False
298
+ try:
299
+ return Path(file_name).resolve().is_relative_to(root)
300
+ except OSError:
301
+ return False
302
+
303
+
304
+ def _provider_config(config: PythonSdkConfig) -> dict[str, str]:
305
+ provider_config = {"api_url": config.api_url or ""}
306
+ if config.api_key:
307
+ provider_config["api_key"] = config.api_key
308
+ return provider_config
309
+
310
+
311
+ def _message_dict(message: Message) -> dict[str, str]:
312
+ return {"role": message.role, "content": message.content}
313
+
314
+
315
+ def _provenance_dict(provenance: Provenance) -> dict[str, str | None]:
316
+ return {
317
+ "source": provenance.source,
318
+ "source_url": provenance.source_url,
319
+ "source_id": provenance.source_id,
320
+ "extractor": provenance.extractor,
321
+ }
322
+
323
+
324
+ def _search_page(page: Any) -> SearchPage:
325
+ hits = [_memory_from_hit(hit) for hit in _get(page, "results", [])]
326
+ return SearchPage(memories=hits, count=int(_get(page, "count", len(hits)) or len(hits)))
327
+
328
+
329
+ def _list_page(page: Any) -> ListPage:
330
+ memories = [_memory_item(memory) for memory in _get(page, "memories", [])]
331
+ return ListPage(memories=memories, count=int(_get(page, "count", len(memories)) or len(memories)))
332
+
333
+
334
+ def _generic_context_package(package: Any) -> ContextPackage:
335
+ hits = [_memory_from_hit(hit) for hit in _get(package, "results", [])]
336
+ return ContextPackage(
337
+ injection_text=str(_get(package, "text", "") or ""),
338
+ memories=hits,
339
+ count=len(hits),
340
+ estimated_context_tokens=int(_get(package, "tokens", 0) or 0),
341
+ )
342
+
343
+
344
+ def _atomic_context_package(page: Any) -> ContextPackage:
345
+ hits = [_memory_from_hit(hit) for hit in _get(page, "results", [])]
346
+ return ContextPackage(
347
+ injection_text=str(_get(page, "injection_text", "") or ""),
348
+ memories=hits,
349
+ count=int(_get(page, "count", len(hits)) or len(hits)),
350
+ estimated_context_tokens=int(_get(page, "estimated_context_tokens", 0) or 0),
351
+ citations=list(_get(page, "citations", []) or []),
352
+ )
353
+
354
+
355
+ def _memory_from_hit(hit: Any) -> MemoryItem:
356
+ return _memory_item(_get(hit, "memory"), _get(hit, "score"))
357
+
358
+
359
+ def _memory_item(memory: Any, score: float | None = None) -> MemoryItem:
360
+ provenance = _get(memory, "provenance")
361
+ source_site = _get(memory, "source_site") or _get(provenance, "source")
362
+ created_at = _get(memory, "created_at")
363
+ return MemoryItem(
364
+ id=_optional_str(_get(memory, "id")),
365
+ content=str(_get(memory, "content", "") or ""),
366
+ score=score,
367
+ source_site=_optional_str(source_site),
368
+ created_at=created_at.isoformat() if hasattr(created_at, "isoformat") else _optional_str(created_at),
369
+ )
370
+
371
+
372
+ def _ingest_result(raw: Any) -> IngestResult:
373
+ return IngestResult(
374
+ created=list(_get(raw, "created", []) or _get(raw, "stored_memory_ids", []) or []),
375
+ updated=list(_get(raw, "updated", []) or _get(raw, "updated_memory_ids", []) or []),
376
+ unchanged=list(_get(raw, "unchanged", []) or []),
377
+ )
378
+
379
+
380
+ def _get(value: Any, key: str, default: Any = None) -> Any:
381
+ if value is None:
382
+ return default
383
+ if isinstance(value, dict):
384
+ return value.get(key, default)
385
+ return getattr(value, key, default)
386
+
387
+
388
+ def _optional_str(value: Any) -> str | None:
389
+ return str(value) if value is not None else None
390
+
391
+
392
+ _check: AtomicMemoryClient = PythonSdkAtomicMemoryClient(config=PythonSdkConfig())