runtime-memory 3.0.0__py3-none-any.whl
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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""memory-layer as a Hermes Agent memory provider.
|
|
2
|
+
|
|
3
|
+
Hermes discovers this through the ``hermes_agent.memory_providers`` entry point
|
|
4
|
+
and activates it with ``memory.provider: runtimememory``. Once active it replaces
|
|
5
|
+
the built-in note files rather than supplementing them, so recall stops being a
|
|
6
|
+
fixed block of text pasted into every prompt and becomes a per-turn retrieval
|
|
7
|
+
against the same SQLite store Claude Code and the MCP clients already share.
|
|
8
|
+
|
|
9
|
+
Two design choices are worth stating up front, because both differ from the
|
|
10
|
+
built-in provider:
|
|
11
|
+
|
|
12
|
+
Recall is synchronous. The Hermes contract expects ``prefetch()`` to hand back a
|
|
13
|
+
result warmed in the background on the previous turn, because the bundled
|
|
14
|
+
providers talk to network services. This store is local SQLite, so recall runs
|
|
15
|
+
against the turn's actual query instead of the one before it. That matters for
|
|
16
|
+
measurement as much as for quality: an off-by-one between question and retrieval
|
|
17
|
+
would confound any attempt to attribute an outcome to what was recalled.
|
|
18
|
+
|
|
19
|
+
Writes are explicit. Persisting every turn verbatim would fill a curated store
|
|
20
|
+
with conversational debris and degrade the retrieval it exists to serve. Memories
|
|
21
|
+
arrive from the ``runtimememory_remember`` tool, from mirrored built-in memory
|
|
22
|
+
writes, and - only when switched on - from end-of-session extraction.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
from runtime_memory.core.logging import get_logger
|
|
34
|
+
from runtime_memory.core.models import (
|
|
35
|
+
MemoryCategory,
|
|
36
|
+
MemoryScope,
|
|
37
|
+
MemorySource,
|
|
38
|
+
Outcome,
|
|
39
|
+
)
|
|
40
|
+
from runtime_memory.hermes._base import (
|
|
41
|
+
INDICATOR_GLYPH,
|
|
42
|
+
MemoryProvider,
|
|
43
|
+
RecallStatus,
|
|
44
|
+
is_trivial_prompt,
|
|
45
|
+
)
|
|
46
|
+
from runtime_memory.hermes.bridge import run_sync, spawn
|
|
47
|
+
from runtime_memory.hermes.tools import TOOL_SCHEMAS, dispatch
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from runtime_memory.core.engine import EngineStats, MemoryEngine
|
|
51
|
+
from runtime_memory.core.models import Memory, SearchResult
|
|
52
|
+
|
|
53
|
+
logger = get_logger(__name__)
|
|
54
|
+
|
|
55
|
+
PROVIDER_NAME = "runtimememory"
|
|
56
|
+
PROVIDER_LABEL = "Runtime Memory"
|
|
57
|
+
|
|
58
|
+
DEFAULT_DB_PATH = "~/.memory-layer/memories.db"
|
|
59
|
+
DEFAULT_RECALL_LIMIT = 8
|
|
60
|
+
DEFAULT_MIN_SCORE = 0.0
|
|
61
|
+
|
|
62
|
+
_WRITE_CONTEXTS = frozenset({"primary", ""})
|
|
63
|
+
"""Agent contexts allowed to write. Subagents, cron and flush runs read only."""
|
|
64
|
+
|
|
65
|
+
_MIRROR_CATEGORY = {
|
|
66
|
+
"memory": MemoryCategory.CONTEXT,
|
|
67
|
+
"user": MemoryCategory.PREFERENCE,
|
|
68
|
+
}
|
|
69
|
+
"""Hermes built-in write targets mapped onto memory-layer categories."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _env_flag(name: str, default: bool = False) -> bool:
|
|
73
|
+
"""Read a boolean environment variable."""
|
|
74
|
+
raw = os.environ.get(name)
|
|
75
|
+
if raw is None:
|
|
76
|
+
return default
|
|
77
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _embedding_provider_name() -> str:
|
|
81
|
+
"""Name the embedding backend for the engine to build.
|
|
82
|
+
|
|
83
|
+
``local`` is the right answer even when ``sentence-transformers`` is
|
|
84
|
+
missing: the engine factory degrades it to the null provider, which indexes
|
|
85
|
+
no vectors and leaves retrieval on the BM25 half of the hybrid. Repeating
|
|
86
|
+
that check here would give the store two answers to the same question, and
|
|
87
|
+
picking ``mock`` writes hash-derived vectors alongside real ones.
|
|
88
|
+
"""
|
|
89
|
+
return os.environ.get("RUNTIME_MEMORY_EMBEDDING") or "local"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RuntimeMemoryProvider(MemoryProvider):
|
|
93
|
+
"""Hermes memory provider backed by a local memory-layer engine."""
|
|
94
|
+
|
|
95
|
+
pre_compress_checkpoint_api_version = 1
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
"""Create an inactive provider. Nothing is opened until ``initialize``."""
|
|
99
|
+
self._engine: MemoryEngine | None = None
|
|
100
|
+
self._session_id: str = ""
|
|
101
|
+
self._project: str | None = None
|
|
102
|
+
self._writes_allowed: bool = True
|
|
103
|
+
|
|
104
|
+
self._db_path: Path = Path(os.environ.get("RUNTIME_MEMORY_DB", DEFAULT_DB_PATH)).expanduser()
|
|
105
|
+
self._recall_limit: int = int(
|
|
106
|
+
os.environ.get("RUNTIME_MEMORY_RECALL_LIMIT", DEFAULT_RECALL_LIMIT)
|
|
107
|
+
)
|
|
108
|
+
self._min_score: float = float(os.environ.get("RUNTIME_MEMORY_MIN_SCORE", DEFAULT_MIN_SCORE))
|
|
109
|
+
self._mirror_builtin: bool = _env_flag("RUNTIME_MEMORY_MIRROR_WRITES", True)
|
|
110
|
+
self._extract_on_end: bool = _env_flag("RUNTIME_MEMORY_EXTRACT_ON_END", False)
|
|
111
|
+
|
|
112
|
+
# Last recall, kept so an outcome can be attributed without the model
|
|
113
|
+
# having to repeat the memory ids back to us.
|
|
114
|
+
self._turn_id: str = ""
|
|
115
|
+
self._last_ids: list[str] = []
|
|
116
|
+
self._last_count: int = 0
|
|
117
|
+
|
|
118
|
+
from runtime_memory.hermes.trace import TraceWriter # noqa: PLC0415
|
|
119
|
+
|
|
120
|
+
self._trace = TraceWriter()
|
|
121
|
+
|
|
122
|
+
# -- identity ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def name(self) -> str:
|
|
126
|
+
"""Provider name, matched against ``memory.provider``."""
|
|
127
|
+
return PROVIDER_NAME
|
|
128
|
+
|
|
129
|
+
def is_available(self) -> bool:
|
|
130
|
+
"""Whether the store can be opened. Checks the filesystem only."""
|
|
131
|
+
try:
|
|
132
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
return os.access(self._db_path.parent, os.W_OK)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
logger.debug(f"memory-layer unavailable: {exc}")
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def unavailable_reason(self) -> str:
|
|
139
|
+
"""Explain an unavailable store."""
|
|
140
|
+
return f"Cannot write to {self._db_path.parent}. Set RUNTIME_MEMORY_DB to a writable path."
|
|
141
|
+
|
|
142
|
+
# -- lifecycle -----------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
|
145
|
+
"""Open the engine and warm it before the first turn.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
session_id: The Hermes session this provider instance serves.
|
|
149
|
+
**kwargs: Hermes context. ``agent_context`` gates writes;
|
|
150
|
+
``agent_workspace`` scopes memories to a project.
|
|
151
|
+
"""
|
|
152
|
+
from runtime_memory.core.engine import EngineConfig, MemoryEngine # noqa: PLC0415
|
|
153
|
+
|
|
154
|
+
self._session_id = session_id
|
|
155
|
+
|
|
156
|
+
agent_context = kwargs.get("agent_context", "primary")
|
|
157
|
+
self._writes_allowed = agent_context in _WRITE_CONTEXTS
|
|
158
|
+
if not self._writes_allowed:
|
|
159
|
+
logger.info(f"Read-only in '{agent_context}' context")
|
|
160
|
+
|
|
161
|
+
workspace = kwargs.get("agent_workspace")
|
|
162
|
+
self._project = os.environ.get("RUNTIME_MEMORY_PROJECT") or (
|
|
163
|
+
Path(workspace).name if workspace else None
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
config = EngineConfig(
|
|
168
|
+
db_path=str(self._db_path),
|
|
169
|
+
embedding_provider=_embedding_provider_name(),
|
|
170
|
+
track_last_search=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
engine = MemoryEngine(config=config)
|
|
174
|
+
run_sync(engine.initialize(), timeout=120.0)
|
|
175
|
+
self._engine = engine
|
|
176
|
+
|
|
177
|
+
# Pull the embedding model into memory now. It costs ~20s on first load,
|
|
178
|
+
# and paying that here rather than inside the user's first turn is the
|
|
179
|
+
# difference between a slow start and a stalled reply.
|
|
180
|
+
spawn(self._warm(), label="warmup")
|
|
181
|
+
|
|
182
|
+
logger.info(
|
|
183
|
+
f"memory-layer ready (db={self._db_path}, project={self._project}, "
|
|
184
|
+
f"writes={'on' if self._writes_allowed else 'off'})"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def _warm(self) -> None:
|
|
188
|
+
"""Touch the retrieval path once so the first real query is fast."""
|
|
189
|
+
if self._engine is not None:
|
|
190
|
+
await self._engine.search("warmup", limit=1, track_usage=False)
|
|
191
|
+
|
|
192
|
+
def shutdown(self) -> None:
|
|
193
|
+
"""Close the engine. The shared event loop deliberately stays up."""
|
|
194
|
+
if self._engine is None:
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
run_sync(self._engine.close(), timeout=10.0)
|
|
198
|
+
except Exception as exc: # shutdown must not raise
|
|
199
|
+
logger.warning(f"Engine close failed: {exc}")
|
|
200
|
+
finally:
|
|
201
|
+
self._engine = None
|
|
202
|
+
|
|
203
|
+
def on_session_switch(
|
|
204
|
+
self,
|
|
205
|
+
new_session_id: str,
|
|
206
|
+
*,
|
|
207
|
+
parent_session_id: str = "",
|
|
208
|
+
reset: bool = False,
|
|
209
|
+
rewound: bool = False,
|
|
210
|
+
**kwargs: Any,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Rebind to a new session id so later writes land in the right record."""
|
|
213
|
+
self._session_id = new_session_id
|
|
214
|
+
if reset:
|
|
215
|
+
self._turn_id, self._last_ids, self._last_count = "", [], 0
|
|
216
|
+
|
|
217
|
+
# -- recall --------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def system_prompt_block(self) -> str:
|
|
220
|
+
"""Static guidance. Recalled content is injected by ``prefetch``."""
|
|
221
|
+
return (
|
|
222
|
+
"You have persistent memory across sessions via Runtime Memory. "
|
|
223
|
+
"Relevant memories are retrieved automatically each turn. "
|
|
224
|
+
"Use runtimememory_remember to save a durable fact worth recalling "
|
|
225
|
+
"later, and runtimememory_outcome to report whether recalled memories "
|
|
226
|
+
"actually helped - that feedback decides what surfaces next time."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
230
|
+
"""Retrieve memories for this turn and format them for injection.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
query: The user's message for the upcoming turn.
|
|
234
|
+
session_id: Session scope, unused for a single shared store.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A formatted memory block, or ``""`` when nothing is worth injecting.
|
|
238
|
+
"""
|
|
239
|
+
self._turn_id = uuid.uuid4().hex
|
|
240
|
+
self._last_ids, self._last_count = [], 0
|
|
241
|
+
|
|
242
|
+
if self._engine is None or is_trivial_prompt(query):
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
started = time.perf_counter()
|
|
246
|
+
try:
|
|
247
|
+
results = run_sync(
|
|
248
|
+
self._engine.search(
|
|
249
|
+
query=query,
|
|
250
|
+
limit=self._recall_limit,
|
|
251
|
+
project=self._project,
|
|
252
|
+
min_score=self._min_score,
|
|
253
|
+
),
|
|
254
|
+
timeout=15.0,
|
|
255
|
+
)
|
|
256
|
+
except Exception as exc: # a failed recall must never break the turn
|
|
257
|
+
logger.warning(f"Recall failed: {exc}")
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
if not results:
|
|
261
|
+
return ""
|
|
262
|
+
|
|
263
|
+
self._last_ids = [r.memory.id for r in results]
|
|
264
|
+
self._last_count = len(results)
|
|
265
|
+
|
|
266
|
+
self._trace.recall(
|
|
267
|
+
turn_id=self._turn_id,
|
|
268
|
+
session_id=session_id or self._session_id,
|
|
269
|
+
query=query,
|
|
270
|
+
results=results,
|
|
271
|
+
project=self._project,
|
|
272
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
273
|
+
)
|
|
274
|
+
return self._format(results)
|
|
275
|
+
|
|
276
|
+
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
|
277
|
+
"""No-op. Recall is synchronous, so there is nothing to warm."""
|
|
278
|
+
|
|
279
|
+
def recall_status(self) -> RecallStatus | None:
|
|
280
|
+
"""Report what the last recall injected, for the indicator line."""
|
|
281
|
+
if not self._last_count:
|
|
282
|
+
return None
|
|
283
|
+
return RecallStatus(
|
|
284
|
+
provider_label=PROVIDER_LABEL,
|
|
285
|
+
count=self._last_count,
|
|
286
|
+
glyph=INDICATOR_GLYPH,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _format(self, results: list[SearchResult]) -> str:
|
|
290
|
+
"""Render search results as a promptable block.
|
|
291
|
+
|
|
292
|
+
Each line carries its memory id so the model can name specific memories
|
|
293
|
+
when reporting an outcome, and its outcome score so a memory with a poor
|
|
294
|
+
track record reads as weaker evidence than one that keeps working.
|
|
295
|
+
"""
|
|
296
|
+
lines = ["## Relevant memories", ""]
|
|
297
|
+
for result in results:
|
|
298
|
+
memory = result.memory
|
|
299
|
+
# Thresholds are inclusive so a single recorded outcome is enough to
|
|
300
|
+
# show: one `worked` lands exactly on +0.2, one `failed` on -0.3.
|
|
301
|
+
marker = ""
|
|
302
|
+
if memory.outcome_score >= 0.2:
|
|
303
|
+
marker = " (has worked before)"
|
|
304
|
+
elif memory.outcome_score <= -0.2:
|
|
305
|
+
marker = " (has failed before)"
|
|
306
|
+
lines.append(f"- [{memory.category.value}] {memory.content}{marker} `{memory.id}`")
|
|
307
|
+
return "\n".join(lines)
|
|
308
|
+
|
|
309
|
+
# -- writes --------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def sync_turn(
|
|
312
|
+
self,
|
|
313
|
+
user_content: str,
|
|
314
|
+
assistant_content: str,
|
|
315
|
+
*,
|
|
316
|
+
session_id: str = "",
|
|
317
|
+
messages: list[dict[str, Any]] | None = None,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Called after every turn. Intentionally does not persist.
|
|
320
|
+
|
|
321
|
+
Storing raw turns would bury the curated memories that make retrieval
|
|
322
|
+
useful. Facts reach the store through the remember tool, mirrored
|
|
323
|
+
built-in writes, or opt-in end-of-session extraction.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def on_memory_write(
|
|
327
|
+
self,
|
|
328
|
+
action: str,
|
|
329
|
+
target: str,
|
|
330
|
+
content: str,
|
|
331
|
+
metadata: dict[str, Any] | None = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Mirror a built-in memory write into the shared store.
|
|
334
|
+
|
|
335
|
+
This is what lifts the built-in character cap in practice: Hermes keeps
|
|
336
|
+
its small always-injected note file, and the same fact also lands here,
|
|
337
|
+
where it is retrieved on relevance instead of pasted in wholesale.
|
|
338
|
+
"""
|
|
339
|
+
if not (self._mirror_builtin and self._writes_allowed):
|
|
340
|
+
return
|
|
341
|
+
if action not in {"add", "replace"} or not content.strip():
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
category = _MIRROR_CATEGORY.get(target, MemoryCategory.CONTEXT)
|
|
345
|
+
spawn(
|
|
346
|
+
self._store(
|
|
347
|
+
content=content.strip(),
|
|
348
|
+
category=category,
|
|
349
|
+
source=MemorySource.IMPORTED,
|
|
350
|
+
tags=["hermes", f"builtin-{target}"],
|
|
351
|
+
metadata={"hermes_action": action, **(metadata or {})},
|
|
352
|
+
kind="mirror",
|
|
353
|
+
),
|
|
354
|
+
label="mirror write",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
|
|
358
|
+
"""Optionally extract durable facts when a session really ends."""
|
|
359
|
+
if not (self._extract_on_end and self._writes_allowed):
|
|
360
|
+
return
|
|
361
|
+
if self._engine is None or not messages:
|
|
362
|
+
return
|
|
363
|
+
logger.info(f"Session end: extraction over {len(messages)} messages")
|
|
364
|
+
spawn(self._extract(messages), label="extraction")
|
|
365
|
+
|
|
366
|
+
async def _extract(self, messages: list[dict[str, Any]]) -> None:
|
|
367
|
+
"""Run LLM extraction over a finished session.
|
|
368
|
+
|
|
369
|
+
Requires the ``extraction`` extra and an API key. Failures are logged and
|
|
370
|
+
dropped: a missed extraction is a lost convenience, not a lost session.
|
|
371
|
+
"""
|
|
372
|
+
from runtime_memory.extraction.extractor import MemoryExtractor # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
transcript = "\n".join(
|
|
375
|
+
f"{m.get('role', '?')}: {m.get('content', '')}" for m in messages if m.get("content")
|
|
376
|
+
)
|
|
377
|
+
result = await MemoryExtractor().extract_and_store(
|
|
378
|
+
transcript=transcript,
|
|
379
|
+
engine=self._require_engine(),
|
|
380
|
+
project=self._project,
|
|
381
|
+
)
|
|
382
|
+
if not result.success:
|
|
383
|
+
logger.warning(f"Extraction failed: {result.error}")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# extract_and_store writes through the engine without handing back the
|
|
387
|
+
# stored rows, so the trace records how many landed, not which.
|
|
388
|
+
if result.memory_count:
|
|
389
|
+
self._trace.write_turn(
|
|
390
|
+
turn_id=self._turn_id,
|
|
391
|
+
session_id=self._session_id,
|
|
392
|
+
memory_ids=[],
|
|
393
|
+
kind="extraction",
|
|
394
|
+
count=result.memory_count,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def _store(
|
|
398
|
+
self,
|
|
399
|
+
*,
|
|
400
|
+
content: str,
|
|
401
|
+
category: MemoryCategory,
|
|
402
|
+
source: MemorySource,
|
|
403
|
+
tags: list[str],
|
|
404
|
+
metadata: dict[str, Any] | None = None,
|
|
405
|
+
kind: str = "tool",
|
|
406
|
+
) -> Memory:
|
|
407
|
+
"""Write one memory and trace it."""
|
|
408
|
+
memory = await self._engine.add( # type: ignore[union-attr]
|
|
409
|
+
content=content,
|
|
410
|
+
category=category,
|
|
411
|
+
project=self._project,
|
|
412
|
+
scope=MemoryScope.PROJECT if self._project else MemoryScope.GLOBAL,
|
|
413
|
+
source=source,
|
|
414
|
+
tags=tags,
|
|
415
|
+
metadata={"hermes_session": self._session_id, **(metadata or {})},
|
|
416
|
+
)
|
|
417
|
+
self._trace.write_turn(
|
|
418
|
+
turn_id=self._turn_id,
|
|
419
|
+
session_id=self._session_id,
|
|
420
|
+
memory_ids=[memory.id],
|
|
421
|
+
kind=kind,
|
|
422
|
+
)
|
|
423
|
+
return memory
|
|
424
|
+
|
|
425
|
+
# -- tools ---------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
|
428
|
+
"""Function-calling schemas for the tools this provider handles."""
|
|
429
|
+
return TOOL_SCHEMAS
|
|
430
|
+
|
|
431
|
+
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs: Any) -> str:
|
|
432
|
+
"""Handle one tool call, returning a JSON string."""
|
|
433
|
+
return dispatch(self, tool_name, args)
|
|
434
|
+
|
|
435
|
+
# -- synchronous helpers used by tool dispatch ---------------------------
|
|
436
|
+
|
|
437
|
+
def _require_engine(self) -> MemoryEngine:
|
|
438
|
+
"""Return the engine or explain why there isn't one."""
|
|
439
|
+
if self._engine is None:
|
|
440
|
+
raise RuntimeError("Runtime Memory is not initialized")
|
|
441
|
+
return self._engine
|
|
442
|
+
|
|
443
|
+
def remember(self, *, content: str, category: MemoryCategory, tags: list[str]) -> Memory:
|
|
444
|
+
"""Store a memory on the model's explicit instruction."""
|
|
445
|
+
self._require_engine()
|
|
446
|
+
if not self._writes_allowed:
|
|
447
|
+
raise RuntimeError("Writes are disabled in this agent context")
|
|
448
|
+
return run_sync(
|
|
449
|
+
self._store(
|
|
450
|
+
content=content,
|
|
451
|
+
category=category,
|
|
452
|
+
source=MemorySource.EXPLICIT,
|
|
453
|
+
tags=[*tags, "hermes"],
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def recall(
|
|
458
|
+
self, *, query: str, limit: int, category: MemoryCategory | None
|
|
459
|
+
) -> list[SearchResult]:
|
|
460
|
+
"""Run an explicit search, separate from the automatic per-turn recall."""
|
|
461
|
+
engine = self._require_engine()
|
|
462
|
+
results = run_sync(
|
|
463
|
+
engine.search(query=query, limit=limit, category=category, project=self._project)
|
|
464
|
+
)
|
|
465
|
+
# Fold into the turn's recall set so an outcome can credit these too.
|
|
466
|
+
for result in results:
|
|
467
|
+
if result.memory.id not in self._last_ids:
|
|
468
|
+
self._last_ids.append(result.memory.id)
|
|
469
|
+
return results
|
|
470
|
+
|
|
471
|
+
def record_outcome(
|
|
472
|
+
self, *, outcome: Outcome, memory_ids: list[str] | None = None
|
|
473
|
+
) -> list[Memory]:
|
|
474
|
+
"""Apply outcome feedback, defaulting to this turn's recalled memories."""
|
|
475
|
+
engine = self._require_engine()
|
|
476
|
+
targets = memory_ids or self._last_ids
|
|
477
|
+
if not targets:
|
|
478
|
+
return []
|
|
479
|
+
|
|
480
|
+
updated = run_sync(engine.record_outcome(targets, outcome))
|
|
481
|
+
self._trace.outcome(
|
|
482
|
+
turn_id=self._turn_id,
|
|
483
|
+
session_id=self._session_id,
|
|
484
|
+
outcome=outcome.value,
|
|
485
|
+
memory_ids=[memory.id for memory in updated],
|
|
486
|
+
origin="tool" if memory_ids else "auto",
|
|
487
|
+
)
|
|
488
|
+
return updated
|
|
489
|
+
|
|
490
|
+
def stats(self) -> EngineStats:
|
|
491
|
+
"""Return store statistics."""
|
|
492
|
+
return run_sync(self._require_engine().stats(project=self._project))
|
|
493
|
+
|
|
494
|
+
# -- configuration -------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
def get_config_schema(self) -> list[dict[str, Any]]:
|
|
497
|
+
"""Fields offered by ``hermes memory setup``."""
|
|
498
|
+
return [
|
|
499
|
+
{
|
|
500
|
+
"key": "db_path",
|
|
501
|
+
"description": "SQLite store shared with Claude Code and MCP clients",
|
|
502
|
+
"default": DEFAULT_DB_PATH,
|
|
503
|
+
"env_var": "RUNTIME_MEMORY_DB",
|
|
504
|
+
"type": "text",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"key": "recall_limit",
|
|
508
|
+
"description": "Memories injected per turn",
|
|
509
|
+
"default": DEFAULT_RECALL_LIMIT,
|
|
510
|
+
"env_var": "RUNTIME_MEMORY_RECALL_LIMIT",
|
|
511
|
+
"type": "integer",
|
|
512
|
+
"minimum": 1,
|
|
513
|
+
"maximum": 50,
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
"key": "mirror_writes",
|
|
517
|
+
"description": "Mirror built-in memory writes into the store",
|
|
518
|
+
"default": True,
|
|
519
|
+
"env_var": "RUNTIME_MEMORY_MIRROR_WRITES",
|
|
520
|
+
"type": "boolean",
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
"key": "extract_on_end",
|
|
524
|
+
"description": "Extract memories at session end (needs an API key)",
|
|
525
|
+
"default": False,
|
|
526
|
+
"env_var": "RUNTIME_MEMORY_EXTRACT_ON_END",
|
|
527
|
+
"type": "boolean",
|
|
528
|
+
},
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
|
|
532
|
+
"""No-op: every setting is an environment variable."""
|
|
533
|
+
|
|
534
|
+
def backup_paths(self) -> list[str]:
|
|
535
|
+
"""The store lives outside HERMES_HOME, so name it for ``hermes backup``."""
|
|
536
|
+
return [str(self._db_path)]
|