@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/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()
|