@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 ADDED
@@ -0,0 +1,157 @@
1
+ # AtomicMemory for Hermes Agent
2
+
3
+ AtomicMemory is a native Hermes memory provider. It participates in Hermes'
4
+ memory lifecycle: background recall is prefetched for the next turn,
5
+ completed turns are synced without blocking the chat loop, and the agent
6
+ gets explicit tools for searching and storing durable facts.
7
+
8
+ By default, recall is **shared** across every AtomicMemory tool the user
9
+ has touched (Claude Code, Codex, the web extension, etc.). Set
10
+ `memory_scope=siloed` to restrict Hermes to Hermes-ingested memories only.
11
+
12
+ ## Architecture
13
+
14
+ ```
15
+ Hermes Agent (Python)
16
+ → plugins/memory/atomicmemory/__init__.py
17
+ → AtomicMemoryClient (Python protocol)
18
+ → PythonSdkAtomicMemoryClient
19
+ → published atomicmemory Python SDK MemoryClient
20
+ ```
21
+
22
+ The Python provider owns Hermes lifecycle compatibility only — registration,
23
+ hooks, tool schemas. Memory semantics flow through the published Python SDK.
24
+
25
+ ## Prerequisites
26
+
27
+ - Hermes Agent installed and `HERMES_HOME` set
28
+ - AtomicMemory core URL exported as `ATOMICMEMORY_API_URL`
29
+
30
+ ## Install (dev)
31
+
32
+ The simplest dev install symlinks the plugin into Hermes' memory directory.
33
+ Hermes installs the published `atomicmemory` SDK from `plugin.yaml`.
34
+
35
+ ```bash
36
+ cd /path/to/atomicmemory-integrations
37
+ mkdir -p "$HERMES_HOME/plugins/memory"
38
+ ln -s "$(pwd)/plugins/hermes" "$HERMES_HOME/plugins/memory/atomicmemory"
39
+ export ATOMICMEMORY_API_URL="http://localhost:3050"
40
+ ```
41
+
42
+ Then select and verify the provider:
43
+
44
+ ```bash
45
+ hermes memory setup
46
+ # select "atomicmemory"
47
+ hermes memory status
48
+ # confirm "atomicmemory" is active
49
+ ```
50
+
51
+ ## Config
52
+
53
+ Hermes' setup wizard prompts for a minimal pair (`scope_user`, `memory_scope`).
54
+ Advanced settings live in `$HERMES_HOME/atomicmemory.json`.
55
+
56
+ Connection details (`ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`) flow
57
+ through environment variables into the Python SDK. The provider does not
58
+ have a default API URL and fails to start if `ATOMICMEMORY_API_URL` is unset.
59
+
60
+ ### Environment
61
+
62
+ | Env var | Purpose |
63
+ |---|---|
64
+ | `ATOMICMEMORY_API_URL` | AtomicMemory core URL. Required. |
65
+ | `ATOMICMEMORY_API_KEY` | Bearer credential for AtomicMemory core. Optional. |
66
+ | `ATOMICMEMORY_PROVIDER` | SDK provider name. Defaults to `atomicmemory`. |
67
+ | `ATOMICMEMORY_SCOPE_USER` | Hermes user identity. Defaults to `$USER`. |
68
+ | `ATOMICMEMORY_MEMORY_SCOPE` | `shared` (default) or `siloed`. |
69
+ | `ATOMICMEMORY_MEMORY_MODE` | `hybrid` (default), `context`, or `tools`. |
70
+ | `ATOMICMEMORY_PREFETCH_ENABLED` | `true`/`false`. Default `true`. |
71
+ | `ATOMICMEMORY_PREFETCH_METHOD` | `context` (default) or `fast`. |
72
+ | `ATOMICMEMORY_SEARCH_LIMIT` | Default search/list limit. |
73
+ | `ATOMICMEMORY_TOKEN_BUDGET` | Default context-package token budget. |
74
+
75
+ ### Provider-local file
76
+
77
+ `$HERMES_HOME/atomicmemory.json` accepts these keys (all optional):
78
+
79
+ | Key | Description |
80
+ |---|---|
81
+ | `scope_user` | User identity. |
82
+ | `scope_agent` | Hermes prompt label (does not change scoping). |
83
+ | `memory_mode` | `hybrid` / `context` / `tools`. |
84
+ | `memory_scope` | `shared` / `siloed`. |
85
+ | `prefetch_enabled` | bool. |
86
+ | `prefetch_method` | `context` / `fast`. |
87
+ | `search_limit` | int. |
88
+ | `token_budget` | int. |
89
+
90
+ Secrets are never persisted here — `api_key` and `api_url` are deliberately
91
+ not in the allowed key set.
92
+
93
+ ## Memory scope
94
+
95
+ | Mode | Recall | Ingest |
96
+ |---|---|---|
97
+ | `shared` (default) | All AtomicMemory memories for the user | stamped `source_site=hermes` |
98
+ | `siloed` | Only Hermes-ingested memories | stamped `source_site=hermes` |
99
+
100
+ The `source_site` filter on recall is enforced through the Python SDK's
101
+ AtomicMemory namespace handle. If the SDK is configured against a
102
+ non-AtomicMemory provider (e.g. mem0), `siloed` mode fails loudly with
103
+ `PROVIDER_UNSUPPORTED` rather than silently dropping the filter.
104
+
105
+ ## Memory mode
106
+
107
+ `memory_mode` selects which Hermes surfaces AtomicMemory exposes:
108
+
109
+ | Mode | Auto-recall + sync | Explicit tools |
110
+ |---|---|---|
111
+ | `hybrid` (default) | yes | yes |
112
+ | `context` | yes | hidden |
113
+ | `tools` | disabled | yes |
114
+
115
+ ## Tools
116
+
117
+ | Tool | Description |
118
+ |---|---|
119
+ | `atomicmemory_search` | Search AtomicMemory by meaning. |
120
+ | `atomicmemory_context` | Build an injection-ready context package. |
121
+ | `atomicmemory_conclude` | Store one explicit durable fact verbatim. |
122
+ | `atomicmemory_profile` | List recent records (description text varies by `memory_scope`). |
123
+
124
+ ## Lifecycle
125
+
126
+ - `queue_prefetch(query)` searches AtomicMemory in a background thread, with
127
+ a generation counter so a slow earlier prefetch can't overwrite a faster
128
+ newer one.
129
+ - `prefetch(query)` returns the most recent completed recall, then clears
130
+ the slot.
131
+ - `sync_turn(user, assistant)` enqueues the turn to a single-writer worker
132
+ thread and returns immediately. The worker calls
133
+ `client.ingest_messages(...)` with `provenance.source = "hermes"` and
134
+ `provenance.sourceUrl = "hermes://session/<session_id>"`.
135
+ - `on_session_end(messages)` drains the worker, then closes the SDK client.
136
+
137
+ ## Reliability
138
+
139
+ A circuit breaker pauses SDK calls for two minutes after five
140
+ consecutive failures and resets on the next success. Hermes continues to
141
+ run while AtomicMemory is temporarily unavailable.
142
+
143
+ ## Troubleshooting
144
+
145
+ | Symptom | Likely cause |
146
+ |---|---|
147
+ | Provider does not appear in `hermes memory setup` | Wrong install path. Memory providers must live under `$HERMES_HOME/plugins/memory/<name>/`, not `$HERMES_HOME/plugins/<name>/`. |
148
+ | `is_available()` returns False | `ATOMICMEMORY_API_URL` unset, or the Hermes Python environment did not install the `atomicmemory` dependency from `plugin.yaml`. |
149
+ | Import fails at startup | The Hermes Python environment is missing the SDK dependency from `plugin.yaml`. |
150
+ | Calls fail with `PROVIDER_UNSUPPORTED` while `memory_scope=siloed` | The configured SDK provider is not the AtomicMemory core (e.g. it's `mem0`). Either switch `ATOMICMEMORY_PROVIDER=atomicmemory` or move to `memory_scope=shared`. |
151
+
152
+ ## Tests
153
+
154
+ ```bash
155
+ # Python provider and SDK adapter (deterministic, no network)
156
+ python3 -m unittest discover plugins/hermes/tests
157
+ ```
package/__init__.py ADDED
@@ -0,0 +1,333 @@
1
+ """AtomicMemory native memory provider for Hermes Agent (v2).
2
+
3
+ Thin Python adapter over the AtomicMemory Python SDK. The provider owns Hermes
4
+ lifecycle compatibility (provider registration, hook dispatch, tool schemas,
5
+ worker supervision); all memory semantics flow through `AtomicMemoryClient`.
6
+
7
+ Default scope is `shared` — Hermes recalls memories from every AtomicMemory
8
+ tool the user has touched. Set `memory_scope=siloed` to restrict recall to
9
+ Hermes-ingested memories only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import threading
18
+ from typing import Any, Callable
19
+
20
+ from .breaker import CircuitBreaker
21
+ from .client import (
22
+ AtomicMemoryClient,
23
+ BridgeError,
24
+ Message,
25
+ Provenance,
26
+ )
27
+ from .config import (
28
+ DEFAULT_USER_ID,
29
+ SOURCE_SITE,
30
+ ProviderConfig,
31
+ get_config_schema,
32
+ ingest_scope_dict,
33
+ load_config,
34
+ read_scope_kwargs,
35
+ save_config,
36
+ )
37
+ from .python_sdk import (
38
+ PythonSdkAtomicMemoryClient,
39
+ PythonSdkConfig,
40
+ sdk_is_available,
41
+ )
42
+ from .tools import (
43
+ CONCLUDE_SCHEMA,
44
+ CONTEXT_SCHEMA,
45
+ SEARCH_SCHEMA,
46
+ TOOL_HANDLERS,
47
+ format_memory_bullets,
48
+ profile_schema,
49
+ )
50
+ from .worker import IngestJob, IngestWorker
51
+
52
+
53
+ try:
54
+ from agent.memory_provider import MemoryProvider # type: ignore[import-not-found]
55
+ except Exception: # pragma: no cover — used only outside Hermes test runs.
56
+ class MemoryProvider: # type: ignore[no-redef]
57
+ """Fallback base class so the provider can be unit-tested standalone."""
58
+
59
+
60
+ try:
61
+ from tools.registry import tool_error # type: ignore[import-not-found]
62
+ except Exception: # pragma: no cover — used only outside Hermes test runs.
63
+ def tool_error(message: str) -> str:
64
+ return json.dumps({"error": message})
65
+
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ class AtomicMemoryMemoryProvider(MemoryProvider):
71
+ """Hermes-compatible AtomicMemory memory provider."""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ client_factory: Callable[[ProviderConfig], AtomicMemoryClient] | None = None,
77
+ ) -> None:
78
+ self._client_factory = client_factory or _default_client_factory
79
+ self._client: AtomicMemoryClient | None = None
80
+ self._config: ProviderConfig = ProviderConfig()
81
+ self._session_id: str = ""
82
+ self._user_id: str = DEFAULT_USER_ID
83
+
84
+ self._breaker = CircuitBreaker()
85
+ self._worker: IngestWorker | None = None
86
+
87
+ self._prefetch_lock = threading.Lock()
88
+ self._prefetch_result: str = ""
89
+ self._prefetch_generation: int = 0
90
+ self._prefetch_thread: threading.Thread | None = None
91
+
92
+ # ------------------------------------------------------------------
93
+ # Hermes provider contract
94
+ # ------------------------------------------------------------------
95
+
96
+ @property
97
+ def name(self) -> str:
98
+ return "atomicmemory"
99
+
100
+ def is_available(self) -> bool:
101
+ if not os.environ.get("ATOMICMEMORY_API_URL"):
102
+ return False
103
+ return sdk_is_available()
104
+
105
+ def get_config_schema(self) -> list[dict[str, Any]]:
106
+ return get_config_schema()
107
+
108
+ def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
109
+ save_config(values, hermes_home)
110
+
111
+ def initialize(self, session_id: str, **kwargs: Any) -> None:
112
+ hermes_home = kwargs.get("hermes_home")
113
+ self._config = load_config(hermes_home=hermes_home)
114
+ self._user_id = (
115
+ _clean_str(kwargs.get("user_id"))
116
+ or self._config.scope_user
117
+ or DEFAULT_USER_ID
118
+ )
119
+ self._session_id = session_id
120
+ client = self._client_factory(self._config)
121
+ client.initialize()
122
+ self._client = client
123
+ self._worker = IngestWorker(
124
+ run_job=self._run_ingest_job,
125
+ on_failure=self._on_ingest_failure,
126
+ )
127
+ self._worker.start()
128
+
129
+ def system_prompt_block(self) -> str:
130
+ action = (
131
+ "Use atomicmemory_search for targeted recall, atomicmemory_context for broad context, "
132
+ "and atomicmemory_conclude to store explicit durable facts."
133
+ if self._tools_enabled()
134
+ else "AtomicMemory recall is injected automatically; explicit AtomicMemory tools are disabled."
135
+ )
136
+ return (
137
+ "# AtomicMemory\n"
138
+ f"Active. User: {self._user_id}. Memory mode: {self._config.memory_mode}. "
139
+ f"Scope: {self._config.memory_scope}.\n"
140
+ f"{action}"
141
+ )
142
+
143
+ def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
144
+ del session_id
145
+ if not self._context_enabled() or not self._config.prefetch_enabled:
146
+ return
147
+ if self._breaker.is_open() or not query:
148
+ return
149
+ with self._prefetch_lock:
150
+ self._prefetch_generation += 1
151
+ generation = self._prefetch_generation
152
+
153
+ def _run() -> None:
154
+ try:
155
+ result = self._prefetch_text(query)
156
+ if result:
157
+ with self._prefetch_lock:
158
+ if generation == self._prefetch_generation:
159
+ self._prefetch_result = result
160
+ self._breaker.record_success()
161
+ except Exception as exc: # noqa: BLE001
162
+ self._breaker.record_failure()
163
+ logger.debug("AtomicMemory prefetch failed: %s", exc)
164
+
165
+ self._prefetch_thread = threading.Thread(
166
+ target=_run, daemon=True, name="atomicmemory-prefetch",
167
+ )
168
+ self._prefetch_thread.start()
169
+
170
+ def prefetch(self, query: str, *, session_id: str = "") -> str:
171
+ del query, session_id
172
+ if not self._context_enabled():
173
+ return ""
174
+ thread = self._prefetch_thread
175
+ if thread is not None and thread.is_alive():
176
+ thread.join(timeout=3.0)
177
+ with self._prefetch_lock:
178
+ result = self._prefetch_result
179
+ self._prefetch_result = ""
180
+ return f"## AtomicMemory\n{result}" if result else ""
181
+
182
+ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
183
+ if not self._context_enabled() or self._breaker.is_open() or self._worker is None:
184
+ return
185
+ self._worker.submit(
186
+ IngestJob(
187
+ user_content=user_content,
188
+ assistant_content=assistant_content,
189
+ session_id=session_id or self._session_id,
190
+ ),
191
+ )
192
+
193
+ def on_session_end(self, messages: list[dict[str, Any]]) -> None:
194
+ del messages
195
+ self.shutdown()
196
+
197
+ def shutdown(self) -> None:
198
+ if self._worker is not None:
199
+ self._worker.shutdown()
200
+ self._worker = None
201
+ if self._client is not None:
202
+ try:
203
+ self._client.shutdown()
204
+ except Exception as exc: # noqa: BLE001
205
+ logger.debug("AtomicMemory client shutdown failed: %s", exc)
206
+ self._client = None
207
+
208
+ def get_tool_schemas(self) -> list[dict[str, Any]]:
209
+ if not self._tools_enabled():
210
+ return []
211
+ return [
212
+ profile_schema(self._config.memory_scope),
213
+ SEARCH_SCHEMA,
214
+ CONTEXT_SCHEMA,
215
+ CONCLUDE_SCHEMA,
216
+ ]
217
+
218
+ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs: Any) -> str:
219
+ del kwargs
220
+ if not self._tools_enabled():
221
+ return tool_error("AtomicMemory tools are disabled by memory_mode.")
222
+ if self._breaker.is_open():
223
+ return json.dumps(
224
+ {
225
+ "error": (
226
+ "AtomicMemory client temporarily unavailable after repeated failures. "
227
+ "It will retry automatically."
228
+ ),
229
+ },
230
+ )
231
+ handler = TOOL_HANDLERS.get(tool_name)
232
+ if handler is None:
233
+ return tool_error(f"Unknown tool: {tool_name}")
234
+ try:
235
+ result = handler(self, args)
236
+ self._breaker.record_success()
237
+ return result
238
+ except Exception as exc: # noqa: BLE001
239
+ self._breaker.record_failure()
240
+ return tool_error(str(exc))
241
+
242
+ # ------------------------------------------------------------------
243
+ # Helpers shared with tools.py
244
+ # ------------------------------------------------------------------
245
+
246
+ def _context_enabled(self) -> bool:
247
+ return self._config.memory_mode in {"hybrid", "context"}
248
+
249
+ def _tools_enabled(self) -> bool:
250
+ return self._config.memory_mode in {"hybrid", "tools"}
251
+
252
+ def _require_client(self) -> AtomicMemoryClient:
253
+ if self._client is None:
254
+ raise BridgeError("AtomicMemory client not initialized", code="NOT_INITIALIZED")
255
+ return self._client
256
+
257
+ def _ingest_provenance(self, *, session_id: str) -> Provenance:
258
+ return Provenance(
259
+ source=SOURCE_SITE,
260
+ source_url=f"hermes://session/{session_id or self._session_id or 'default'}",
261
+ )
262
+
263
+ def _read_kwargs(self, *, limit_override: int | None = None) -> dict[str, Any]:
264
+ kwargs = read_scope_kwargs(memory_scope=self._config.memory_scope, user_id=self._user_id)
265
+ kwargs["limit"] = limit_override if limit_override is not None else self._config.search_limit
266
+ return kwargs
267
+
268
+ # ------------------------------------------------------------------
269
+ # Prefetch + ingest worker
270
+ # ------------------------------------------------------------------
271
+
272
+ def _prefetch_text(self, query: str) -> str:
273
+ client = self._require_client()
274
+ kwargs = read_scope_kwargs(memory_scope=self._config.memory_scope, user_id=self._user_id)
275
+ if self._config.prefetch_method == "fast":
276
+ page = client.search(
277
+ query=query,
278
+ scope=kwargs["scope"],
279
+ limit=self._config.search_limit,
280
+ source_site=kwargs.get("source_site"),
281
+ )
282
+ return format_memory_bullets(page)
283
+ package = client.package(
284
+ query=query,
285
+ scope=kwargs["scope"],
286
+ token_budget=self._config.token_budget,
287
+ source_site=kwargs.get("source_site"),
288
+ )
289
+ if package.injection_text:
290
+ return package.injection_text
291
+ return format_memory_bullets(package)
292
+
293
+ def _run_ingest_job(self, job: IngestJob) -> None:
294
+ if self._client is None:
295
+ return
296
+ try:
297
+ self._client.ingest_messages(
298
+ messages=[
299
+ Message(role="user", content=job.user_content),
300
+ Message(role="assistant", content=job.assistant_content),
301
+ ],
302
+ scope=ingest_scope_dict(user_id=self._user_id),
303
+ provenance=self._ingest_provenance(session_id=job.session_id),
304
+ metadata={"kind": "turn"},
305
+ )
306
+ self._breaker.record_success()
307
+ except Exception:
308
+ self._breaker.record_failure()
309
+ raise
310
+
311
+ def _on_ingest_failure(self, exc: BaseException) -> None:
312
+ logger.warning("AtomicMemory ingest failed: %s", exc)
313
+
314
+
315
+ def _default_client_factory(config: ProviderConfig) -> AtomicMemoryClient:
316
+ return PythonSdkAtomicMemoryClient(
317
+ config=PythonSdkConfig(
318
+ provider=os.environ.get("ATOMICMEMORY_PROVIDER", "atomicmemory"),
319
+ api_url=os.environ.get("ATOMICMEMORY_API_URL"),
320
+ api_key=os.environ.get("ATOMICMEMORY_API_KEY"),
321
+ )
322
+ )
323
+
324
+
325
+ def _clean_str(value: Any) -> str | None:
326
+ if value is None:
327
+ return None
328
+ cleaned = str(value).strip()
329
+ return cleaned or None
330
+
331
+
332
+ def register(ctx: Any) -> None:
333
+ ctx.register_memory_provider(AtomicMemoryMemoryProvider())
package/breaker.py ADDED
@@ -0,0 +1,57 @@
1
+ """Thread-safe circuit breaker — restored from v1, with a real lock.
2
+
3
+ Wraps every SDK call. After `threshold` consecutive failures the breaker
4
+ opens for `cooldown_seconds`; the next probing call after cooldown reads as
5
+ closed (the counter is cleared eagerly on the read so a single success
6
+ proves recovery).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class CircuitBreaker:
22
+ threshold: int = 5
23
+ cooldown_seconds: float = 120.0
24
+ _consecutive_failures: int = 0
25
+ _open_until_monotonic: float = 0.0
26
+ _lock: threading.Lock = None # type: ignore[assignment]
27
+
28
+ def __post_init__(self) -> None:
29
+ if self._lock is None:
30
+ self._lock = threading.Lock()
31
+
32
+ def is_open(self, *, now: float | None = None) -> bool:
33
+ clock = now if now is not None else time.monotonic()
34
+ with self._lock:
35
+ if self._consecutive_failures < self.threshold:
36
+ return False
37
+ if clock >= self._open_until_monotonic:
38
+ self._consecutive_failures = 0
39
+ return False
40
+ return True
41
+
42
+ def record_success(self) -> None:
43
+ with self._lock:
44
+ self._consecutive_failures = 0
45
+
46
+ def record_failure(self, *, now: float | None = None) -> None:
47
+ clock = now if now is not None else time.monotonic()
48
+ with self._lock:
49
+ self._consecutive_failures += 1
50
+ if self._consecutive_failures >= self.threshold:
51
+ self._open_until_monotonic = clock + self.cooldown_seconds
52
+ logger.warning(
53
+ "AtomicMemory circuit breaker tripped after %d consecutive failures. "
54
+ "Pausing calls for %.0fs.",
55
+ self._consecutive_failures,
56
+ self.cooldown_seconds,
57
+ )
package/client.py ADDED
@@ -0,0 +1,161 @@
1
+ """AtomicMemoryClient protocol.
2
+
3
+ Narrow seam between the Hermes Python provider and the AtomicMemory Python SDK.
4
+ The provider depends only on this interface so lifecycle code, tool handlers,
5
+ scope policy, and tests are isolated from SDK-specific response shapes.
6
+
7
+ The shapes here intentionally drop SDK fields the Hermes integration does
8
+ not consume (importance, tier assignments, observability blocks). Add them
9
+ when a call site needs them, not before.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Protocol
16
+
17
+
18
+ Scope = dict[str, Any]
19
+ """Scope dict matching the V3 MemoryClient shape: {user, agent?, namespace?, thread?}.
20
+
21
+ Hermes uses user-only scope. Workspace scope is left available for future
22
+ provider modes but is not exercised in v2.
23
+ """
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Message:
28
+ """One turn message used by ingest_messages."""
29
+
30
+ role: str
31
+ content: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Provenance:
36
+ """Ingest provenance. source is required by Hermes; everything else optional."""
37
+
38
+ source: str
39
+ source_url: str | None = None
40
+ source_id: str | None = None
41
+ extractor: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class MemoryItem:
46
+ """Minimal memory record consumed by Hermes tools (search/list/profile)."""
47
+
48
+ id: str | None
49
+ content: str
50
+ score: float | None = None
51
+ source_site: str | None = None
52
+ created_at: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SearchPage:
57
+ memories: list[MemoryItem] = field(default_factory=list)
58
+ count: int = 0
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ContextPackage:
63
+ injection_text: str = ""
64
+ memories: list[MemoryItem] = field(default_factory=list)
65
+ count: int = 0
66
+ estimated_context_tokens: int = 0
67
+ citations: list[str] = field(default_factory=list)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ListPage:
72
+ memories: list[MemoryItem] = field(default_factory=list)
73
+ count: int = 0
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class IngestResult:
78
+ created: list[str] = field(default_factory=list)
79
+ updated: list[str] = field(default_factory=list)
80
+ unchanged: list[str] = field(default_factory=list)
81
+
82
+
83
+ class BridgeError(RuntimeError):
84
+ """Bridge call returned a typed error."""
85
+
86
+ def __init__(self, message: str, code: str | None = None) -> None:
87
+ super().__init__(message)
88
+ self.code = code
89
+
90
+
91
+ class BridgeCrashedError(BridgeError):
92
+ """Client transport exited unexpectedly mid-call."""
93
+
94
+ def __init__(self, message: str = "AtomicMemory client transport exited") -> None:
95
+ super().__init__(message, code="BRIDGE_CRASHED")
96
+
97
+
98
+ class ProviderUnsupportedError(BridgeError):
99
+ """Client returned PROVIDER_UNSUPPORTED — the active provider lacks a feature.
100
+
101
+ Today this happens when source_site is requested with a non-AtomicMemory
102
+ provider. The client fails loudly rather than silently dropping the filter.
103
+ """
104
+
105
+ def __init__(self, message: str) -> None:
106
+ super().__init__(message, code="PROVIDER_UNSUPPORTED")
107
+
108
+
109
+ class AtomicMemoryClient(Protocol):
110
+ """Narrow client surface used by the Hermes provider.
111
+
112
+ The production Python SDK implementation lives in python_sdk.py.
113
+ """
114
+
115
+ def initialize(self) -> None: ...
116
+
117
+ def shutdown(self) -> None: ...
118
+
119
+ def search(
120
+ self,
121
+ *,
122
+ query: str,
123
+ scope: Scope,
124
+ limit: int,
125
+ source_site: str | None = None,
126
+ ) -> SearchPage: ...
127
+
128
+ def package(
129
+ self,
130
+ *,
131
+ query: str,
132
+ scope: Scope,
133
+ token_budget: int,
134
+ source_site: str | None = None,
135
+ ) -> ContextPackage: ...
136
+
137
+ def list_recent(
138
+ self,
139
+ *,
140
+ scope: Scope,
141
+ limit: int,
142
+ source_site: str | None = None,
143
+ ) -> ListPage: ...
144
+
145
+ def ingest_messages(
146
+ self,
147
+ *,
148
+ messages: list[Message],
149
+ scope: Scope,
150
+ provenance: Provenance,
151
+ metadata: dict[str, Any] | None = None,
152
+ ) -> IngestResult: ...
153
+
154
+ def ingest_verbatim(
155
+ self,
156
+ *,
157
+ content: str,
158
+ scope: Scope,
159
+ provenance: Provenance,
160
+ metadata: dict[str, Any] | None = None,
161
+ ) -> IngestResult: ...