mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
Every MemoryStore instance opened its own SQLite connection guarded by its own RLock. Several providers coexist in one process (the main agent plus every delegate_task subagent), so instances pointing at the same memory_store.db raced as independent WAL writers. Combined with writes that were not rolled back on error, one connection could leave an open write transaction that pinned the write lock and made every other connection's writes fail with "database is locked" for the full busy timeout. Instances for the same database now share ONE process-wide connection and ONE re-entrant lock, so access is fully serialized and cross-connection contention is impossible. The shared connection is refcounted: closing one instance never tears it out from under a live sibling, and the last close releases it. The connection runs in autocommit (isolation_level=None) so a write that raises mid-method can never leave a dangling transaction holding the write lock; the existing explicit commit() calls become harmless no-ops. The provider's shutdown() now calls the refcount-guarded close() instead of just dropping the reference: leaving finalization to GC kept the connection (and its write lock) alive indefinitely on long-running gateways, prolonging the exact contention this fix removes. The last provider now releases the connection deterministically while siblings stay live; regression tests fail without the wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
419 lines
17 KiB
Python
419 lines
17 KiB
Python
"""hermes-memory-store — holographic memory plugin using MemoryProvider interface.
|
|
|
|
Registers as a MemoryProvider plugin, giving the agent structured fact storage
|
|
with entity resolution, trust scoring, and HRR-based compositional retrieval.
|
|
|
|
Original plugin by dusterbloom (PR #2351), adapted to the MemoryProvider ABC.
|
|
|
|
Config in $HERMES_HOME/config.yaml (profile-scoped):
|
|
plugins:
|
|
hermes-memory-store:
|
|
db_path: $HERMES_HOME/memory_store.db # omit to use the default
|
|
auto_extract: false
|
|
default_trust: 0.5
|
|
min_trust_threshold: 0.3
|
|
temporal_decay_half_life: 0
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, List
|
|
|
|
from agent.memory_provider import MemoryProvider
|
|
from tools.registry import tool_error
|
|
from .store import MemoryStore
|
|
from .retrieval import FactRetriever
|
|
from hermes_cli.config import cfg_get
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool schemas (unchanged from original PR)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FACT_STORE_SCHEMA = {
|
|
"name": "fact_store",
|
|
"description": (
|
|
"Deep structured memory with algebraic reasoning. "
|
|
"Use alongside the memory tool — memory for always-on context, "
|
|
"fact_store for deep recall and compositional queries.\n\n"
|
|
"ACTIONS (simple → powerful):\n"
|
|
"• add — Store a fact the user would expect you to remember.\n"
|
|
"• search — Keyword lookup ('editor config', 'deploy process').\n"
|
|
"• probe — Entity recall: ALL facts about a person/thing.\n"
|
|
"• related — What connects to an entity? Structural adjacency.\n"
|
|
"• reason — Compositional: facts connected to MULTIPLE entities simultaneously.\n"
|
|
"• contradict — Memory hygiene: find facts making conflicting claims.\n"
|
|
"• update/remove/list — CRUD operations.\n\n"
|
|
"IMPORTANT: Before answering questions about the user, ALWAYS probe or reason first."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["add", "search", "probe", "related", "reason", "contradict", "update", "remove", "list"],
|
|
},
|
|
"content": {"type": "string", "description": "Fact content (required for 'add')."},
|
|
"query": {"type": "string", "description": "Search query (required for 'search')."},
|
|
"entity": {"type": "string", "description": "Entity name for 'probe'/'related'."},
|
|
"entities": {"type": "array", "items": {"type": "string"}, "description": "Entity names for 'reason'."},
|
|
"fact_id": {"type": "integer", "description": "Fact ID for 'update'/'remove'."},
|
|
"category": {"type": "string", "enum": ["user_pref", "project", "tool", "general"]},
|
|
"tags": {"type": "string", "description": "Comma-separated tags."},
|
|
"trust_delta": {"type": "number", "description": "Trust adjustment for 'update'."},
|
|
"min_trust": {"type": "number", "description": "Minimum trust filter (default: 0.3)."},
|
|
"limit": {"type": "integer", "description": "Max results (default: 10)."},
|
|
},
|
|
"required": ["action"],
|
|
},
|
|
}
|
|
|
|
FACT_FEEDBACK_SCHEMA = {
|
|
"name": "fact_feedback",
|
|
"description": (
|
|
"Rate a fact after using it. Mark 'helpful' if accurate, 'unhelpful' if outdated. "
|
|
"This trains the memory — good facts rise, bad facts sink."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {"type": "string", "enum": ["helpful", "unhelpful"]},
|
|
"fact_id": {"type": "integer", "description": "The fact ID to rate."},
|
|
},
|
|
"required": ["action", "fact_id"],
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_plugin_config() -> dict:
|
|
from hermes_constants import get_hermes_home
|
|
config_path = get_hermes_home() / "config.yaml"
|
|
if not config_path.exists():
|
|
return {}
|
|
try:
|
|
import yaml
|
|
with open(config_path, encoding="utf-8-sig") as f:
|
|
all_config = yaml.safe_load(f) or {}
|
|
return cfg_get(all_config, "plugins", "hermes-memory-store", default={}) or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MemoryProvider implementation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class HolographicMemoryProvider(MemoryProvider):
|
|
"""Holographic memory with structured facts, entity resolution, and HRR retrieval."""
|
|
|
|
def __init__(self, config: dict | None = None):
|
|
self._config = config or _load_plugin_config()
|
|
self._store = None
|
|
self._retriever = None
|
|
self._min_trust = float(self._config.get("min_trust_threshold", 0.3))
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "holographic"
|
|
|
|
def is_available(self) -> bool:
|
|
return True # SQLite is always available, numpy is optional
|
|
|
|
def save_config(self, values, hermes_home):
|
|
"""Write config to config.yaml under plugins.hermes-memory-store."""
|
|
from pathlib import Path
|
|
config_path = Path(hermes_home) / "config.yaml"
|
|
try:
|
|
import yaml
|
|
existing = {}
|
|
if config_path.exists():
|
|
with open(config_path, encoding="utf-8-sig") as f:
|
|
existing = yaml.safe_load(f) or {}
|
|
existing.setdefault("plugins", {})
|
|
existing["plugins"]["hermes-memory-store"] = values
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(existing, f, default_flow_style=False)
|
|
except Exception:
|
|
pass
|
|
|
|
def get_config_schema(self):
|
|
from hermes_constants import display_hermes_home
|
|
_default_db = f"{display_hermes_home()}/memory_store.db"
|
|
return [
|
|
{"key": "db_path", "description": "SQLite database path", "default": _default_db},
|
|
{"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]},
|
|
{"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"},
|
|
{"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
|
|
]
|
|
|
|
def initialize(self, session_id: str, **kwargs) -> None:
|
|
from hermes_constants import get_hermes_home
|
|
_hermes_home = str(get_hermes_home())
|
|
_default_db = _hermes_home + "/memory_store.db"
|
|
db_path = self._config.get("db_path", _default_db)
|
|
# Expand $HERMES_HOME in user-supplied paths so config values like
|
|
# "$HERMES_HOME/memory_store.db" or "~/.hermes/memory_store.db" both
|
|
# resolve to the active profile's directory.
|
|
if isinstance(db_path, str):
|
|
db_path = db_path.replace("$HERMES_HOME", _hermes_home)
|
|
db_path = db_path.replace("${HERMES_HOME}", _hermes_home)
|
|
default_trust = float(self._config.get("default_trust", 0.5))
|
|
hrr_dim = int(self._config.get("hrr_dim", 1024))
|
|
hrr_weight = float(self._config.get("hrr_weight", 0.3))
|
|
temporal_decay = int(self._config.get("temporal_decay_half_life", 0))
|
|
|
|
self._store = MemoryStore(db_path=db_path, default_trust=default_trust, hrr_dim=hrr_dim)
|
|
self._retriever = FactRetriever(
|
|
store=self._store,
|
|
temporal_decay_half_life=temporal_decay,
|
|
hrr_weight=hrr_weight,
|
|
hrr_dim=hrr_dim,
|
|
)
|
|
self._session_id = session_id
|
|
|
|
def system_prompt_block(self) -> str:
|
|
if not self._store:
|
|
return ""
|
|
try:
|
|
total = self._store._conn.execute(
|
|
"SELECT COUNT(*) FROM facts"
|
|
).fetchone()[0]
|
|
except Exception:
|
|
total = 0
|
|
if total == 0:
|
|
return (
|
|
"# Holographic Memory\n"
|
|
"Active. Empty fact store — proactively add facts the user would expect you to remember.\n"
|
|
"Use fact_store(action='add') to store durable structured facts about people, projects, preferences, decisions.\n"
|
|
"Use fact_feedback to rate facts after using them (trains trust scores)."
|
|
)
|
|
return (
|
|
f"# Holographic Memory\n"
|
|
f"Active. {total} facts stored with entity resolution and trust scoring.\n"
|
|
f"Use fact_store to search, probe entities, reason across entities, or add facts.\n"
|
|
f"Use fact_feedback to rate facts after using them (trains trust scores)."
|
|
)
|
|
|
|
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
if not self._retriever or not query:
|
|
return ""
|
|
try:
|
|
results = self._retriever.search(query, min_trust=self._min_trust, limit=5)
|
|
if not results:
|
|
return ""
|
|
lines = []
|
|
for r in results:
|
|
trust = r.get("trust_score", r.get("trust", 0))
|
|
lines.append(f"- [{trust:.1f}] {r.get('content', '')}")
|
|
return "## Holographic Memory\n" + "\n".join(lines)
|
|
except Exception as e:
|
|
logger.debug("Holographic prefetch failed: %s", e)
|
|
return ""
|
|
|
|
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
|
# Holographic memory stores explicit facts via tools, not auto-sync.
|
|
# The on_session_end hook handles auto-extraction if configured.
|
|
pass
|
|
|
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
return [FACT_STORE_SCHEMA, FACT_FEEDBACK_SCHEMA]
|
|
|
|
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
|
if tool_name == "fact_store":
|
|
return self._handle_fact_store(args)
|
|
elif tool_name == "fact_feedback":
|
|
return self._handle_fact_feedback(args)
|
|
return tool_error(f"Unknown tool: {tool_name}")
|
|
|
|
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
|
if not self._config.get("auto_extract", False):
|
|
return
|
|
if not self._store or not messages:
|
|
return
|
|
self._auto_extract_facts(messages)
|
|
|
|
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
|
"""Mirror built-in memory writes as facts."""
|
|
if action == "add" and self._store and content:
|
|
try:
|
|
category = "user_pref" if target == "user" else "general"
|
|
self._store.add_fact(content, category=category)
|
|
except Exception as e:
|
|
logger.debug("Holographic memory_write mirror failed: %s", e)
|
|
|
|
def shutdown(self) -> None:
|
|
# Release the shared SQLite connection deterministically on the
|
|
# caller's thread. Dropping the reference alone leaves fd finalization
|
|
# to GC, which keeps the connection (and its write lock) alive on a
|
|
# long-running gateway and prolongs the "database is locked" contention
|
|
# this store's shared-connection refcounting is meant to eliminate.
|
|
# close() is idempotent and refcount-guarded, so siblings stay safe.
|
|
if self._store is not None:
|
|
try:
|
|
self._store.close()
|
|
except Exception as e:
|
|
logger.debug("Holographic shutdown close() failed: %s", e)
|
|
self._store = None
|
|
self._retriever = None
|
|
|
|
# -- Tool handlers -------------------------------------------------------
|
|
|
|
def _handle_fact_store(self, args: dict) -> str:
|
|
try:
|
|
action = args["action"]
|
|
store = self._store
|
|
retriever = self._retriever
|
|
|
|
if action == "add":
|
|
fact_id = store.add_fact(
|
|
args["content"],
|
|
category=args.get("category", "general"),
|
|
tags=args.get("tags", ""),
|
|
)
|
|
return json.dumps({"fact_id": fact_id, "status": "added"})
|
|
|
|
elif action == "search":
|
|
results = retriever.search(
|
|
args["query"],
|
|
category=args.get("category"),
|
|
min_trust=float(args.get("min_trust", self._min_trust)),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "probe":
|
|
results = retriever.probe(
|
|
args["entity"],
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "related":
|
|
results = retriever.related(
|
|
args["entity"],
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "reason":
|
|
entities = args.get("entities", [])
|
|
if not entities:
|
|
return tool_error("reason requires 'entities' list")
|
|
results = retriever.reason(
|
|
entities,
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "contradict":
|
|
results = retriever.contradict(
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "update":
|
|
updated = store.update_fact(
|
|
int(args["fact_id"]),
|
|
content=args.get("content"),
|
|
trust_delta=float(args["trust_delta"]) if "trust_delta" in args else None,
|
|
tags=args.get("tags"),
|
|
category=args.get("category"),
|
|
)
|
|
return json.dumps({"updated": updated})
|
|
|
|
elif action == "remove":
|
|
removed = store.remove_fact(int(args["fact_id"]))
|
|
return json.dumps({"removed": removed})
|
|
|
|
elif action == "list":
|
|
facts = store.list_facts(
|
|
category=args.get("category"),
|
|
min_trust=float(args.get("min_trust", 0.0)),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"facts": facts, "count": len(facts)})
|
|
|
|
else:
|
|
return tool_error(f"Unknown action: {action}")
|
|
|
|
except KeyError as exc:
|
|
return tool_error(f"Missing required argument: {exc}")
|
|
except Exception as exc:
|
|
return tool_error(str(exc))
|
|
|
|
def _handle_fact_feedback(self, args: dict) -> str:
|
|
try:
|
|
fact_id = int(args["fact_id"])
|
|
helpful = args["action"] == "helpful"
|
|
result = self._store.record_feedback(fact_id, helpful=helpful)
|
|
return json.dumps(result)
|
|
except KeyError as exc:
|
|
return tool_error(f"Missing required argument: {exc}")
|
|
except Exception as exc:
|
|
return tool_error(str(exc))
|
|
|
|
# -- Auto-extraction (on_session_end) ------------------------------------
|
|
|
|
def _auto_extract_facts(self, messages: list) -> None:
|
|
_PREF_PATTERNS = [
|
|
re.compile(r'\bI\s+(?:prefer|like|love|use|want|need)\s+(.+)', re.IGNORECASE),
|
|
re.compile(r'\bmy\s+(?:favorite|preferred|default)\s+\w+\s+is\s+(.+)', re.IGNORECASE),
|
|
re.compile(r'\bI\s+(?:always|never|usually)\s+(.+)', re.IGNORECASE),
|
|
]
|
|
_DECISION_PATTERNS = [
|
|
re.compile(r'\bwe\s+(?:decided|agreed|chose)\s+(?:to\s+)?(.+)', re.IGNORECASE),
|
|
re.compile(r'\bthe\s+project\s+(?:uses|needs|requires)\s+(.+)', re.IGNORECASE),
|
|
]
|
|
|
|
extracted = 0
|
|
for msg in messages:
|
|
if msg.get("role") != "user":
|
|
continue
|
|
content = msg.get("content", "")
|
|
if not isinstance(content, str) or len(content) < 10:
|
|
continue
|
|
|
|
for pattern in _PREF_PATTERNS:
|
|
if pattern.search(content):
|
|
try:
|
|
self._store.add_fact(content[:400], category="user_pref")
|
|
extracted += 1
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
for pattern in _DECISION_PATTERNS:
|
|
if pattern.search(content):
|
|
try:
|
|
self._store.add_fact(content[:400], category="project")
|
|
extracted += 1
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
if extracted:
|
|
logger.info("Auto-extracted %d facts from conversation", extracted)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register(ctx) -> None:
|
|
"""Register the holographic memory provider with the plugin system."""
|
|
config = _load_plugin_config()
|
|
provider = HolographicMemoryProvider(config=config)
|
|
ctx.register_memory_provider(provider)
|