@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/tools.py ADDED
@@ -0,0 +1,193 @@
1
+ """Tool schemas + handler dispatch for the Hermes memory provider.
2
+
3
+ Lives outside `__init__.py` so the provider class stays under the workspace
4
+ LOC ceiling. The handlers depend only on the provider's public-ish helpers
5
+ (`_require_client`, `_user_id`, `_session_id`, `_config`, `_read_kwargs`,
6
+ `_ingest_provenance`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import TYPE_CHECKING, Any, Callable
13
+
14
+ from .config import ingest_scope_dict, read_scope_kwargs
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from . import AtomicMemoryMemoryProvider
19
+
20
+
21
+ try:
22
+ from tools.registry import tool_error # type: ignore[import-not-found]
23
+ except Exception: # pragma: no cover — used only outside Hermes test runs.
24
+ def tool_error(message: str) -> str:
25
+ return json.dumps({"error": message})
26
+
27
+
28
+ SEARCH_SCHEMA = {
29
+ "name": "atomicmemory_search",
30
+ "description": "Search AtomicMemory by meaning for prior user preferences, project context, decisions, and facts.",
31
+ "parameters": {
32
+ "type": "object",
33
+ "properties": {
34
+ "query": {"type": "string", "description": "What to search for."},
35
+ "top_k": {
36
+ "type": "integer",
37
+ "description": "Maximum results to return. Defaults to provider config, max 50.",
38
+ },
39
+ },
40
+ "required": ["query"],
41
+ },
42
+ }
43
+
44
+ CONTEXT_SCHEMA = {
45
+ "name": "atomicmemory_context",
46
+ "description": "Build an injection-ready AtomicMemory context package for a broad query.",
47
+ "parameters": {
48
+ "type": "object",
49
+ "properties": {
50
+ "query": {"type": "string", "description": "The topic to assemble context for."},
51
+ "token_budget": {
52
+ "type": "integer",
53
+ "description": "Approximate context budget. Defaults to provider config.",
54
+ },
55
+ },
56
+ "required": ["query"],
57
+ },
58
+ }
59
+
60
+ CONCLUDE_SCHEMA = {
61
+ "name": "atomicmemory_conclude",
62
+ "description": "Store one explicit durable fact, preference, correction, or decision in AtomicMemory.",
63
+ "parameters": {
64
+ "type": "object",
65
+ "properties": {
66
+ "conclusion": {"type": "string", "description": "The fact to store verbatim."},
67
+ },
68
+ "required": ["conclusion"],
69
+ },
70
+ }
71
+
72
+
73
+ def profile_schema(memory_scope: str) -> dict[str, Any]:
74
+ description = (
75
+ "List recent records for the current Hermes user from any AtomicMemory tool."
76
+ if memory_scope == "shared"
77
+ else "List recent Hermes-source AtomicMemory records for the current user."
78
+ )
79
+ return {
80
+ "name": "atomicmemory_profile",
81
+ "description": description,
82
+ "parameters": {
83
+ "type": "object",
84
+ "properties": {
85
+ "limit": {
86
+ "type": "integer",
87
+ "description": "Maximum records to return. Defaults to provider config, max 50.",
88
+ },
89
+ },
90
+ "required": [],
91
+ },
92
+ }
93
+
94
+
95
+ def _handle_search(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) -> str:
96
+ query = _clean_str(args.get("query"))
97
+ if not query:
98
+ return tool_error("Missing required parameter: query")
99
+ limit = _bounded_int(args.get("top_k"), provider._config.search_limit, 50)
100
+ kwargs = provider._read_kwargs(limit_override=limit)
101
+ page = provider._require_client().search(query=query, **kwargs)
102
+ if not page.memories:
103
+ return json.dumps({"result": "No relevant memories found.", "count": 0})
104
+ return json.dumps({"results": _serialize_memories(page), "count": page.count or len(page.memories)})
105
+
106
+
107
+ def _handle_context(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) -> str:
108
+ query = _clean_str(args.get("query"))
109
+ if not query:
110
+ return tool_error("Missing required parameter: query")
111
+ token_budget = _bounded_int(args.get("token_budget"), provider._config.token_budget, 50000)
112
+ kwargs = read_scope_kwargs(memory_scope=provider._config.memory_scope, user_id=provider._user_id)
113
+ package = provider._require_client().package(
114
+ query=query,
115
+ scope=kwargs["scope"],
116
+ token_budget=token_budget,
117
+ source_site=kwargs.get("source_site"),
118
+ )
119
+ return json.dumps(
120
+ {
121
+ "result": package.injection_text,
122
+ "results": _serialize_memories(package),
123
+ "count": package.count or len(package.memories),
124
+ "estimated_context_tokens": package.estimated_context_tokens,
125
+ "citations": package.citations,
126
+ },
127
+ )
128
+
129
+
130
+ def _handle_conclude(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) -> str:
131
+ conclusion = _clean_str(args.get("conclusion"))
132
+ if not conclusion:
133
+ return tool_error("Missing required parameter: conclusion")
134
+ provider._require_client().ingest_verbatim(
135
+ content=conclusion,
136
+ scope=ingest_scope_dict(user_id=provider._user_id),
137
+ provenance=provider._ingest_provenance(session_id=provider._session_id),
138
+ metadata={"kind": "fact"},
139
+ )
140
+ return json.dumps({"result": "Fact stored."})
141
+
142
+
143
+ def _handle_profile(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) -> str:
144
+ limit = _bounded_int(args.get("limit"), provider._config.search_limit, 50)
145
+ kwargs = provider._read_kwargs(limit_override=limit)
146
+ page = provider._require_client().list_recent(**kwargs)
147
+ if not page.memories:
148
+ return json.dumps({"result": "No memories stored yet.", "count": 0})
149
+ return json.dumps({"results": _serialize_memories(page), "count": page.count or len(page.memories)})
150
+
151
+
152
+ TOOL_HANDLERS: dict[str, Callable[[AtomicMemoryMemoryProvider, dict[str, Any]], str]] = {
153
+ "atomicmemory_search": _handle_search,
154
+ "atomicmemory_context": _handle_context,
155
+ "atomicmemory_conclude": _handle_conclude,
156
+ "atomicmemory_profile": _handle_profile,
157
+ }
158
+
159
+
160
+ def serialize_memories(page: Any) -> list[dict[str, Any]]:
161
+ return _serialize_memories(page)
162
+
163
+
164
+ def format_memory_bullets(page: Any) -> str:
165
+ return "\n".join(f"- {item.content}" for item in page.memories if item.content)
166
+
167
+
168
+ def _serialize_memories(page: Any) -> list[dict[str, Any]]:
169
+ return [
170
+ {
171
+ "id": item.id,
172
+ "memory": item.content,
173
+ "score": item.score,
174
+ "source_site": item.source_site,
175
+ "created_at": item.created_at,
176
+ }
177
+ for item in page.memories
178
+ ]
179
+
180
+
181
+ def _bounded_int(value: Any, default: int, maximum: int) -> int:
182
+ try:
183
+ result = int(value)
184
+ except (TypeError, ValueError):
185
+ result = default
186
+ return max(1, min(result, maximum))
187
+
188
+
189
+ def _clean_str(value: Any) -> str | None:
190
+ if value is None:
191
+ return None
192
+ cleaned = str(value).strip()
193
+ return cleaned or None
package/worker.py ADDED
@@ -0,0 +1,113 @@
1
+ """IngestWorker — single-writer queue + daemon thread for non-blocking sync_turn.
2
+
3
+ Replaces the v1 per-call thread model that leaked orphans on join timeouts.
4
+ Hermes' chat loop calls `submit()` (non-blocking); the daemon worker drains
5
+ the queue serially, calling the supplied ingest function on each entry.
6
+
7
+ `shutdown()` posts a sentinel and joins the worker (bounded). Drops oldest
8
+ on a full queue with a single warning log per drop.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import queue
15
+ import threading
16
+ from dataclasses import dataclass
17
+ from typing import Any, Callable
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class IngestJob:
25
+ """Payload passed from sync_turn into the worker queue."""
26
+
27
+ user_content: str
28
+ assistant_content: str
29
+ session_id: str
30
+
31
+
32
+ _SHUTDOWN_SENTINEL = object()
33
+
34
+
35
+ class IngestWorker:
36
+ """Single-writer worker that drains queued ingest jobs in order."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ run_job: Callable[[IngestJob], None],
42
+ max_queue_size: int = 32,
43
+ on_failure: Callable[[BaseException], None] | None = None,
44
+ ) -> None:
45
+ self._run_job = run_job
46
+ self._on_failure = on_failure
47
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=max_queue_size)
48
+ self._thread: threading.Thread | None = None
49
+ self._started = threading.Event()
50
+ self._stopped = threading.Event()
51
+
52
+ def start(self) -> None:
53
+ if self._thread is not None:
54
+ return
55
+ self._thread = threading.Thread(
56
+ target=self._run,
57
+ daemon=True,
58
+ name="atomicmemory-ingest-worker",
59
+ )
60
+ self._thread.start()
61
+ self._started.set()
62
+
63
+ def submit(self, job: IngestJob) -> bool:
64
+ """Non-blocking enqueue. Returns False if the queue is full (drop)."""
65
+ if self._stopped.is_set():
66
+ return False
67
+ try:
68
+ self._queue.put_nowait(job)
69
+ return True
70
+ except queue.Full:
71
+ logger.warning("AtomicMemory ingest queue full; dropping turn for session %s", job.session_id)
72
+ return False
73
+
74
+ def shutdown(self, *, timeout: float = 10.0) -> None:
75
+ if self._thread is None:
76
+ return
77
+ self._stopped.set()
78
+ try:
79
+ self._queue.put_nowait(_SHUTDOWN_SENTINEL)
80
+ except queue.Full:
81
+ # Drain one slot to make space for the sentinel; ensures shutdown
82
+ # doesn't hang behind a saturated queue.
83
+ try:
84
+ self._queue.get_nowait()
85
+ self._queue.task_done()
86
+ except queue.Empty:
87
+ pass
88
+ self._queue.put_nowait(_SHUTDOWN_SENTINEL)
89
+ thread = self._thread
90
+ self._thread = None
91
+ thread.join(timeout=timeout)
92
+
93
+ def queue_size(self) -> int:
94
+ return self._queue.qsize()
95
+
96
+ def _run(self) -> None:
97
+ while True:
98
+ item = self._queue.get()
99
+ try:
100
+ if item is _SHUTDOWN_SENTINEL:
101
+ return
102
+ try:
103
+ self._run_job(item)
104
+ except BaseException as exc: # noqa: BLE001 — caller decides retry policy
105
+ if self._on_failure is not None:
106
+ try:
107
+ self._on_failure(exc)
108
+ except Exception: # noqa: BLE001
109
+ logger.exception("ingest on_failure callback raised")
110
+ else:
111
+ logger.warning("AtomicMemory ingest job failed: %s", exc)
112
+ finally:
113
+ self._queue.task_done()