chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
<p align="center">
|
||||
<img src="../../assets/banner.png" alt="agentmemory" width="640" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
<img src="https://github.com/NousResearch.png?size=80" alt="Hermes Agent" width="28" height="28" align="center" />
|
||||
agentmemory for Hermes Agent
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your Hermes agent remembers everything. No more re-explaining.</strong><br/>
|
||||
<sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>. Cross-agent shared with Claude Code, Cursor, OpenCode, and more.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/MCP-43_tools-1f6feb?style=flat-square" alt="43 MCP tools" />
|
||||
<img src="https://img.shields.io/badge/Hooks-6_lifecycle-1f6feb?style=flat-square" alt="6 lifecycle hooks" />
|
||||
<img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
|
||||
<img src="https://img.shields.io/badge/Self--hosted-yes-00875f?style=flat-square" alt="Self-hosted" />
|
||||
<img src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square" alt="Apache 2.0" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Install it in 30 seconds
|
||||
|
||||
**Paste this prompt into Hermes** and it does the whole setup for you:
|
||||
|
||||
```text
|
||||
Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a
|
||||
separate terminal to start the memory server on localhost:3111. Then
|
||||
add this to `~/.hermes/config.yaml` so Hermes can use agentmemory as
|
||||
an MCP server with all 43 memory tools:
|
||||
|
||||
mcp_servers:
|
||||
agentmemory:
|
||||
command: npx
|
||||
args: ["-y", "@agentmemory/mcp"]
|
||||
|
||||
memory:
|
||||
provider: agentmemory
|
||||
|
||||
Verify it's working with
|
||||
`curl http://localhost:3111/agentmemory/health` — it should return
|
||||
{"status":"healthy"}. Open the real-time viewer at
|
||||
http://localhost:3113 to watch memories being captured live.
|
||||
|
||||
If I want deeper integration — pre-LLM context injection, turn-level
|
||||
capture, memory-write mirroring to MEMORY.md, and system prompt block
|
||||
injection — copy `integrations/hermes` from the agentmemory repo to
|
||||
`~/.hermes/plugins/agentmemory` instead. That gives me the
|
||||
6-hook memory provider plugin on top of the MCP server.
|
||||
```
|
||||
|
||||
That's it. Hermes handles the rest.
|
||||
|
||||
## Quick setup
|
||||
|
||||
### Option 1: MCP server (zero code)
|
||||
|
||||
Add to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
agentmemory:
|
||||
command: npx
|
||||
args: ["-y", "@agentmemory/mcp"]
|
||||
|
||||
memory:
|
||||
provider: agentmemory
|
||||
```
|
||||
|
||||
This gives Hermes access to all 43 MCP tools and enables the agentmemory memory provider. Start the server separately:
|
||||
|
||||
```bash
|
||||
npx @agentmemory/agentmemory
|
||||
```
|
||||
|
||||
### Option 2: Memory provider plugin (deeper integration)
|
||||
|
||||
Copy this folder to your Hermes plugins directory:
|
||||
|
||||
```bash
|
||||
cp -r integrations/hermes ~/.hermes/plugins/agentmemory
|
||||
```
|
||||
|
||||
Start the agentmemory server:
|
||||
|
||||
```bash
|
||||
npx @agentmemory/agentmemory
|
||||
```
|
||||
|
||||
The plugin auto-detects the running server and hooks into the Hermes agent loop. Make sure `memory.provider` is set to `agentmemory` in `~/.hermes/config.yaml`:
|
||||
|
||||
- `prefetch()` injects relevant memories before each LLM call
|
||||
- `sync_turn()` captures every conversation turn in the background
|
||||
- `on_session_end()` marks sessions complete for summarization
|
||||
- `on_pre_compress()` re-injects context before compaction
|
||||
- `on_memory_write()` mirrors MEMORY.md writes to agentmemory
|
||||
- `system_prompt_block()` injects project profile at session start
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
|
||||
| `AGENTMEMORY_SECRET` | (none) | Auth token for protected instances |
|
||||
| `AGENTMEMORY_REQUIRE_HTTPS` | (off) | When set to `1`, refuse to send the bearer token over plaintext HTTP to a non-loopback host. Sends only when `AGENTMEMORY_URL` is `https://...` or points at `localhost`/`127.0.0.1`/`::1`. With this off, the plugin warns once on stderr but still sends. |
|
||||
|
||||
The plugin reads `~/.agentmemory/.env` (or `$XDG_CONFIG_HOME/agentmemory/.env`) at import time and populates any missing values into the process environment via `os.environ.setdefault`. Anything you set in the shell takes precedence; the file is only used to fill gaps. This means `hermes memory status` reports the plugin as available even when the agentmemory service is launched by systemd or another process manager that loads `~/.agentmemory/.env` directly without exporting it to the Hermes CLI shell (#250).
|
||||
|
||||
## What Hermes gets
|
||||
|
||||
- 95.2% retrieval accuracy (LongMemEval-S, ICLR 2025)
|
||||
- Hybrid search: BM25 + vector + knowledge graph
|
||||
- Memory versioning, decay, and auto-forget
|
||||
- Cross-agent: memories from Claude Code, Cursor, Gemini CLI all accessible
|
||||
- Real-time viewer at http://localhost:3113
|
||||
|
||||
## How it works
|
||||
|
||||
Hermes has two memory files (MEMORY.md, USER.md) and SQLite full-text search. agentmemory adds structured memory on top:
|
||||
|
||||
| Hermes built-in | agentmemory adds |
|
||||
|---|---|
|
||||
| MEMORY.md (flat text) | Structured observations with facts, concepts, files |
|
||||
| USER.md (preferences) | Project profiles with top patterns and conventions |
|
||||
| SQLite FTS5 (session search) | BM25 + vector + knowledge graph (95.2% R@5) |
|
||||
| Skills (self-improving) | Skill extraction from completed sessions |
|
||||
| Single agent | Cross-agent memory via MCP + REST |
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
agentmemory memory provider for Hermes Agent.
|
||||
|
||||
Drop this folder into ~/.hermes/plugins/agentmemory/
|
||||
or install via: hermes plugin install agentmemory
|
||||
|
||||
Requires agentmemory server running: npx @agentmemory/agentmemory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
try:
|
||||
from agent.memory_provider import MemoryProvider
|
||||
except ImportError:
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class MemoryProvider(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool: ...
|
||||
@abstractmethod
|
||||
def initialize(self, session_id: str, **kwargs: Any) -> None: ...
|
||||
@abstractmethod
|
||||
def get_tool_schemas(self) -> list[dict]: ...
|
||||
@abstractmethod
|
||||
def handle_tool_call(self, name: str, args: dict) -> str: ...
|
||||
def get_config_schema(self) -> list[dict]: return []
|
||||
def save_config(self, values: dict, hermes_home: str) -> None: pass
|
||||
def system_prompt_block(self) -> str: return ""
|
||||
def prefetch(self, query: str, **kwargs: Any) -> str: return ""
|
||||
def queue_prefetch(self, query: str, **kwargs: Any) -> None: pass
|
||||
def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: pass
|
||||
def on_session_end(self, messages: list, **kwargs: Any) -> None: pass
|
||||
def on_pre_compress(self, messages: list, **kwargs: Any) -> None: pass
|
||||
def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None: pass
|
||||
def shutdown(self, **kwargs: Any) -> None: pass
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:3111"
|
||||
TIMEOUT = 5
|
||||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
_plaintext_bearer_warned = False
|
||||
|
||||
# agentmemory's documented runtime config lives at ~/.agentmemory/.env.
|
||||
# When agentmemory is launched as a systemd user service (or any other
|
||||
# process manager that loads that file directly), those values never
|
||||
# reach an interactive shell. `hermes memory status` then reads
|
||||
# os.environ in the Hermes CLI process, finds AGENTMEMORY_URL /
|
||||
# AGENTMEMORY_SECRET unset, and reports the plugin as "Missing" even
|
||||
# though the service is healthy and live sessions can use it (#250).
|
||||
#
|
||||
# Preload the file at plugin-import time using os.environ.setdefault so
|
||||
# we never override anything the user explicitly set in the shell. The
|
||||
# preload is best-effort and silent on any failure (file absent,
|
||||
# unreadable, malformed) — the plugin falls back to its existing default
|
||||
# (http://localhost:3111) and Hermes status reflects that.
|
||||
def _preload_agentmemory_dotenv() -> None:
|
||||
candidates: list[Path] = []
|
||||
home = os.environ.get("HOME")
|
||||
if home:
|
||||
candidates.append(Path(home) / ".agentmemory" / ".env")
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config:
|
||||
candidates.append(Path(xdg_config) / "agentmemory" / ".env")
|
||||
for path in candidates:
|
||||
try:
|
||||
if not path.is_file():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key:
|
||||
os.environ.setdefault(key, value)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
# Guarantee AGENTMEMORY_URL is set so `hermes memory status` never
|
||||
# reports it as Missing when a user runs agentmemory at the default
|
||||
# localhost:3111 (or via systemd with the URL line commented out in
|
||||
# ~/.agentmemory/.env because it matches the default). #520.
|
||||
os.environ.setdefault("AGENTMEMORY_URL", DEFAULT_BASE_URL)
|
||||
|
||||
|
||||
_preload_agentmemory_dotenv()
|
||||
|
||||
|
||||
def _validate_url(base: str) -> bool:
|
||||
if not base:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(base)
|
||||
# .port raises ValueError on a non-numeric or out-of-range port
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
return bool(parsed.hostname)
|
||||
|
||||
|
||||
def _uses_plaintext_bearer_auth(base: str, secret: str = "") -> bool:
|
||||
if not secret:
|
||||
return False
|
||||
parsed = urlparse(base)
|
||||
return parsed.scheme == "http" and (parsed.hostname or "").lower() not in LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def _plaintext_bearer_auth_message(base: str) -> str:
|
||||
return f"agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to {base}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel."
|
||||
|
||||
|
||||
def _warn_plaintext_bearer_auth(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def _check_plaintext_bearer_guard(
|
||||
base: str,
|
||||
secret: str = "",
|
||||
warn: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
global _plaintext_bearer_warned
|
||||
if not _uses_plaintext_bearer_auth(base, secret):
|
||||
return
|
||||
message = _plaintext_bearer_auth_message(base)
|
||||
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
|
||||
raise RuntimeError(message)
|
||||
if not _plaintext_bearer_warned:
|
||||
_plaintext_bearer_warned = True
|
||||
(warn or _warn_plaintext_bearer_auth)(message)
|
||||
|
||||
|
||||
def _reset_plaintext_bearer_guard_for_tests() -> None:
|
||||
global _plaintext_bearer_warned
|
||||
_plaintext_bearer_warned = False
|
||||
|
||||
|
||||
def _api(base: str, path: str, body: dict | None = None, method: str = "POST", secret: str = "") -> dict | None:
|
||||
if not _validate_url(base):
|
||||
return None
|
||||
url = f"{base}/agentmemory/{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
auth = secret or os.environ.get("AGENTMEMORY_SECRET", "")
|
||||
_check_plaintext_bearer_guard(base, auth)
|
||||
if auth:
|
||||
headers["Authorization"] = f"Bearer {auth}"
|
||||
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except (URLError, TimeoutError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _api_bg(base: str, path: str, body: dict | None = None) -> None:
|
||||
t = threading.Thread(target=_api, args=(base, path, body), daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
class AgentMemoryProvider(MemoryProvider):
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "agentmemory"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
# Hermes contract: no network calls in is_available.
|
||||
base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
|
||||
return _validate_url(base)
|
||||
|
||||
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
||||
self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
|
||||
self._session_id = session_id
|
||||
self._project = kwargs.get("cwd", os.getcwd())
|
||||
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
|
||||
_check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", ""))
|
||||
|
||||
_api(self._base, "session/start", {
|
||||
"sessionId": session_id,
|
||||
"project": self._project,
|
||||
"cwd": self._project,
|
||||
})
|
||||
|
||||
def get_config_schema(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"key": "url",
|
||||
"description": "agentmemory server URL",
|
||||
"default": DEFAULT_BASE_URL,
|
||||
"env_var": "AGENTMEMORY_URL",
|
||||
},
|
||||
{
|
||||
"key": "secret",
|
||||
"description": "agentmemory auth secret (optional)",
|
||||
"secret": True,
|
||||
"required": False,
|
||||
"env_var": "AGENTMEMORY_SECRET",
|
||||
},
|
||||
]
|
||||
|
||||
def save_config(self, values: dict, hermes_home: str) -> None:
|
||||
config_path = Path(hermes_home) / "agentmemory.json"
|
||||
config_path.write_text(json.dumps(values, indent=2))
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
result = _api(self._base, "context", {
|
||||
"sessionId": self._session_id,
|
||||
"project": self._project,
|
||||
})
|
||||
if result and result.get("context"):
|
||||
return result["context"]
|
||||
return ""
|
||||
|
||||
def prefetch(self, query: str, **kwargs: Any) -> str:
|
||||
result = _api(self._base, "smart-search", {
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
})
|
||||
if not result or not result.get("results"):
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
for r in result["results"][:5]:
|
||||
obs = r.get("observation", r)
|
||||
title = obs.get("title", "")
|
||||
narrative = obs.get("narrative", "")
|
||||
if title:
|
||||
lines.append(f"- {title}: {narrative[:200]}")
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def queue_prefetch(self, query: str, **kwargs: Any) -> None:
|
||||
_api_bg(self._base, "smart-search", {"query": query, "limit": 3})
|
||||
|
||||
def get_tool_schemas(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": "memory_recall",
|
||||
"description": "Search agentmemory for past observations by keyword",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {"type": "integer", "description": "Max results", "default": 10},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_save",
|
||||
"description": "Save an insight, decision, or pattern to long-term memory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "What to remember"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["pattern", "preference", "architecture", "bug", "workflow", "fact"],
|
||||
"description": "Memory type",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_search",
|
||||
"description": "Hybrid semantic + keyword search across all memories",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def handle_tool_call(self, name: str, args: dict) -> str:
|
||||
# Hermes stores the return value as the tool result `content` in the
|
||||
# session history. Anthropic-protocol providers reject non-string
|
||||
# content with a 400 on the next request, so always serialize to a
|
||||
# JSON string here — matches what agentmemory's main MCP server does
|
||||
# in src/mcp/standalone.ts (`{ type: "text", text: JSON.stringify(...) }`).
|
||||
if name == "memory_recall":
|
||||
result = _api(self._base, "search", {
|
||||
"query": args["query"],
|
||||
"limit": args.get("limit", 10),
|
||||
})
|
||||
if not result:
|
||||
return json.dumps({"results": []})
|
||||
items = []
|
||||
for r in result.get("results", []):
|
||||
obs = r.get("observation", r)
|
||||
items.append({
|
||||
"title": obs.get("title", ""),
|
||||
"type": obs.get("type", ""),
|
||||
"narrative": obs.get("narrative", ""),
|
||||
"importance": obs.get("importance", 0),
|
||||
"timestamp": obs.get("timestamp", ""),
|
||||
})
|
||||
return json.dumps({"results": items})
|
||||
|
||||
if name == "memory_save":
|
||||
result = _api(self._base, "remember", {
|
||||
"content": args["content"],
|
||||
"type": args.get("type", "fact"),
|
||||
})
|
||||
return json.dumps(result or {"success": False})
|
||||
|
||||
if name == "memory_search":
|
||||
result = _api(self._base, "smart-search", {
|
||||
"query": args["query"],
|
||||
"limit": args.get("limit", 5),
|
||||
})
|
||||
if not result:
|
||||
return json.dumps({"results": []})
|
||||
items = []
|
||||
for r in result.get("results", []):
|
||||
obs = r.get("observation", r)
|
||||
items.append({
|
||||
"title": obs.get("title", ""),
|
||||
"narrative": obs.get("narrative", "")[:300],
|
||||
"score": r.get("combinedScore", r.get("score", 0)),
|
||||
})
|
||||
return json.dumps({"results": items})
|
||||
|
||||
return json.dumps({"error": f"Unknown tool: {name}"})
|
||||
|
||||
def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None:
|
||||
_api_bg(self._base, "observe", {
|
||||
"hookType": "post_tool_use",
|
||||
"sessionId": kwargs.get("session_id", self._session_id),
|
||||
"project": self._project,
|
||||
"cwd": self._project,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"data": {
|
||||
"tool_name": "conversation",
|
||||
"tool_input": user[:500],
|
||||
"tool_output": assistant[:2000],
|
||||
},
|
||||
})
|
||||
|
||||
def on_session_end(self, messages: list, **kwargs: Any) -> None:
|
||||
_api(self._base, "session/end", {
|
||||
"sessionId": kwargs.get("session_id", self._session_id),
|
||||
})
|
||||
|
||||
def on_pre_compress(self, messages: list, **kwargs: Any) -> None:
|
||||
result = _api(self._base, "context", {
|
||||
"sessionId": kwargs.get("session_id", self._session_id),
|
||||
"project": self._project,
|
||||
})
|
||||
if result and result.get("context"):
|
||||
messages.insert(0, {
|
||||
"role": "user",
|
||||
"content": f"[agentmemory context before compaction]\n{result['context']}",
|
||||
})
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None:
|
||||
if action in ("add", "update") and content:
|
||||
_api_bg(self._base, "remember", {
|
||||
"content": content,
|
||||
"type": "fact",
|
||||
})
|
||||
|
||||
def shutdown(self, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
ctx.register_memory_provider(AgentMemoryProvider())
|
||||
@@ -0,0 +1,12 @@
|
||||
name: agentmemory
|
||||
version: 0.8.0
|
||||
description: "Persistent cross-session memory for Hermes Agent via agentmemory. 95.2% retrieval accuracy on LongMemEval."
|
||||
author: "Rohit Ghumare"
|
||||
homepage: "https://github.com/rohitg00/agentmemory"
|
||||
hooks:
|
||||
- prefetch
|
||||
- sync_turn
|
||||
- on_session_end
|
||||
- on_pre_compress
|
||||
- on_memory_write
|
||||
- system_prompt_block
|
||||
Reference in New Issue
Block a user