@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/README.md +157 -0
- package/__init__.py +333 -0
- package/breaker.py +57 -0
- package/client.py +161 -0
- package/config.py +236 -0
- package/package.json +29 -0
- package/plugin.yaml +12 -0
- package/python_sdk.py +392 -0
- package/tools.py +193 -0
- package/worker.py +113 -0
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())
|