Revert "chore(memory): holographic leaves core for its community catalog plugin"

This reverts commit 9e827685f2 (#136424). It landed before the announced
October 15, 2026 date for holographic leaving core; it re-lands on or after
that date. Restores plugins/memory/holographic and its tests/docs.
This commit is contained in:
teknium1 authored and Teknium committed 2026-10-10 20:58:02 -07:00
1 parent 4b351c751a
commit 435b7bd3a7
30 files changed
+1914 -94

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ Si tu habilidad es especializada, contribuida por la comunidad o de nicho, es me
## Proveedores de Memoria: Publicar como Plugin Independiente
**Ya no aceptamos nuevos proveedores de memoria en este repositorio.** El conjunto de proveedores integrados en `plugins/memory/` (byterover, retaindb) está cerrado, y los antiguos proveedores integrados hindsight, honcho, supermemory, mem0, openviking y holographic ahora se distribuyen desde el catálogo de plugins. Si quieres añadir un nuevo backend de memoria, publícalo como un **repositorio de plugin independiente** que los usuarios instalen en `~/.hermes/plugins/` (o a través de un entry point de pip).
**Ya no aceptamos nuevos proveedores de memoria en este repositorio.** El conjunto de proveedores integrados en `plugins/memory/` (byterover, holographic, retaindb) está cerrado, y los antiguos proveedores integrados hindsight, honcho, supermemory, mem0 y openviking ahora se distribuyen desde el catálogo de plugins. Si quieres añadir un nuevo backend de memoria, publícalo como un **repositorio de plugin independiente** que los usuarios instalen en `~/.hermes/plugins/` (o a través de un entry point de pip).
Los plugins de memoria independientes:
+1 -1
View File
@@ -69,7 +69,7 @@ If your skill is specialized, community-contributed, or niche, it's better suite
## Memory Providers: Ship as a Standalone Plugin
**We are no longer accepting new memory providers into this repo.** The set of built-in providers under `plugins/memory/` (byterover, retaindb) is closed, and the former in-tree providers hindsight, honcho, supermemory, mem0, openviking and holographic now ship from the plugin catalog. If you want to add a new memory backend, publish it as a **standalone plugin repo** that users install into `~/.hermes/plugins/` (or via a pip entry point).
**We are no longer accepting new memory providers into this repo.** The set of built-in providers under `plugins/memory/` (byterover, holographic, retaindb) is closed, and the former in-tree providers hindsight, honcho, supermemory, mem0 and openviking now ship from the plugin catalog. If you want to add a new memory backend, publish it as a **standalone plugin repo** that users install into `~/.hermes/plugins/` (or via a pip entry point).
Standalone memory plugins:
+2 -2
View File
@@ -285,8 +285,8 @@ class GatewayProfileReconcileMixin:
from hermes_state_registry import close_all_under
close_all_under(home)
with _log_suppressed(logging.DEBUG, "memory-store release failed", exc_info=True):
from plugins.memory import import_provider_module # catalog plugin since it left core
import_provider_module("holographic", "store").MemoryStore.release_all_under(home)
from plugins.memory.holographic.store import MemoryStore
MemoryStore.release_all_under(home)
# The gateway process is a second holder the CLI-side delete cannot reach: its routed
# log files (logs/.__agent.lock, logs/.__errors.lock) and its scoped MCP servers'
# mcp-stderr.log handle live HERE, so unserve must release them in this process too
+2 -2
View File
@@ -1320,8 +1320,8 @@ DEFAULT_CONFIG = {
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# Periodic built-in memory review; 0 when an external provider auto-extracts.
"nudge_interval": 10,
# One external provider: bundled (retaindb, byterover) or catalog-installed (honcho,
# hindsight, supermemory, mem0, openviking, holographic). Empty = built-in only.
# One external provider: bundled (holographic, retaindb, byterover) or catalog-installed
# (honcho, hindsight, supermemory, mem0, openviking). Empty = built-in only.
"provider": "",
},
# Subagent delegation — override the provider:model used by delegate_task so children run on a
+1 -16
View File
@@ -139,21 +139,6 @@ def _adopt_legacy_sidecar(plugin_dir: Path, record: dict) -> Optional[dict]:
return _write_catalog_block(plugin_dir, record, block)
# Archived Nous handoff repos of providers that left core -> the catalog entry that superseded them: a URL
# install of one is adopted as that entry, so `update` re-pins it instead of pulling the archive forever.
_HANDOFF_REPOS = {"github.com/nousresearch/hermes-plugin-holographic": "holographic"}
def _adopt_handoff_install(plugin_dir: Path, record: dict) -> Optional[dict]:
source = str(record.get("source") or "").split("#", 1)[0]
entry = get_live_catalog_entry(_HANDOFF_REPOS.get(_normalize_repo(source), ""))
if entry is None:
return None
sha = str(record.get("revision") or "").lower()
return _write_catalog_block(plugin_dir, record, {"name": entry.name, "repo": source, "tier": entry.tier,
"pin": sha, "sha": sha})
def read_catalog_sidecar(plugin_dir) -> Optional[dict]:
"""Catalog provenance of an installed plugin (``catalog_name``/``repo``/``sha``/``tier``/``pin``), or
``None`` for a non-catalog install. Read from the installer-owned metadata record, never from the
@@ -183,7 +168,7 @@ def read_catalog_sidecar(plugin_dir) -> Optional[dict]:
}
block = _write_catalog_block(plugin_dir, record, block)
else:
block = _adopt_legacy_sidecar(plugin_dir, record) or _adopt_handoff_install(plugin_dir, record)
block = _adopt_legacy_sidecar(plugin_dir, record)
if not block or not block.get("name"):
return None
return {"catalog_name": block["name"], "repo": block.get("repo", ""), "sha": block.get("sha", ""),
+1 -1
View File
@@ -186,7 +186,7 @@ def update_plugin(
raise pc.PluginOperationError(
f"The updated plugin renamed itself to '{installed_name}', but that plugin already exists.")
pc._check_manifest_version(manifest, installed_name)
pc._scan_merged_tree(staged, source, merged, force=False, reviewed_pin=catalog_entry is not None)
pc._scan_merged_tree(staged, source, merged, force=False)
pc._copy_example_files(staged, pc._console())
_refresh_declared_dependencies(target, staged, manifest, interactive=interactive)
if tree_digest(target) != before:
+2 -2
View File
@@ -12,9 +12,9 @@ def build_memory_parser(subparsers, *, cmd_memory: Callable) -> None:
memory_parser = subparsers.add_parser(
"memory", help="Configure external memory provider",
description="Set up and manage external memory provider plugins.\n\n"
"Bundled providers: retaindb, byterover.\n"
"Bundled providers: holographic, retaindb, byterover.\n"
"Catalog providers (e.g. honcho, hindsight, supermemory,\n"
"mem0, openviking, holographic): hermes plugins install <name>.\n\n"
"mem0, openviking): hermes plugins install <name>.\n\n"
"Only one external provider can be active at a time.\n"
"Built-in memory (MEMORY.md/USER.md) is always active.")
memory_sub = memory_parser.add_subparsers(dest="memory_command")
+2 -2
View File
@@ -17,8 +17,8 @@ command. A hook with no concrete consumer is speculative infrastructure and is r
## What may live in this tree (policy)
- **No new in-tree memory providers (May 2026).** `plugins/memory/` is closed (byterover,
retaindb stay until their Oct 15 2026 removal). hindsight (Sep 2026), honcho, supermemory,
mem0, openviking and holographic (Oct 2026) moved to the plugin catalog — `plugin-catalog/<name>.yaml`, auto-installed by
holographic, retaindb stay until their Oct 15 2026 removal). hindsight (Sep 2026), honcho,
supermemory, mem0 and openviking (Oct 2026) moved to the plugin catalog — `plugin-catalog/<name>.yaml`, auto-installed by
`hermes_cli/memory_provider_migration.py` for homes still configured for them. Host-side code a
catalog provider still relies on (the `honcho_host_block` config storage kind, profile clone /
rename / update-sync hooks) resolves the provider's modules through
+36
View File
@@ -0,0 +1,36 @@
# Holographic Memory Provider
Local SQLite fact store with FTS5 search, trust scoring, entity resolution, and HRR-based compositional retrieval.
## Requirements
None — uses SQLite (always available). NumPy optional for HRR algebra.
## Setup
```bash
hermes memory setup # select "holographic"
```
Or manually:
```bash
hermes config set memory.provider holographic
```
## Config
Config in `config.yaml` under `plugins.hermes-memory-store`:
| Key | Default | Description |
|-----|---------|-------------|
| `db_path` | `$HERMES_HOME/memory_store.db` | SQLite database path |
| `auto_extract` | `false` | Auto-extract facts at session end |
| `default_trust` | `0.5` | Default trust score for new facts |
| `hrr_dim` | `1024` | HRR vector dimensions |
## Tools
| Tool | Description |
|------|-------------|
| `fact_store` | 9 actions: add, search, probe, related, reason, contradict, update, remove, list |
| `fact_feedback` | Rate facts as helpful/unhelpful (trains trust scores) |
+267
View File
@@ -0,0 +1,267 @@
"""hermes-memory-store — holographic memory plugin (MemoryProvider): structured fact storage with entity
resolution, trust scoring, and HRR-based compositional retrieval. Original plugin by dusterbloom (PR #2351).
Config in $HERMES_HOME/config.yaml under plugins.hermes-memory-store: db_path ($HERMES_HOME/memory_store.db),
auto_extract (false), default_trust (0.5), min_trust_threshold (0.3), temporal_decay_half_life (0),
hrr_dim (1024), hrr_weight (0.3)."""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
from utils import is_truthy_value
from .store import MemoryStore
from .retrieval import FactRetriever
from hermes_cli.config import cfg_get
logger = logging.getLogger(__name__)
# Expanded by initialize() against the profile that opens it. A concrete path is copied by a profile clone
# and outlives a rename, so it keeps naming the old profile's DB.
_DEFAULT_DB_PATH = "$HERMES_HOME/memory_store.db"
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\nACTIONS (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"],
},
}
# Auto-extraction (on_session_end): (patterns, category) — user preferences -> user_pref, decisions -> project.
_EXTRACT_CATEGORIES = (
([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)], "user_pref"),
([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)], "project"),
)
def _load_plugin_config() -> dict:
try:
from hermes_cli.config import load_config_readonly # canonical: managed-scope overlay + ${VAR} expansion
return cfg_get(load_config_readonly(), "plugins", "hermes-memory-store", default={}) or {}
except Exception:
return {}
def _results(items: list, key: str = "results") -> str:
return json.dumps({key: items, "count": len(items)})
def _limit(args: dict) -> int:
return int(args.get("limit", 10))
def _tool_handler(actions: dict):
"""(self, args) handler dispatching on args["action"] over ``actions``; unknown action -> tool_error."""
return lambda self, args: (actions[args["action"]](self, args) if args["action"] in actions
else tool_error(f"Unknown action: {args['action']}"))
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 = 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."""
# The canonical writer: config lock, managed-mode refusal, default stripping, atomic replace.
# ``merge_existing`` keeps every other section; *hermes_home* is the active profile already.
from hermes_cli.config import save_config
values = dict(values)
# This profile's own DB spelled out (older setups wrote it; the dashboard form re-submits what it
# read) pins every clone and rename of the profile to this file, so it is stored as the placeholder.
db_path = values.get("db_path")
if isinstance(db_path, str) and Path(db_path).expanduser() == Path(hermes_home) / "memory_store.db":
values["db_path"] = _DEFAULT_DB_PATH
save_config({"plugins": {"hermes-memory-store": values}}, merge_existing=True)
def get_config_schema(self):
return [
{"key": "db_path", "description": "SQLite database path", "default": _DEFAULT_DB_PATH},
{"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())
db_path = self._config.get("db_path", _hermes_home + "/memory_store.db")
if isinstance(db_path, str): # expand $HERMES_HOME so paths resolve to the active profile
db_path = db_path.replace("$HERMES_HOME", _hermes_home).replace("${HERMES_HOME}", _hermes_home)
hrr_dim = int(self._config.get("hrr_dim", 1024))
self._store = MemoryStore(db_path=db_path, default_trust=float(self._config.get("default_trust", 0.5)), hrr_dim=hrr_dim)
self._retriever = FactRetriever(store=self._store, hrr_dim=hrr_dim, hrr_weight=float(self._config.get("hrr_weight", 0.3)),
temporal_decay_half_life=int(self._config.get("temporal_decay_half_life", 0)))
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
body = ("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"
if total == 0 else
f"Active. {total} facts stored with entity resolution and trust scoring.\n"
"Use fact_store to search, probe entities, reason across entities, or add facts.\n")
return "# Holographic Memory\n" + body + "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)
lines = [f"- [{r.get('trust_score', r.get('trust', 0)):.1f}] {r.get('content', '')}" for r in results]
return "## Holographic Memory\n" + "\n".join(lines) if results else ""
except Exception as e:
logger.debug("Holographic prefetch failed: %s", e)
return ""
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 not in self._TOOL_HANDLERS:
return tool_error(f"Unknown tool: {tool_name}")
try:
return self._TOOL_HANDLERS[tool_name](self, args)
except KeyError as exc:
return tool_error(f"Missing required argument: {exc}")
except Exception as exc:
return tool_error(str(exc))
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
# is_truthy_value: auto_extract is a string enum ("false"/"true"); plain truthiness would treat "false" as on.
if is_truthy_value(self._config.get("auto_extract", False)) and self._store and messages:
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:
self._store.add_fact(content, category="user_pref" if target == "user" else "general")
except Exception as e:
logger.debug("Holographic memory_write mirror failed: %s", e)
def shutdown(self) -> None:
# Close on the caller's thread: leaving the shared connection (+ write lock) to GC keeps it alive on a gateway.
if self._store is not None:
try:
self._store.close()
except Exception as e:
logger.debug("Holographic shutdown close() failed: %s", e)
self._store = self._retriever = None
# Tool handlers (self, args) -> str. KeyError from args[...] / Exception -> tool_error in handle_tool_call;
# argument coercion order (and therefore which error surfaces first) mirrors the underlying call order.
def _entity_query(self, method: str, a: dict) -> str:
"""'probe' / 'related': single-entity retriever queries."""
return _results(getattr(self._retriever, method)(a["entity"], category=a.get("category"), limit=_limit(a)))
_TOOL_HANDLERS = {
"fact_store": _tool_handler({
"add": lambda self, a: json.dumps({"fact_id": self._store.add_fact(
a["content"], category=a.get("category", "general"), tags=a.get("tags", "")), "status": "added"}),
"search": lambda self, a: _results(self._retriever.search(
a["query"], category=a.get("category"), min_trust=float(a.get("min_trust", self._min_trust)), limit=_limit(a))),
"probe": lambda self, a: self._entity_query("probe", a),
"related": lambda self, a: self._entity_query("related", a),
"reason": lambda self, a: _results(self._retriever.reason(a["entities"], category=a.get("category"), limit=_limit(a)))
if a.get("entities") else tool_error("reason requires 'entities' list"),
"contradict": lambda self, a: _results(self._retriever.contradict(category=a.get("category"), limit=_limit(a))),
"update": lambda self, a: json.dumps({"updated": self._store.update_fact(
int(a["fact_id"]), content=a.get("content"), trust_delta=float(a["trust_delta"]) if "trust_delta" in a else None,
tags=a.get("tags"), category=a.get("category"))}),
"remove": lambda self, a: json.dumps({"removed": self._store.remove_fact(int(a["fact_id"]))}),
"list": lambda self, a: _results(self._store.list_facts(
category=a.get("category"), min_trust=float(a.get("min_trust", 0.0)), limit=_limit(a)), key="facts"),
}),
"fact_feedback": lambda self, a: json.dumps(self._store.record_feedback(int(a["fact_id"]), helpful=a["action"] == "helpful")),
}
def _auto_extract_facts(self, messages: list) -> None:
# Compaction handoff summaries arrive as role="user" and match the decision patterns; never store the
# compactor's own output as a fact. A merge-into-tail row holds genuine prior user text BEFORE
# _MERGED_SUMMARY_DELIMITER (after the header) and the summary AFTER it — harvest only that segment.
from agent.context_compressor import _MERGED_PRIOR_CONTEXT_HEADER, _MERGED_SUMMARY_DELIMITER, is_compaction_summary_message # heavy; lazy
extracted = 0
for msg in messages:
content = msg.get("content", "") if msg.get("role") == "user" else None
pre = content.split(_MERGED_SUMMARY_DELIMITER, 1)[0].removeprefix(_MERGED_PRIOR_CONTEXT_HEADER).strip() \
if isinstance(content, str) and _MERGED_SUMMARY_DELIMITER in content else ""
if pre:
content = pre
elif content is None or is_compaction_summary_message(msg):
continue
if not isinstance(content, str) or len(content) < 10:
continue
for patterns, category in _EXTRACT_CATEGORIES:
if any(p.search(content) for p in patterns):
try:
self._store.add_fact(content[:400], category=category)
extracted += 1
except Exception:
pass
if extracted:
logger.info("Auto-extracted %d facts from conversation", extracted)
def register(ctx) -> None:
"""Register the holographic memory provider with the plugin system."""
ctx.register_memory_provider(HolographicMemoryProvider(config=_load_plugin_config()))
+125
View File
@@ -0,0 +1,125 @@
"""Holographic Reduced Representations (HRR) with phase encoding. Each concept is a vector of angles in [0, 2π):
bind = circular convolution (phase addition), unbind = circular correlation (phase subtraction), bundle =
superposition (circular mean). Phase encoding avoids the magnitude collapse of complex-number HRRs and maps cleanly
to cosine similarity; atoms derive deterministically from SHA-256 so representations are identical across processes,
machines, and Python versions. References: Plate (1995) HRRs; Gayler (2004) Vector Symbolic Architectures."""
import hashlib
import logging
import math
import struct
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
np = None # type: ignore[assignment]
_HAS_NUMPY = False
logger = logging.getLogger(__name__)
_TWO_PI = 2.0 * math.pi
_FLOAT32_BLOB_PREFIX = b"HRR1"
_F32, _F64 = 4, 8 # itemsizes of np.float32 / np.float64
ROLE_CONTENT, ROLE_ENTITY = "__hrr_role_content__", "__hrr_role_entity__" # role atoms used by encode_fact
def _require_numpy() -> None:
if not _HAS_NUMPY:
raise RuntimeError("numpy is required for holographic operations")
def encode_atom(word: str, dim: int = 1024) -> np.ndarray:
"""Deterministic phase vector: SHA-256 counter blocks of f"{word}:{i}" -> uint16 -> [0, 2π).
hashlib rather than numpy RNG so atoms are reproducible across platforms."""
_require_numpy()
uint16_values = [v for i in range(math.ceil(dim / 16)) # 32-byte digest = 16 uint16 values
for v in struct.unpack("<16H", hashlib.sha256(f"{word}:{i}".encode()).digest())]
return np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Circular convolution = phase addition; result is quasi-orthogonal to both inputs."""
_require_numpy()
return (a + b) % _TWO_PI
def unbind(memory: np.ndarray, key: np.ndarray) -> np.ndarray:
"""Circular correlation = phase subtraction; unbind(bind(a, b), a) ≈ b."""
_require_numpy()
return (memory - key) % _TWO_PI
def bundle(*vectors: np.ndarray) -> np.ndarray:
"""Superposition via circular mean; holds O(sqrt(dim)) items before similarity degrades."""
_require_numpy()
return np.angle(np.sum([np.exp(1j * v) for v in vectors], axis=0)) % _TWO_PI
def similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Phase cosine similarity in [-1, 1]; ~0 for unrelated vectors."""
_require_numpy()
return float(np.mean(np.cos(a - b)))
def encode_text(text: str, dim: int = 1024) -> np.ndarray:
"""Bag-of-words bundle of token atoms; empty text -> encode_atom("__hrr_empty__")."""
_require_numpy()
tokens = [t for t in (tok.strip(".,!?;:\"'()[]{}") for tok in text.lower().split()) if t]
return bundle(*[encode_atom(token, dim) for token in tokens]) if tokens else encode_atom("__hrr_empty__", dim)
def encode_fact(content: str, entities: list[str], dim: int = 1024) -> np.ndarray:
"""bundle(bind(text, ROLE_CONTENT), bind(entity_i, ROLE_ENTITY)...), so
unbind(fact, bind(entity, ROLE_ENTITY)) ≈ content_vector."""
_require_numpy()
role_content, role_entity = encode_atom(ROLE_CONTENT, dim), encode_atom(ROLE_ENTITY, dim)
return bundle(bind(encode_text(content, dim), role_content),
*[bind(encode_atom(entity.lower(), dim), role_entity) for entity in entities])
def phases_to_bytes(phases: np.ndarray, dim: int | None = None) -> bytes:
"""Serialize as prefixed float32 (half the size of legacy float64 blobs). At dim=1 the prefixed float32 blob
and a raw float64 blob are both 8 bytes, so write legacy float64 there to keep ``bytes_to_phases`` unambiguous."""
_require_numpy()
dim = int(phases.shape[0]) if dim is None else dim
if len(_FLOAT32_BLOB_PREFIX) + dim * _F32 == dim * _F64:
return np.asarray(phases, dtype=np.float64).tobytes()
return _FLOAT32_BLOB_PREFIX + np.asarray(phases, dtype=np.float32).tobytes()
def bytes_to_phases(data: bytes, dim: int | None = None) -> np.ndarray:
"""Deserialize prefixed float32 or legacy raw float64 blobs (always returns float64). With ``dim`` given, a
prefixed blob whose size equals the float64 size (dim=1) is read as legacy float64: ``phases_to_bytes`` never
writes a prefixed blob at that size."""
_require_numpy()
plen = len(_FLOAT32_BLOB_PREFIX)
prefixed = data.startswith(_FLOAT32_BLOB_PREFIX)
f32 = lambda payload: np.frombuffer(payload, dtype=np.float32).astype(np.float64)
f64 = lambda payload: np.frombuffer(payload, dtype=np.float64).copy()
if dim is None:
payload, size, what = (data[plen:], _F32, "float32 vector blob has invalid payload") if prefixed else (data, _F64, "legacy vector blob has invalid")
if len(payload) % size != 0:
raise ValueError(f"HRR {what} byte length: {len(payload)}")
return f32(payload) if prefixed else f64(payload)
float32_blob_bytes, float64_bytes = plen + dim * _F32, dim * _F64
collides = float32_blob_bytes == float64_bytes
if not collides and prefixed and len(data) == float32_blob_bytes:
return f32(data[plen:])
if len(data) == float64_bytes:
return f64(data)
if not prefixed:
raise ValueError(f"HRR legacy vector blob has {len(data)} bytes; expected {float64_bytes} (float64) for dim={dim}")
expected = f"{float64_bytes} (legacy float64)" if collides else f"{float32_blob_bytes} (prefixed float32) or {float64_bytes} (legacy float64)"
raise ValueError(f"HRR vector blob has {len(data)} bytes ({len(data) - plen} payload bytes after the float32 prefix); "
f"expected {expected} for dim={dim}")
def snr_estimate(dim: int, n_items: int) -> float:
"""SNR = sqrt(dim / n_items) (inf when empty); warns below 2.0 (n_items > dim/4)."""
_require_numpy()
snr = math.sqrt(dim / n_items) if n_items > 0 else float("inf")
if snr < 2.0:
logger.warning("HRR storage near capacity: SNR=%.2f (dim=%d, n_items=%d). "
"Retrieval accuracy may degrade. Consider increasing dim or reducing stored items.", snr, dim, n_items)
return snr
+3
View File
@@ -0,0 +1,3 @@
name: holographic
version: 0.1.0
description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval."
+215
View File
@@ -0,0 +1,215 @@
"""Hybrid keyword/BM25 retrieval for the memory store: FTS5 candidates reranked with
Jaccard similarity and HRR vector similarity, trust-weighted (ported from KIK memory_agent.py)."""
from __future__ import annotations
import math
from collections.abc import Callable
from datetime import datetime, timezone, UTC
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .store import MemoryStore
from . import holographic as hrr
_FACT_COLUMNS = "fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at"
_ROLE_ENTITY, _ROLE_CONTENT = hrr.ROLE_ENTITY, hrr.ROLE_CONTENT
_PUNCT = ".,;:!?\"'()[]{}#@<>"
_FTS_OPERATORS = str.maketrans("", "", '"()*^:-+')
# Stopwords dropped before FTS5 OR-expansion: short English function words that
# carry no retrieval signal and force false-negative AND matches.
_FTS_STOPWORDS = frozenset("""
a about above after again all am an and any are as at be because been before being between both but by can could
did do does doing don down during each few for from further had has have having he her here hers herself him himself
his how i if in into is it its itself just me more most my myself no nor not now of off on once only or other our
ours ourselves out over own same she should so some such than that the their theirs them themselves then there these
they this those through to too under until up very was we were what when where which while who whom why will with
would you your yours yourself yourselves""".split())
def _shift(sim: float) -> float:
"""Cosine similarity [-1, 1] -> [0, 1]."""
return (sim + 1.0) / 2.0
class FactRetriever:
"""Multi-strategy fact retrieval with trust-weighted scoring."""
def __init__(self, store: MemoryStore, temporal_decay_half_life: int = 0, # days, 0 = disabled
fts_weight: float = 0.4, jaccard_weight: float = 0.3, hrr_weight: float = 0.3, hrr_dim: int = 1024):
self.store, self.half_life, self.hrr_dim = store, temporal_decay_half_life, hrr_dim
if hrr_weight > 0 and not hrr._HAS_NUMPY: # redistribute weights without numpy
fts_weight, jaccard_weight, hrr_weight = 0.6, 0.4, 0.0
self.fts_weight, self.jaccard_weight, self.hrr_weight = fts_weight, jaccard_weight, hrr_weight
def _atom(self, word: str):
return hrr.encode_atom(word, self.hrr_dim)
def _phases(self, blob: bytes):
return hrr.bytes_to_phases(blob, dim=self.hrr_dim)
def search(self, query: str, category: str | None = None, min_trust: float = 0.3, limit: int = 10) -> list[dict]:
"""FTS5 candidates (limit*3) → Jaccard + HRR rerank → trust weighting → optional temporal decay
0.5^(age_days / half_life). Returns fact dicts with 'score', sorted desc."""
candidates = self._fts_candidates(query, category, min_trust, limit * 3)
query_tokens = self._tokenize(query)
# Query vector is loop-invariant; encode lazily on the first candidate that carries an HRR vector
# so stores whose hrr_vector was never backfilled don't pay for it.
query_vec = None
for fact in candidates:
jaccard = self._jaccard_similarity(query_tokens, self._tokenize(fact["content"]) | self._tokenize(fact.get("tags", "")))
hrr_sim = 0.5 # neutral
if self.hrr_weight > 0 and fact.get("hrr_vector"):
fact_vec = self._phases(fact["hrr_vector"])
if query_vec is None:
query_vec = hrr.encode_text(query, self.hrr_dim)
hrr_sim = _shift(hrr.similarity(query_vec, fact_vec))
relevance = self.fts_weight * fact.get("fts_rank", 0.0) + self.jaccard_weight * jaccard + self.hrr_weight * hrr_sim
fact["score"] = relevance * fact["trust_score"]
if self.half_life > 0:
fact["score"] *= self._temporal_decay(fact.get("updated_at") or fact.get("created_at"))
results = sorted(candidates, key=lambda x: x["score"], reverse=True)[:limit]
for fact in results:
fact.pop("hrr_vector", None) # callers expect JSON-serializable dicts
return results
def _vector_query(self, fallback: str, category: str | None, limit: int, sim_fn: Callable) -> list[dict]:
"""Rank every fact vector (optionally per category) by sim_fn; FTS5 fallback when no vectors exist."""
rows = self._vector_rows(category)
return self._rank_by_vector(rows, sim_fn, limit) if rows else self.search(fallback, category=category, limit=limit)
def probe(self, entity: str, category: str | None = None, limit: int = 10) -> list[dict]:
"""Compositional entity query: unbind bind(entity, ROLE_ENTITY) from the category bank (or each fact vector)
to find facts where the entity plays a structural role. Not keyword search. Falls back to FTS5 without numpy."""
if not hrr._HAS_NUMPY:
return self.search(entity, category=category, limit=limit)
probe_key = hrr.bind(self._atom(entity.lower()), self._atom(_ROLE_ENTITY))
if category: # category bank first, then individual fact vectors
bank_row = self.store._conn.execute("SELECT vector FROM memory_banks WHERE bank_name = ?", (f"cat:{category}",)).fetchone()
if bank_row:
extracted = hrr.unbind(self._phases(bank_row["vector"]), probe_key)
return self._rank_by_vector(self._vector_rows(category), lambda _f, fact_vec: hrr.similarity(extracted, fact_vec), limit)
role_content = self._atom(_ROLE_CONTENT) # loop-invariant: encode once, not per row
# Does unbinding the probe key leave the fact's content signal?
return self._vector_query(entity, category, limit, lambda fact, fact_vec: hrr.similarity(
hrr.unbind(fact_vec, probe_key), hrr.bind(hrr.encode_text(fact["content"], self.hrr_dim), role_content)))
def related(self, entity: str, category: str | None = None, limit: int = 10) -> list[dict]:
"""Facts structurally connected to an entity (shared context), not just facts *about* it as in probe.
Falls back to FTS5 without numpy."""
if not hrr._HAS_NUMPY:
return self.search(entity, category=category, limit=limit)
entity_vec = self._atom(entity.lower()) # bare atom, not role-bound: ANY structural match
roles = (self._atom(_ROLE_ENTITY), self._atom(_ROLE_CONTENT)) # loop-invariant: encode once
# A residual similar to ANY role vector means the entity plays a structural role in the fact.
return self._vector_query(entity, category, limit, lambda _f, fact_vec: max(
hrr.similarity(hrr.unbind(fact_vec, entity_vec), role) for role in roles))
def reason(self, entities: list[str], category: str | None = None, limit: int = 10) -> list[dict]:
"""Multi-entity compositional query (vector-space JOIN): facts where ALL entities play structural roles.
Falls back to FTS5 without numpy."""
if not hrr._HAS_NUMPY or not entities:
return self.search(" ".join(entities), category=category, limit=limit)
role_entity, role_content = self._atom(_ROLE_ENTITY), self._atom(_ROLE_CONTENT)
probe_keys = [hrr.bind(self._atom(entity.lower()), role_entity) for entity in entities]
# AND semantics via min: high only if EVERY entity is structurally present.
return self._vector_query(" ".join(entities), category, limit, lambda _f, fact_vec: min(
hrr.similarity(hrr.unbind(fact_vec, key), role_content) for key in probe_keys))
def contradict(self, category: str | None = None, threshold: float = 0.3, limit: int = 10) -> list[dict]:
"""Pairs of facts sharing entities (same subject) with low content-vector similarity (different claims). Empty without numpy."""
if not hrr._HAS_NUMPY:
return []
rows = self._vector_rows(category, columns="fact_id, content, category, tags, trust_score, created_at, updated_at, hrr_vector")
if len(rows) < 2:
return []
if len(rows) > 500: # O(n²) guard: only compare the most recently updated facts
rows = sorted(rows, key=lambda r: r["updated_at"] or r["created_at"], reverse=True)[:500]
facts = [] # (public dict, lower-cased entity names, phase vector)
for row in rows:
fact = dict(row)
entity_rows = self.store._conn.execute(
"SELECT e.name FROM entities e JOIN fact_entities fe ON fe.entity_id = e.entity_id WHERE fe.fact_id = ?",
(fact["fact_id"],),
).fetchall()
facts.append((fact, {r["name"].lower() for r in entity_rows}, self._phases(fact.pop("hrr_vector"))))
contradictions = []
for i, (f1, ents1, vec1) in enumerate(facts):
for f2, ents2, vec2 in facts[i + 1:]:
if not ents1 or not ents2:
continue
entity_overlap = len(ents1 & ents2) / len(ents1 | ents2)
if entity_overlap < 0.3:
continue # not enough shared subject to be contradictory
content_sim = hrr.similarity(vec1, vec2)
contradiction_score = entity_overlap * (1.0 - _shift(content_sim)) # high overlap + low similarity
if contradiction_score >= threshold:
contradictions.append({
"fact_a": f1, "fact_b": f2,
"entity_overlap": round(entity_overlap, 3),
"content_similarity": round(content_sim, 3),
"contradiction_score": round(contradiction_score, 3),
"shared_entities": sorted(ents1 & ents2),
})
return sorted(contradictions, key=lambda x: x["contradiction_score"], reverse=True)[:limit]
def _vector_rows(self, category: str | None, columns: str = _FACT_COLUMNS + ", hrr_vector") -> list:
"""All facts that carry an HRR vector, optionally filtered by category."""
where = "WHERE hrr_vector IS NOT NULL" + (" AND category = ?" if category else "")
return self.store._conn.execute(f"SELECT {columns} FROM facts {where}", [category] if category else []).fetchall()
def _rank_by_vector(self, rows: list, sim_fn: Callable[[dict, object], float], limit: int) -> list[dict]:
"""Score each row as (sim + 1) / 2 * trust_score (sim shifted to [0, 1]), sorted desc."""
scored = [dict(row) for row in rows]
for fact in scored:
fact["score"] = _shift(sim_fn(fact, self._phases(fact.pop("hrr_vector")))) * fact["trust_score"]
return sorted(scored, key=lambda x: x["score"], reverse=True)[:limit]
def _fts_candidates(self, query: str, category: str | None, min_trust: float, limit: int) -> list[dict]:
"""Raw FTS5 MATCH candidates with rank normalized to [0, 1] as 'fts_rank'."""
category_clause = "AND f.category = ? " if category else ""
params = [self._sanitize_fts_query(query)] + ([category] if category else []) + [min_trust, limit]
sql = ("SELECT f.*, facts_fts.rank as fts_rank_raw FROM facts_fts JOIN facts f ON f.fact_id = facts_fts.rowid "
f"WHERE facts_fts MATCH ? {category_clause}AND f.trust_score >= ? ORDER BY facts_fts.rank LIMIT ?")
try:
results = [dict(row) for row in self.store._conn.execute(sql, params).fetchall()]
except Exception:
return [] # FTS5 MATCH can fail on malformed queries
# FTS5 rank is negative (lower = better); normalize |rank| / max to [0, 1] (1e-6 floor avoids div by zero)
max_rank = max([abs(f["fts_rank_raw"]) for f in results] + [1e-6])
for fact in results:
fact["fts_rank"] = abs(fact.pop("fts_rank_raw")) / max_rank
return results
@staticmethod
def _tokenize(text: str) -> set[str]:
"""Lowercase whitespace tokens with surrounding punctuation stripped (no stemming)."""
return {c for c in (w.strip(_PUNCT) for w in text.lower().split()) if c} if text else set()
@staticmethod
def _sanitize_fts_query(query: str) -> str:
"""Natural-language query -> FTS5-safe OR expression of quoted tokens. FTS5 AND-joins a multi-word
MATCH by default, which tanks recall on prose: drop stopwords and <2-char tokens, strip FTS5 operator
chars, phrase-quote each survivor. If nothing survives, return the raw query (zero results, not a SQL error)."""
if not query:
return ""
tokens = [f'"{c}"' for c in (raw.strip(_PUNCT).translate(_FTS_OPERATORS) for raw in query.lower().split())
if len(c) >= 2 and c not in _FTS_STOPWORDS]
return " OR ".join(tokens) if tokens else query
@staticmethod
def _jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard similarity coefficient: |A ∩ B| / |A ∪ B|."""
return len(set_a & set_b) / len(set_a | set_b) if set_a and set_b else 0.0
def _temporal_decay(self, timestamp_str: str | None) -> float:
"""0.5^(age_days / half_life); 1.0 if disabled, missing, unparseable, or in the future."""
if not self.half_life or not timestamp_str:
return 1.0
try:
ts = datetime.fromisoformat(timestamp_str) if isinstance(timestamp_str, str) else timestamp_str
age_days = (datetime.now(UTC) - (ts if ts.tzinfo else ts.replace(tzinfo=UTC))).total_seconds() / 86400
return 1.0 if age_days < 0 else math.pow(0.5, age_days / self.half_life)
except (ValueError, TypeError):
return 1.0
+309
View File
@@ -0,0 +1,309 @@
"""SQLite-backed fact store with entity resolution and trust scoring (single-user Hermes memory plugin)."""
import os
import re
import sqlite3
import threading
from pathlib import Path
from . import holographic as hrr
_SCHEMA = """
CREATE TABLE IF NOT EXISTS facts (
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL UNIQUE,
category TEXT DEFAULT 'general',
tags TEXT DEFAULT '',
trust_score REAL DEFAULT 0.5,
retrieval_count INTEGER DEFAULT 0,
helpful_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
hrr_vector BLOB
);
CREATE TABLE IF NOT EXISTS entities (
entity_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
entity_type TEXT DEFAULT 'unknown',
aliases TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS fact_entities (
fact_id INTEGER REFERENCES facts(fact_id),
entity_id INTEGER REFERENCES entities(entity_id),
PRIMARY KEY (fact_id, entity_id)
);
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
USING fts5(content, tags, content=facts, content_rowid=fact_id);
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
INSERT INTO facts_fts(rowid, content, tags)
VALUES (new.fact_id, new.content, new.tags);
END;
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
VALUES ('delete', old.fact_id, old.content, old.tags);
END;
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
VALUES ('delete', old.fact_id, old.content, old.tags);
INSERT INTO facts_fts(rowid, content, tags)
VALUES (new.fact_id, new.content, new.tags);
END;
CREATE TABLE IF NOT EXISTS memory_banks (
bank_id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_name TEXT NOT NULL UNIQUE,
vector BLOB NOT NULL,
dim INTEGER NOT NULL,
fact_count INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
_HELPFUL_DELTA, _UNHELPFUL_DELTA = 0.05, -0.10
# Entity extraction patterns, applied in order: capitalized multi-word phrases ("John Doe"), double-quoted terms,
# single-quoted terms, then "X aka Y" (both sides).
_RE_SINGLE_ENTITY = (re.compile(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b'), re.compile(r'"([^"]+)"'), re.compile(r"'([^']+)'"))
_RE_AKA = re.compile(r'(\w+(?:\s+\w+)*)\s+(?:aka|also known as)\s+(\w+(?:\s+\w+)*)', re.IGNORECASE)
_ENTITY_NAMES_SQL = "SELECT e.name FROM entities e JOIN fact_entities fe ON fe.entity_id = e.entity_id WHERE fe.fact_id = ?"
# Entity lookup order: exact name, then aliases (comma-separated; wrapped in commas for whole-alias matching).
_ENTITY_LOOKUPS = ("SELECT entity_id FROM entities WHERE name LIKE ?",
"SELECT entity_id FROM entities WHERE ',' || aliases || ',' LIKE '%,' || ? || ',%'")
def _clamp_trust(value: float) -> float:
return max(0.0, min(1.0, value))
class MemoryStore:
"""SQLite-backed fact store with entity resolution and trust scoring.
Process-wide shared connection registry: SQLite allows one writer at a time and several providers
coexist per process (main agent + every delegate_task subagent), so all instances for the same database
share ONE connection and ONE re-entrant lock — writes are fully serialized and "database is locked" is
impossible. Refcounted: closing one instance never tears the connection out from under a sibling."""
_shared: dict = {}
_shared_guard = threading.Lock()
def __init__(self, db_path: str | Path | None = None, default_trust: float = 0.5, hrr_dim: int = 1024) -> None:
if db_path is None:
from hermes_constants import get_hermes_home
db_path = str(get_hermes_home() / "memory_store.db")
self.db_path = Path(db_path).expanduser()
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.default_trust, self.hrr_dim, self._hrr_available = _clamp_trust(default_trust), hrr_dim, hrr._HAS_NUMPY
try: # resolve() so symlinked/relative paths to the same file share ONE connection
self._key = str(self.db_path.resolve())
except OSError:
self._key = str(self.db_path)
with MemoryStore._shared_guard:
entry = MemoryStore._shared.get(self._key)
if entry is None:
# Autocommit: a write that raises mid-method can't leave a dangling transaction (and its
# write lock) open; the explicit commit() calls in _write are then harmless no-ops.
conn = sqlite3.connect(self._key, check_same_thread=False, timeout=10.0, isolation_level=None)
conn.row_factory = sqlite3.Row
entry = MemoryStore._shared[self._key] = {"conn": conn, "lock": threading.RLock(), "refs": 0, "ready": False}
entry["refs"] += 1
self._entry, self._conn, self._lock = entry, entry["conn"], entry["lock"]
with self._lock: # schema initialised once per shared connection
if not entry["ready"]:
self._init_db()
entry["ready"] = True
def _init_db(self) -> None:
"""Create schema, enable WAL via the shared fallback helper (NFS/SMB/FUSE degrade gracefully), add hrr_vector to pre-HRR DBs."""
from hermes_state_wal import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
self._conn.executescript(_SCHEMA)
if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}:
from hermes_cli.sqlite_util import add_column_if_missing
add_column_if_missing(self._conn, "facts", "hrr_vector", "hrr_vector BLOB")
self._conn.commit()
def _one(self, sql: str, params=()):
return self._conn.execute(sql, params).fetchone()
def _write(self, sql: str, params=()) -> sqlite3.Cursor:
cur = self._conn.execute(sql, params)
self._conn.commit()
return cur
def add_fact(self, content: str, category: str = "general", tags: str = "") -> int:
"""Insert a fact and return its fact_id; on duplicate content (UNIQUE) return the existing fact_id untouched.
Links extracted entities and rebuilds the category bank."""
with self._lock:
content = content.strip()
if not content:
raise ValueError("content must not be empty")
try:
fact_id: int = self._write("INSERT INTO facts (content, category, tags, trust_score) VALUES (?, ?, ?, ?)",
(content, category, tags, self.default_trust)).lastrowid # type: ignore[assignment]
except sqlite3.IntegrityError:
return int(self._one("SELECT fact_id FROM facts WHERE content = ?", (content,))["fact_id"])
self._link_entities(fact_id, content)
self._compute_hrr_vector(fact_id, content)
self._rebuild_bank(category)
return fact_id
def update_fact(self, fact_id: int, content: str | None = None, trust_delta: float | None = None,
tags: str | None = None, category: str | None = None) -> bool:
"""Partially update a fact (trust clamped to [0, 1]). Returns True if the row existed."""
with self._lock:
row = self._one("SELECT fact_id, trust_score FROM facts WHERE fact_id = ?", (fact_id,))
if row is None:
return False
changes = {col: val for col, val in {
"content": content.strip() if content is not None else None, "tags": tags, "category": category,
"trust_score": _clamp_trust(row["trust_score"] + trust_delta) if trust_delta is not None else None,
}.items() if val is not None}
assignments = ", ".join(["updated_at = CURRENT_TIMESTAMP"] + [f"{col} = ?" for col in changes])
self._write(f"UPDATE facts SET {assignments} WHERE fact_id = ?", [*changes.values(), fact_id])
if content is not None: # re-extract entities and recompute the HRR vector
self._write("DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,))
self._link_entities(fact_id, content)
self._compute_hrr_vector(fact_id, content)
self._rebuild_bank(category or self._one("SELECT category FROM facts WHERE fact_id = ?", (fact_id,))["category"])
return True
def remove_fact(self, fact_id: int) -> bool:
"""Delete a fact and its entity links. Returns True if the row existed."""
with self._lock:
row = self._one("SELECT fact_id, category FROM facts WHERE fact_id = ?", (fact_id,))
if row is None:
return False
self._conn.execute("DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,))
self._write("DELETE FROM facts WHERE fact_id = ?", (fact_id,))
self._rebuild_bank(row["category"])
return True
def list_facts(self, category: str | None = None, min_trust: float = 0.0, limit: int = 50) -> list[dict]:
"""Browse facts ordered by trust_score descending, optionally filtered by category / min trust."""
with self._lock:
category_clause = "AND category = ? " if category is not None else ""
params = [min_trust] + ([category] if category is not None else []) + [limit]
sql = ("SELECT fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, "
f"created_at, updated_at FROM facts WHERE trust_score >= ? {category_clause}"
"ORDER BY trust_score DESC LIMIT ?")
return [dict(r) for r in self._conn.execute(sql, params).fetchall()]
def record_feedback(self, fact_id: int, helpful: bool) -> dict:
"""Adjust trust asymmetrically: helpful -> +0.05 and helpful_count += 1; unhelpful -> -0.10.
Returns {fact_id, old_trust, new_trust, helpful_count}. Raises KeyError if fact_id is unknown."""
with self._lock:
row = self._one("SELECT fact_id, trust_score, helpful_count FROM facts WHERE fact_id = ?", (fact_id,))
if row is None:
raise KeyError(f"fact_id {fact_id} not found")
old_trust: float = row["trust_score"]
new_trust = _clamp_trust(old_trust + (_HELPFUL_DELTA if helpful else _UNHELPFUL_DELTA))
increment = 1 if helpful else 0
self._write("UPDATE facts SET trust_score = ?, helpful_count = helpful_count + ?, "
"updated_at = CURRENT_TIMESTAMP WHERE fact_id = ?", (new_trust, increment, fact_id))
return {"fact_id": fact_id, "old_trust": old_trust, "new_trust": new_trust, "helpful_count": row["helpful_count"] + increment}
def _extract_entities(self, text: str) -> list[str]:
"""Regex entity candidates (see the pattern table), deduplicated case-insensitively in first-seen order."""
raw = [m.group(1) for pattern in _RE_SINGLE_ENTITY for m in pattern.finditer(text)]
for m in _RE_AKA.finditer(text):
raw += [m.group(1), m.group(2)]
uniq: dict[str, str] = {} # lower-cased key -> first-seen spelling, insertion-ordered
for name in filter(None, (n.strip() for n in raw)):
uniq.setdefault(name.lower(), name)
return list(uniq.values())
def _link_entities(self, fact_id: int, content: str) -> None:
"""Extract entities from content, resolve/create them, and link each to the fact."""
for name in self._extract_entities(content):
self._write("INSERT OR IGNORE INTO fact_entities (fact_id, entity_id) VALUES (?, ?)",
(fact_id, self._resolve_entity(name)))
def _resolve_entity(self, name: str) -> int:
"""Return the entity_id for a case-insensitive name or alias match, creating the entity if absent."""
for sql in _ENTITY_LOOKUPS:
row = self._one(sql, (name,))
if row is not None:
return int(row["entity_id"])
return int(self._write("INSERT INTO entities (name) VALUES (?)", (name,)).lastrowid) # type: ignore[arg-type]
def _compute_hrr_vector(self, fact_id: int, content: str) -> None:
"""Compute and store the HRR vector for a fact (linked entities as roles). No-op without numpy."""
if not self._hrr_available:
return
entities = [row["name"] for row in self._conn.execute(_ENTITY_NAMES_SQL, (fact_id,)).fetchall()]
blob = hrr.phases_to_bytes(hrr.encode_fact(content, entities, self.hrr_dim))
self._write("UPDATE facts SET hrr_vector = ? WHERE fact_id = ?", (blob, fact_id))
def _rebuild_bank(self, category: str) -> None:
"""Full rebuild of a category's memory bank from all its fact vectors."""
if not self._hrr_available:
return
bank_name = f"cat:{category}"
rows = self._conn.execute("SELECT hrr_vector FROM facts WHERE category = ? AND hrr_vector IS NOT NULL", (category,)).fetchall()
if not rows:
self._write("DELETE FROM memory_banks WHERE bank_name = ?", (bank_name,))
return
bank_vector = hrr.bundle(*[hrr.bytes_to_phases(row["hrr_vector"], dim=self.hrr_dim) for row in rows])
hrr.snr_estimate(self.hrr_dim, len(rows)) # warns when near capacity
self._write("INSERT INTO memory_banks (bank_name, vector, dim, fact_count, updated_at) "
"VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(bank_name) DO UPDATE SET "
"vector = excluded.vector, dim = excluded.dim, fact_count = excluded.fact_count, "
"updated_at = excluded.updated_at", (bank_name, hrr.phases_to_bytes(bank_vector), self.hrr_dim, len(rows)))
@classmethod
def release_all_under(cls, directory: str | Path) -> int:
"""Force-close every shared connection whose database lives under ``directory``; returns the count.
close() is refcount-driven, so a live holder (e.g. an agent's provider) keeps a profile's SQLite handle
open, which on Windows makes rmtree of the profile fail. The directory is going away, so later use by a
stale holder is expected to fail.
That is exactly what a profile delete must break on Windows: the desktop's main ``serve`` process
opens ``memory_store.db`` for every known profile, and ``rmtree`` of the profile directory fails
with ``WinError 32`` while any of those handles is open (#88347). In a process that holds none (e.g.
the CLI deleting from outside serve) this is a harmless no-op returning 0.
"""
root = os.path.normcase(str(Path(directory).expanduser().resolve())) + os.sep
with cls._shared_guard:
doomed = [cls._shared.pop(key) for key in list(cls._shared) if os.path.normcase(key).startswith(root)]
for entry in doomed:
try:
with entry["lock"]:
entry["conn"].close()
except Exception:
pass # an already-closed/broken connection must not abort releasing siblings
return len(doomed)
def close(self) -> None:
"""Release this instance's reference; the connection closes with the last holder. Idempotent."""
with MemoryStore._shared_guard:
entry = getattr(self, "_entry", None)
if entry is None:
return
entry["refs"] -= 1
if entry["refs"] <= 0:
try:
entry["conn"].close()
finally:
# Pop only OUR entry: after release_all_under() a same-path store may have
# registered a FRESH entry under this key; a stale late close() must not evict it.
# See #88347.
if MemoryStore._shared.get(self._key) is entry:
MemoryStore._shared.pop(self._key, None)
self._entry = None
def __enter__(self) -> "MemoryStore":
return self
def __exit__(self, *_: object) -> None:
self.close()
+12 -11
View File
@@ -501,14 +501,15 @@ class TestPluginMemoryDiscovery:
from plugins.memory import discover_memory_providers
providers = discover_memory_providers()
names = [name for name, _, _ in providers]
assert "byterover" in names # bundled
assert "holographic" in names # always available (no external deps)
def test_load_provider_by_name(self):
"""load_memory_provider returns a working provider instance."""
from plugins.memory import load_memory_provider
p = load_memory_provider("byterover")
p = load_memory_provider("holographic")
assert p is not None
assert p.name == "byterover"
assert p.name == "holographic"
assert p.is_available()
def test_load_nonexistent_returns_none(self):
"""load_memory_provider returns None for unknown names."""
@@ -561,14 +562,14 @@ class TestUserInstalledProviderDiscovery:
def test_bundled_takes_precedence(self, tmp_path, monkeypatch):
"""Bundled provider wins when user plugin has the same name."""
from plugins.memory import load_memory_provider, discover_memory_providers
# Create user plugin named "byterover" (same as bundled)
plugin_dir = tmp_path / "plugins" / "byterover"
# Create user plugin named "holographic" (same as bundled)
plugin_dir = tmp_path / "plugins" / "holographic"
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text(
"from agent.memory_provider import MemoryProvider\n"
"class Fake(MemoryProvider):\n"
" @property\n"
" def name(self): return 'byterover-FAKE'\n"
" def name(self): return 'holographic-FAKE'\n"
" def is_available(self): return True\n"
" def initialize(self, **kw): pass\n"
" def sync_turn(self, *a, **kw): pass\n"
@@ -579,15 +580,15 @@ class TestUserInstalledProviderDiscovery:
"plugins.memory._get_user_plugins_dir",
lambda: tmp_path / "plugins",
)
# Load should return bundled (name "byterover"), not user (name "byterover-FAKE")
p = load_memory_provider("byterover")
# Load should return bundled (name "holographic"), not user (name "holographic-FAKE")
p = load_memory_provider("holographic")
assert p is not None
assert p.name == "byterover" # bundled wins
assert p.name == "holographic" # bundled wins
# discover should not duplicate
providers = discover_memory_providers()
count = sum(1 for n, _, _ in providers if n == "byterover")
assert count == 1
holo_count = sum(1 for n, _, _ in providers if n == "holographic")
assert holo_count == 1
class TestUserInstalledProviderCli:
@@ -208,25 +208,6 @@ def test_in_tree_sidecar_cannot_forge_catalog_provenance(world, tmp_path):
assert cat.catalog_annotation(real) == f"catalog:community@{world['sha1'][:8]}"
def test_url_install_of_an_archived_handoff_repo_is_repinned_to_its_catalog_entry(world, tmp_path, monkeypatch):
"""A provider that left core was first handed off through a Nous repo that is now archived; a URL install of
it has no catalog record, so `update` would keep pulling the archive. It is adopted as the catalog entry that
superseded it, and `update` re-pins it to the maintained repo at the reviewed sha."""
archived = tmp_path / "archived"
target = _install_url(archived, "cat-plugin", {"ARCHIVED.md": "moved\n"})
assert cat.read_catalog_sidecar(target) is None # control: an unknown repo stays a plain URL install
monkeypatch.setitem(cat._HANDOFF_REPOS, pc_cat._normalize_repo(archived.as_uri()), "cat-plugin")
assert cat.read_catalog_sidecar(target)["catalog_name"] == "cat-plugin"
seen = {}
real_scan = pc._scan_plugin_tree
monkeypatch.setattr(pc, "_scan_plugin_tree", lambda *a, **k: seen.update(k) or real_scan(*a, **k))
result = pc.dashboard_update_user_plugin("cat-plugin")
assert result["ok"] and result["unchanged"] is False and _head(target) == world["sha1"]
assert seen["reviewed_pin"] is True # the re-pinned tree IS the reviewed sha: caution findings don't block it
record = pc._read_install_metadata()["cat-plugin"]
assert record["source"] == world["repo"].as_uri() and record["catalog"]["repo"] == world["repo"].as_uri()
def test_ref_install_records_installed_sha_so_update_is_offered(world):
"""`install NAME --ref X` checks out X, not the reviewed pin; provenance must say X so list/TUI flag the
drift and `update` re-pins instead of answering 'already at catalog pin'."""
@@ -285,6 +285,7 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path):
from hermes_cli import projects_db
from hermes_cli import kanban_db_connect as kbc
from hermes_state import SessionDB
from plugins.memory.holographic.store import MemoryStore
from plugins.platforms.discord.recovery import DiscordRecoveryStore
from tools import async_delegation
@@ -341,6 +342,14 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path):
finally:
projects_conn.close()
holographic = MemoryStore(db_path=tmp_path / "memory_store.db")
try:
observed["holographic"] = holographic._conn.execute(
"PRAGMA journal_mode"
).fetchone()[0].lower()
finally:
holographic.close()
response_store = ResponseStore(db_path=str(tmp_path / "response_store.db"))
try:
observed["response_store"] = response_store._conn.execute(
@@ -358,5 +367,6 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path):
"session_db": "delete",
"kanban": "delete",
"projects": "delete",
"holographic": "delete",
"response_store": "delete",
}
@@ -109,12 +109,12 @@ def test_bundled_still_wins_over_project(tmp_path, monkeypatch):
PluginManager's later-wins order. A provider is activated by name, so a
directory dropped into the working tree must not be able to shadow a
shipped one and silently redirect the agent's memory."""
_write_provider_dir(tmp_path / ".hermes" / "plugins", "byterover")
_write_provider_dir(tmp_path / ".hermes" / "plugins", "holographic")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
resolved = memory_plugins.find_provider_dir("byterover")
assert resolved == Path(memory_plugins.__file__).parent / "byterover"
resolved = memory_plugins.find_provider_dir("holographic")
assert resolved == Path(memory_plugins.__file__).parent / "holographic"
# ---------------------------------------------------------------------------
@@ -0,0 +1,139 @@
"""Regression tests for #57682 — holographic auto_extract harvested
context-compaction handoff summaries into fact_store, and ran even when
configured off.
Two compounding defects:
1. Gate: the plugin's config schema declares ``auto_extract`` as a string enum
(``"false"``/``"true"``), and the ``on_session_end`` gate used plain
truthiness — ``not "false"`` is ``False`` — so extraction ran despite being
explicitly configured off.
2. Eligibility: ``_auto_extract_facts`` scanned every ``role == "user"``
message. Compaction handoff summaries can be inserted as ``role="user"``
messages, and their prose reliably matches the decision patterns
(``we decided/agreed/chose``, ``the project uses/needs/requires``), so the
compactor's own output was stored as a durable ``project`` fact on every
session rollover that followed a compaction.
"""
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
SUMMARY_PREFIX,
_MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER,
is_compaction_summary_message,
)
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path, **config):
base = {"db_path": str(tmp_path / "memory_store.db"), "hrr_dim": 64}
base.update(config)
provider = HolographicMemoryProvider(config=base)
provider.initialize(session_id="test-session")
return provider
def _fact_contents(provider):
return [f["content"] for f in provider._store.list_facts(limit=100)]
def _user(content, **extra):
msg = {"role": "user", "content": content}
msg.update(extra)
return msg
DECISION_MSG = "we decided to use PostgreSQL for the persistence layer"
SUMMARY_MSG = (
f"{SUMMARY_PREFIX}\n## Historical Task Snapshot\n"
"The project uses a kanban board for all dispatch. "
"We agreed to route reviews through the fan-in consumer."
)
# ---------------------------------------------------------------------------
# Defect 1 — string-boolean gate
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("off_value", [False, "false", "False", "no", "off", "0", None, ""])
def test_auto_extract_off_values_disable_extraction(tmp_path, off_value):
provider = _make_provider(tmp_path, auto_extract=off_value)
provider.on_session_end([_user(DECISION_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
# ---------------------------------------------------------------------------
# Defect 2 — compaction summaries harvested as facts
# ---------------------------------------------------------------------------
def test_compaction_summary_not_harvested(tmp_path):
"""The exact failure mode from #57682: summary prose matches the decision
patterns but must not become a fact."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end([_user(SUMMARY_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
def test_metadata_marked_summary_not_harvested(tmp_path):
"""In-process summaries carry COMPRESSED_SUMMARY_METADATA_KEY even if a
future prefix rewrite changes the content sentinel."""
provider = _make_provider(tmp_path, auto_extract=True)
marked = _user(DECISION_MSG, **{COMPRESSED_SUMMARY_METADATA_KEY: True})
provider.on_session_end([marked])
assert _fact_contents(provider) == []
provider.shutdown()
def test_merged_into_tail_summary_suffix_not_harvested_prefix_content_ignored(tmp_path):
"""Merge-into-tail summaries embed the handoff prefix after the delimiter,
not at the start of the message. The wrapped pre-delimiter segment here has
no fact-pattern match, so nothing is harvested from either side."""
provider = _make_provider(tmp_path, auto_extract=True)
merged = _user(
f"{_MERGED_PRIOR_CONTEXT_HEADER}\nplease fix the login bug\n"
f"{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}"
)
provider.on_session_end([merged])
assert _fact_contents(provider) == []
provider.shutdown()
def test_real_user_messages_still_extracted_alongside_summary(tmp_path):
"""The guard must skip only the summary, not suppress extraction for the
genuine user turns around it."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end(
[
_user(SUMMARY_MSG),
_user("I prefer tabs over spaces for indentation"),
]
)
facts = _fact_contents(provider)
assert len(facts) == 1
assert "tabs over spaces" in facts[0]
provider.shutdown()
# ---------------------------------------------------------------------------
# is_compaction_summary_message — public helper contract
# ---------------------------------------------------------------------------
def test_helper_detects_prefix_metadata_and_merged_forms():
assert is_compaction_summary_message(_user(SUMMARY_MSG))
assert is_compaction_summary_message(
_user("anything", **{COMPRESSED_SUMMARY_METADATA_KEY: True})
)
assert is_compaction_summary_message(
_user(f"prior tail\n{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}")
)
assert not is_compaction_summary_message(_user(DECISION_MSG))
assert not is_compaction_summary_message(_user(""))
@@ -0,0 +1,93 @@
"""The holographic fact DB set up with the default path belongs to whichever profile opens it.
Setup used to offer, and store, the active profile's concrete path (``~/.hermes/memory_store.db``).
``profile create --clone`` copies config.yaml verbatim, so the clone opened the source profile's
facts; ``profile rename`` moves the directory, so the renamed profile opened an empty DB in a
re-created directory under its old name.
"""
from __future__ import annotations
import io
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
import hermes_yaml as yaml
from hermes_cli import memory_setup
from hermes_cli.profiles import create_profile, rename_profile
from plugins.memory.holographic import HolographicMemoryProvider, _load_plugin_config
@pytest.fixture()
def root(tmp_path, monkeypatch):
# A ``~/`` path is displayed through Path.home() and expanded through HOME; both name the sandbox.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _setup_with_defaults(monkeypatch, home: Path) -> dict:
"""``hermes memory setup`` for holographic with Enter on every field, saved as ``cmd_setup`` does."""
monkeypatch.setenv("HERMES_HOME", str(home))
provider = HolographicMemoryProvider(config={"hrr_dim": 64})
monkeypatch.setattr(memory_setup, "_curses_select", lambda *_a, default=0, **_k: default)
monkeypatch.setattr(sys, "stdin", io.StringIO("\n" * 10))
answers: dict = {}
assert memory_setup._prompt_schema_fields("holographic", provider.get_config_schema(), answers, {})
provider.save_config(answers, str(home))
return answers
def _opened_db(monkeypatch, home: Path) -> Path:
"""The DB a session started with ``hermes -p <profile>`` opens."""
monkeypatch.setenv("HERMES_HOME", str(home))
provider = HolographicMemoryProvider(config=_load_plugin_config())
provider.initialize("s")
try:
return provider._store.db_path.resolve()
finally:
provider.shutdown()
@pytest.mark.parametrize("lifecycle", ["clone", "rename"])
def test_default_db_path_follows_the_profile_that_opens_it(root, monkeypatch, lifecycle):
if lifecycle == "clone":
answers = _setup_with_defaults(monkeypatch, root)
monkeypatch.setenv("HERMES_HOME", str(root))
profile = create_profile("work", clone_config=True, no_alias=True)
else:
source = create_profile("alpha", no_alias=True)
answers = _setup_with_defaults(monkeypatch, source)
monkeypatch.setenv("HERMES_HOME", str(root))
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
profile = rename_profile("alpha", "beta")
assert _opened_db(monkeypatch, profile) == (profile / "memory_store.db").resolve()
if lifecycle == "rename":
assert not (root / "profiles" / "alpha").exists()
# The prompt offers the value it stores, so what the user accepted is what config.yaml says.
assert answers["db_path"] == "$HERMES_HOME/memory_store.db"
@pytest.mark.parametrize("spelling", ["display", "absolute", "elsewhere"])
def test_save_config_stores_only_this_profiles_default_path_as_the_placeholder(root, monkeypatch, spelling):
# "display"/"absolute": the concrete value an older setup wrote, and the dashboard form re-submits on
# its next save. "elsewhere": a deliberately shared store (#4726) is kept as given.
profile = create_profile("work", no_alias=True)
monkeypatch.setenv("HERMES_HOME", str(profile))
given = {
"display": "~/.hermes/profiles/work/memory_store.db",
"absolute": str(profile / "memory_store.db"),
"elsewhere": str(root / "shared" / "facts.db"),
}[spelling]
expected = given if spelling == "elsewhere" else "$HERMES_HOME/memory_store.db"
HolographicMemoryProvider(config={"hrr_dim": 64}).save_config({"db_path": given, "hrr_dim": "64"}, str(profile))
stored = yaml.safe_load((profile / "config.yaml").read_text(encoding="utf-8"))["plugins"]["hermes-memory-store"]
assert stored == {"db_path": expected, "hrr_dim": "64"}
@@ -0,0 +1,110 @@
"""Tests for FactRetriever FTS5 query sanitization.
These tests cover the fix where raw natural-language queries passed to
FTS5 MATCH were AND-joined by default, dropping recall to zero on any
multi-word prose query. The sanitizer drops stopwords and OR-joins the
remaining content tokens as phrase literals.
"""
from __future__ import annotations
import pytest
pytest.importorskip("numpy") # retrieval module imports numpy indirectly
from plugins.memory.holographic.retrieval import FactRetriever
from plugins.memory.holographic.store import MemoryStore
# ---------------------------------------------------------------------------
# _sanitize_fts_query — unit tests (no DB required)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"query,expected_tokens",
[
# stopwords dropped
("what happened with the deployment rollback", {"happened", "deployment", "rollback"}),
# single content word passes through
("compaction", {"compaction"}),
# all stopwords → falls back to raw
("the and of", None), # None = sentinel for fallback-to-raw
# empty string → empty output
("", ""),
# FTS5 operator characters stripped
("context: length-probe", {"context", "lengthprobe"}),
# trailing punctuation stripped by tokenizer
("hello, world!", {"hello", "world"}),
],
)
def test_sanitize_fts_query_extracts_content_tokens(query, expected_tokens):
result = FactRetriever._sanitize_fts_query(query)
if expected_tokens == "":
assert result == ""
return
if expected_tokens is None:
# Pathological case: all stopwords — should fall back to raw query
assert result == query
return
# OR-joined phrase literals: `"tok1" OR "tok2" OR ...`
# Extract the tokens between quotes, order-independent.
import re
matches = re.findall(r'"([^"]+)"', result)
assert set(matches) == expected_tokens, f"got {result!r}"
# ---------------------------------------------------------------------------
# Integration test — actually run _fts_candidates against an in-memory DB
# ---------------------------------------------------------------------------
@pytest.fixture
def retriever_with_facts(tmp_path):
"""MemoryStore seeded with a few facts for retrieval tests."""
db_path = tmp_path / "test_facts.db"
store = MemoryStore(str(db_path))
store.add_fact(
content="The Thursday deployment rollback failed because of stale migration state.",
category="project",
)
store.add_fact(
content="Compaction settings tuned to 0.85 threshold.",
category="tool",
)
store.add_fact(
content="Venice.ai advertises availableContextTokens inside model_spec.",
category="tool",
)
retriever = FactRetriever(store=store)
yield retriever
store.close()
def test_prefetch_recovers_prose_query(retriever_with_facts):
"""A natural-language query should now match the relevant fact.
Before the sanitizer fix, 'what happened with the deployment rollback'
returned zero hits because FTS5 required every token to co-occur.
"""
results = retriever_with_facts.search(
"what happened with the deployment rollback"
)
assert len(results) >= 1
# The top hit should be the deployment rollback fact
assert "deployment rollback" in results[0]["content"].lower()
# ---------------------------------------------------------------------------
# Loop-invariant encode hoists (perf) — search/probe/related must encode
# constant vectors ONCE per call, not once per candidate/row.
# encode_text/encode_atom are deterministic (SHA-256 counter blocks), so the
# hoisted vectors are bit-identical to the per-iteration values they replace.
# ---------------------------------------------------------------------------
from plugins.memory.holographic import holographic as hrr
def test_encode_functions_are_deterministic():
"""Soundness premise of the hoists: same input -> identical vector."""
import numpy as np
assert np.array_equal(hrr.encode_text("deploy target", 1024),
hrr.encode_text("deploy target", 1024))
assert np.array_equal(hrr.encode_atom("__hrr_role_content__", 1024),
hrr.encode_atom("__hrr_role_content__", 1024))
@@ -0,0 +1,39 @@
"""``save_config`` of the holographic provider writes config.yaml through the canonical writer.
The provider used to ``yaml.dump`` straight over config.yaml, bypassing the config lock, the
managed-mode refusal and the atomic replace. Two contracts pin the canonical path: unrelated
sections survive a provider save, and a managed install refuses the write.
"""
from __future__ import annotations
import hermes_yaml as yaml
from plugins.memory.holographic import HolographicMemoryProvider
def _provider():
return HolographicMemoryProvider(config={})
def test_save_config_merges_into_existing_yaml(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("model:\n default: keep-me\nmemory:\n provider: holographic\n")
_provider().save_config({"db_path": "custom.db", "hrr_dim": "512"}, str(tmp_path))
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["plugins"]["hermes-memory-store"] == {"db_path": "custom.db", "hrr_dim": "512"}
assert raw["model"]["default"] == "keep-me"
assert raw["memory"]["provider"] == "holographic"
def test_save_config_respects_managed_mode(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
before = "model:\n default: managed\n"
(tmp_path / "config.yaml").write_text(before)
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: True)
monkeypatch.setattr("hermes_cli.config.managed_error", lambda *_a, **_k: None)
_provider().save_config({"db_path": "custom.db"}, str(tmp_path))
assert (tmp_path / "config.yaml").read_text() == before
@@ -0,0 +1,130 @@
"""Regression test for #44037 — holographic provider leaked its SQLite
connection to GC on shutdown instead of closing it.
The corruption-mechanism framing in #44037 (TLS bytes written into the DB via
an fd-recycle race) was not reproducible from the code: dropping a sqlite
connection flushes valid pages through SQLite's own VFS, never TLS framing, and
the provider is at most a *releaser* of DB fds, not the TLS-flushing owner.
But the underlying resource-hygiene bug is real and is what this test pins:
``HolographicMemoryProvider.shutdown()`` must call ``MemoryStore.close()`` so
the ``check_same_thread=False`` connection's fd is released deterministically
on shutdown, rather than at a non-deterministic GC time on an arbitrary thread.
"""
import sqlite3
import pytest
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path):
db_path = str(tmp_path / "memory_store.db")
provider = HolographicMemoryProvider(config={"db_path": db_path, "hrr_dim": 64})
provider.initialize(session_id="test-session")
return provider
def test_shutdown_closes_store_connection(tmp_path):
provider = _make_provider(tmp_path)
store = provider._store
assert store is not None
conn = store._conn
# Connection is live before shutdown.
conn.execute("SELECT 1").fetchone()
provider.shutdown()
# References are dropped...
assert provider._store is None
assert provider._retriever is None
# ...AND the underlying connection was actually closed (not left to GC).
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_release_all_under_closes_connections_inside_directory_only(tmp_path):
"""Regression test for #88347 — profile delete must break refcounted handles.
``close()`` alone can never free a database held by a live agent (refs >= 1),
and on Windows an open SQLite handle makes rmtree of the profile directory
fail with WinError 32. ``release_all_under`` force-closes exactly the shared
connections under the doomed directory and leaves everything else alone.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
inside = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
outside = MemoryStore(db_path=tmp_path / "other" / "memory_store.db", hrr_dim=64)
inside_conn = inside._conn
outside_conn = outside._conn
# Both live before the release; the inside one is held "forever" (refs=1,
# like a live agent's provider — nobody calls close()).
inside_conn.execute("SELECT 1").fetchone()
outside_conn.execute("SELECT 1").fetchone()
try:
released = MemoryStore.release_all_under(profile_dir)
assert released == 1
# The doomed connection is really closed despite refs > 0 ...
with pytest.raises(sqlite3.ProgrammingError):
inside_conn.execute("SELECT 1")
# ... and the registry entry is gone, so a second release finds
# nothing (the CLI-process no-op case).
assert str((profile_dir / "memory_store.db").resolve()) not in MemoryStore._shared
assert MemoryStore.release_all_under(profile_dir) == 0
# A new store on the same path reopens fresh instead of reusing
# the dead connection.
reopened = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
reopened._conn.execute("SELECT 1").fetchone()
# The sibling outside the directory is untouched.
outside_conn.execute("SELECT 1").fetchone()
finally:
inside.close()
reopened.close()
outside.close()
def test_stale_holder_close_does_not_evict_fresh_registry_entry(tmp_path):
"""Follow-up to #88347 — a stale holder's late ``close()`` must be inert.
After ``release_all_under`` force-closes a profile's connection, a store
re-created on the same path registers a FRESH shared entry under the same
key. If the stale holder (whose entry was force-closed) then calls
``close()``, it must not pop the fresh entry out of the registry — that
would let a third store open a SECOND connection to the same database and
silently reintroduce the multi-writer contention the registry prevents.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
db_path = profile_dir / "memory_store.db"
stale = MemoryStore(db_path=db_path, hrr_dim=64)
assert MemoryStore.release_all_under(profile_dir) == 1
fresh = MemoryStore(db_path=db_path, hrr_dim=64)
key = fresh._key
fresh_entry = MemoryStore._shared[key]
# The stale holder's late close must leave the fresh entry registered...
stale.close()
assert MemoryStore._shared.get(key) is fresh_entry
# ...and a third store must attach to the SAME shared connection.
third = MemoryStore(db_path=db_path, hrr_dim=64)
try:
assert third._conn is fresh._conn
finally:
third.close()
fresh.close()
# Normal last-holder close still evicts its own entry.
assert key not in MemoryStore._shared
@@ -0,0 +1,193 @@
"""Tests for the holographic MemoryStore shared-connection registry.
MemoryStore instances pointing at the same database file must share one
process-wide SQLite connection and one re-entrant lock. Multiple providers
coexist in a single process (the main agent plus every delegate_task
subagent); when each instance owned a private connection they raced as
independent WAL writers and intermittently failed with "database is locked".
Covers: connection sharing/refcounting, close() semantics, cross-instance
visibility, concurrent multi-instance writers, and write-lock release after
a failed write.
"""
import sqlite3
import threading
import pytest
from plugins.memory.holographic.store import MemoryStore
@pytest.fixture(autouse=True)
def _clean_shared_registry():
"""Each test starts and ends with an empty shared-connection registry."""
# Drop any leakage from earlier tests in the same process.
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
yield
leaked = list(MemoryStore._shared)
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
assert not leaked, f"test leaked shared connections: {leaked}"
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "memory_store.db"
class TestSharedConnection:
def test_same_path_shares_one_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
assert a._conn is b._conn
assert a._lock is b._lock
assert len(MemoryStore._shared) == 1
assert MemoryStore._shared[str(a.db_path)]["refs"] == 2
finally:
a.close()
b.close()
def test_different_paths_get_distinct_connections(self, tmp_path):
a = MemoryStore(tmp_path / "one.db")
b = MemoryStore(tmp_path / "two.db")
try:
assert a._conn is not b._conn
assert len(MemoryStore._shared) == 2
finally:
a.close()
b.close()
@pytest.mark.require_symlinks
def test_symlinked_path_shares_connection(self, tmp_path):
"""A symlink to the same DB file must hit the same registry entry —
otherwise two connections to one file silently reintroduce the
multi-writer contention the registry exists to prevent."""
real_dir = tmp_path / "real"
real_dir.mkdir()
link_dir = tmp_path / "link"
link_dir.symlink_to(real_dir)
a = MemoryStore(real_dir / "memory_store.db")
b = MemoryStore(link_dir / "memory_store.db")
try:
assert a._conn is b._conn
assert len(MemoryStore._shared) == 1
finally:
a.close()
b.close()
def test_writes_visible_across_instances(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
fact_id = a.add_fact("Hermes likes shared connections", category="test")
facts = b.list_facts(category="test")
assert [f["fact_id"] for f in facts] == [fact_id]
finally:
a.close()
b.close()
class TestCloseSemantics:
def test_closing_one_instance_keeps_sibling_alive(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
try:
# The shared connection must survive the sibling's close().
fact_id = b.add_fact("survivor write")
assert fact_id > 0
finally:
b.close()
def test_last_close_releases_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
conn = a._conn
a.close()
b.close()
assert MemoryStore._shared == {}
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_close_is_idempotent(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
a.close() # double close must not steal b's reference
try:
b.add_fact("still alive after double close")
assert MemoryStore._shared[str(b.db_path)]["refs"] == 1
finally:
b.close()
def test_context_manager_releases_reference(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("context managed")
assert MemoryStore._shared == {}
def test_reopen_after_full_close(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("first lifetime")
with MemoryStore(db_path) as store:
facts = store.list_facts()
assert [f["content"] for f in facts] == ["first lifetime"]
class TestConcurrency:
def test_concurrent_multi_instance_writers(self, db_path):
"""Many instances writing from many threads must never hit
'database is locked' — the failure mode of per-instance connections."""
n_threads, n_facts = 8, 15
errors: list[BaseException] = []
def writer(idx: int) -> None:
store = MemoryStore(db_path)
try:
for i in range(n_facts):
store.add_fact(f"fact thread={idx} seq={i}", category="load")
except BaseException as exc:
errors.append(exc)
finally:
store.close()
threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"concurrent writers failed: {errors[:3]}"
with MemoryStore(db_path) as store:
facts = store.list_facts(category="load", limit=500)
assert len(facts) == n_threads * n_facts
assert MemoryStore._shared == {}
def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):
"""A write that raises mid-method must not leave an open transaction
holding the SQLite write lock (autocommit isolation_level=None)."""
broken = MemoryStore(db_path)
sibling = MemoryStore(db_path)
try:
monkeypatch.setattr(
MemoryStore,
"_rebuild_bank",
lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")),
)
with pytest.raises(RuntimeError, match="boom"):
broken.add_fact("write that fails after the INSERT")
monkeypatch.undo()
# No dangling transaction: the connection reports autocommit state
# and the sibling can write immediately.
assert broken._conn.in_transaction is False
sibling.add_fact("sibling write right after the failure")
finally:
broken.close()
sibling.close()
@@ -0,0 +1,205 @@
"""Storage-size regression tests for holographic HRR vectors."""
from __future__ import annotations
import pytest
np = pytest.importorskip("numpy")
from plugins.memory.holographic import holographic as hrr
from plugins.memory.holographic.retrieval import FactRetriever
from plugins.memory.holographic.store import MemoryStore
pytestmark = pytest.mark.skipif(
not hrr._HAS_NUMPY,
reason="holographic vector storage requires numpy",
)
def _float32_blob_size(dim: int) -> int:
return len(hrr._FLOAT32_BLOB_PREFIX) + dim * np.dtype(np.float32).itemsize
def test_phases_to_bytes_stores_float32_and_round_trips_with_dim() -> None:
dim = 1024
phases = hrr.encode_atom("storage-size-regression", dim=dim)
blob = hrr.phases_to_bytes(phases)
assert len(blob) == _float32_blob_size(dim)
restored = hrr.bytes_to_phases(blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_phases_to_bytes_round_trips_without_dim() -> None:
dim = 1024
phases = hrr.encode_atom("dimensionless-round-trip", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases))
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_phases_to_bytes_round_trips_ambiguous_small_dims_without_dim() -> None:
dim = 2
phases = hrr.encode_atom("ambiguous-small-dimension", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases))
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_bytes_to_phases_rejects_malformed_float32_blobs() -> None:
phases = hrr.encode_atom("malformed-float32-blob", dim=2)
blob = hrr.phases_to_bytes(phases)
with pytest.raises(ValueError, match="expected .* for dim=3"):
hrr.bytes_to_phases(blob, dim=3)
with pytest.raises(ValueError, match="invalid payload byte length"):
hrr.bytes_to_phases(hrr._FLOAT32_BLOB_PREFIX + b"x")
def test_bytes_to_phases_reads_legacy_float64_blobs_with_and_without_dim() -> None:
dim = 1024
phases = hrr.encode_atom("legacy-float64-regression", dim=dim)
legacy_blob = phases.astype(np.float64, copy=False).tobytes()
assert len(legacy_blob) == dim * np.dtype(np.float64).itemsize
restored_with_dim = hrr.bytes_to_phases(legacy_blob, dim=dim)
restored_without_dim = hrr.bytes_to_phases(legacy_blob)
assert restored_with_dim.shape == (dim,)
assert restored_without_dim.shape == (dim,)
np.testing.assert_allclose(restored_with_dim, phases, rtol=0, atol=0)
np.testing.assert_allclose(restored_without_dim, phases, rtol=0, atol=0)
def test_bytes_to_phases_prefers_dim_matched_legacy_float64_on_prefix_collision() -> None:
dim = 4
legacy_blob = hrr._FLOAT32_BLOB_PREFIX + b"\0" * (
dim * np.dtype(np.float64).itemsize - len(hrr._FLOAT32_BLOB_PREFIX)
)
restored = hrr.bytes_to_phases(legacy_blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_array_equal(
restored,
np.frombuffer(legacy_blob, dtype=np.float64).copy(),
)
def test_dim1_phases_to_bytes_writes_legacy_float64() -> None:
"""At dim=1 the float32 prefixed blob (8 B) collides with raw float64
(8 B), so phases_to_bytes must fall back to raw float64."""
dim = 1
phases = hrr.encode_atom("dim-one-ambiguity", dim=dim)
blob = hrr.phases_to_bytes(phases, dim=dim)
assert len(blob) == dim * np.dtype(np.float64).itemsize # 8 bytes, no prefix
assert not blob.startswith(hrr._FLOAT32_BLOB_PREFIX)
def test_dim1_round_trip_with_dim() -> None:
"""Round-trip at dim=1 must work via the legacy float64 path."""
dim = 1
phases = hrr.encode_atom("dim-one-round-trip", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases, dim=dim), dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=0)
def test_dim1_legacy_blob_starting_with_prefix_decodes_as_float64() -> None:
"""A legacy float64 blob at dim=1 that happens to start with HRR1 must
decode as float64, not be misread as a prefixed float32 blob."""
dim = 1
phases = hrr.encode_atom("prefix-collision-dim-one", dim=dim)
legacy_blob = phases.astype(np.float64).tobytes()
# Force the blob to start with HRR1 prefix bytes
collision_blob = hrr._FLOAT32_BLOB_PREFIX + legacy_blob[len(hrr._FLOAT32_BLOB_PREFIX):]
assert len(collision_blob) == dim * np.dtype(np.float64).itemsize
restored = hrr.bytes_to_phases(collision_blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, np.frombuffer(collision_blob, dtype=np.float64).copy(), rtol=0, atol=0)
def test_memory_store_reads_legacy_float64_vectors(tmp_path) -> None:
dim = 64
db_path = tmp_path / "legacy_memory_store.db"
with MemoryStore(db_path=db_path, hrr_dim=dim) as store:
fact_id = store.add_fact(
'Bob Stone keeps "legacy HRR vectors" searchable.',
category="compat",
tags="legacy storage",
)
fact_blob = store._conn.execute(
"SELECT hrr_vector FROM facts WHERE fact_id = ?",
(fact_id,),
).fetchone()["hrr_vector"]
bank_blob = store._conn.execute(
"SELECT vector FROM memory_banks WHERE bank_name = ?",
("cat:compat",),
).fetchone()["vector"]
legacy_fact_blob = hrr.bytes_to_phases(fact_blob, dim=dim).astype(np.float64).tobytes()
legacy_bank_blob = hrr.bytes_to_phases(bank_blob, dim=dim).astype(np.float64).tobytes()
store._conn.execute(
"UPDATE facts SET hrr_vector = ? WHERE fact_id = ?",
(legacy_fact_blob, fact_id),
)
store._conn.execute(
"UPDATE memory_banks SET vector = ? WHERE bank_name = ?",
(legacy_bank_blob, "cat:compat"),
)
store._conn.commit()
assert len(legacy_fact_blob) == dim * np.dtype(np.float64).itemsize
assert len(legacy_bank_blob) == dim * np.dtype(np.float64).itemsize
retriever = FactRetriever(store, hrr_dim=dim)
results = retriever.search("legacy HRR vectors", category="compat", limit=1)
assert results
assert results[0]["fact_id"] == fact_id
def test_memory_store_persists_fact_and_bank_vectors_as_float32(tmp_path) -> None:
dim = 64
db_path = tmp_path / "memory_store.db"
with MemoryStore(db_path=db_path, hrr_dim=dim) as store:
fact_id = store.add_fact(
'Alice Smith stores "compact HRR vectors" for Python tests.',
category="perf",
tags="hrr storage",
)
fact_blob = store._conn.execute(
"SELECT hrr_vector FROM facts WHERE fact_id = ?",
(fact_id,),
).fetchone()["hrr_vector"]
bank_blob = store._conn.execute(
"SELECT vector FROM memory_banks WHERE bank_name = ?",
("cat:perf",),
).fetchone()["vector"]
assert len(fact_blob) == _float32_blob_size(dim)
assert len(bank_blob) == _float32_blob_size(dim)
retriever = FactRetriever(store, hrr_dim=dim)
results = retriever.search("compact HRR vectors", category="perf", limit=1)
assert results
assert results[0]["fact_id"] == fact_id
+1 -1
View File
@@ -84,7 +84,7 @@ Speech-to-text supports eight providers: local faster-whisper (free, runs on-dev
## Memory & Personalization
- **[Built-in Memory](../user-guide/features/memory.md)** — Persistent, curated memory via `MEMORY.md` and `USER.md` files. The agent maintains bounded stores of personal notes and user profile data that survive across sessions.
- **[Memory Providers](../user-guide/features/memory-providers.md)** — Plug in external memory backends for deeper personalization. Two providers ship with Hermes: RetainDB (hybrid search) and ByteRover (CLI-based); Honcho (dialectic reasoning), Hindsight (knowledge graphs), Supermemory (semantic recall), Mem0 (cloud extraction), OpenViking (tiered retrieval) and Holographic (local SQLite) are available from the plugin catalog via `hermes plugins install <name>`.
- **[Memory Providers](../user-guide/features/memory-providers.md)** — Plug in external memory backends for deeper personalization. Three providers ship with Hermes: Holographic (local SQLite), RetainDB (hybrid search) and ByteRover (CLI-based); Honcho (dialectic reasoning), Hindsight (knowledge graphs), Supermemory (semantic recall), Mem0 (cloud extraction) and OpenViking (tiered retrieval) are available from the plugin catalog via `hermes plugins install <name>`.
## Messaging Platforms
+1 -1
View File
@@ -1569,7 +1569,7 @@ See [Hooks](../user-guide/features/hooks.md) for event signatures and payload sh
hermes memory <subcommand>
```
Set up and manage external memory provider plugins. Bundled providers: retaindb, byterover; honcho, hindsight, supermemory, mem0, openviking and holographic (plugin catalog) after `hermes plugins install <name>` (`hermes update` does this automatically for a provider already named in `memory.provider`). Only one external provider can be active at a time. Built-in memory (MEMORY.md/USER.md) is always active.
Set up and manage external memory provider plugins. Bundled providers: holographic, retaindb, byterover; honcho, hindsight, supermemory, mem0 and openviking (plugin catalog) after `hermes plugins install <name>` (`hermes update` does this automatically for a provider already named in `memory.provider`). Only one external provider can be active at a time. Built-in memory (MEMORY.md/USER.md) is always active.
Subcommands:
@@ -6,7 +6,7 @@ description: "External memory provider plugins — Honcho, OpenViking, Mem0, Hin
# Memory Providers
Hermes Agent ships with 2 external memory provider plugins (RetainDB and ByteRover) that give the agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md, and more (such as Honcho, Hindsight, Supermemory and Holographic) are available from the [plugin catalog](./plugins.md). Only **one** external provider can be active at a time — the built-in memory is always active alongside it.
Hermes Agent ships with 5 external memory provider plugins that give the agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md, and more (such as Honcho, Hindsight and Supermemory) are available from the [plugin catalog](./plugins.md). Only **one** external provider can be active at a time — the built-in memory is always active alongside it.
## Quick Start
@@ -22,9 +22,9 @@ Or set manually in `~/.hermes/config.yaml`:
```yaml
memory:
provider: retaindb # or byterover,
# or honcho / hindsight / supermemory / mem0 / openviking / holographic
# (plugin catalog — run `hermes plugins install <name>` first)
provider: holographic # or retaindb, byterover,
# or honcho / hindsight / supermemory / mem0 / openviking (plugin
# catalog — run `hermes plugins install <name>` first)
```
## How It Works
@@ -551,10 +551,6 @@ Hindsight reads `$HERMES_HOME/hindsight/config.json` (per profile home), `~/.hin
### Holographic
:::info Plugin catalog
Holographic is maintained by [itpartypattaya](https://github.com/itpartypattaya/hermes-plugin-holographic) and installed from the [plugin catalog](./plugins.md) rather than bundled with Hermes. It is the provider that used to ship in-tree, with the same provider name, settings and database. Existing setups are migrated automatically — see [Migrating from bundled Holographic](#migrating-from-bundled-holographic).
:::
Local SQLite fact store with FTS5 full-text search, trust scoring, and HRR (Holographic Reduced Representations) for compositional algebraic queries.
| | |
@@ -568,8 +564,7 @@ Local SQLite fact store with FTS5 full-text search, trust scoring, and HRR (Holo
**Setup:**
```bash
hermes plugins install holographic # from the plugin catalog
hermes memory setup # select "holographic"
hermes memory setup # select "holographic"
# Or manually:
hermes config set memory.provider holographic
```
@@ -588,20 +583,6 @@ hermes config set memory.provider holographic
- `contradict` — automated detection of conflicting facts
- Trust scoring with asymmetric feedback (+0.05 helpful / -0.10 unhelpful)
The catalog plugin has its own release notes and options beyond this list; see its [README](https://github.com/itpartypattaya/hermes-plugin-holographic) for the current tool actions and settings.
#### Migrating from bundled Holographic
Holographic used to ship inside the Hermes tree. If your `config.yaml` already has `memory.provider: holographic`, there is nothing to do:
- `hermes update` installs the catalog plugin into every profile home that names the provider.
- If the plugin is still missing on the first agent start (`hermes chat`, the gateway, Desktop), Hermes installs it and tells you it did.
- With `security.allow_lazy_installs: false` the agent-start path does not install anything; it prints the exact `hermes plugins install holographic` command instead.
Your facts (`$HERMES_HOME/memory_store.db`, or the `db_path` you set) and your `plugins.hermes-memory-store` settings are read exactly as before, so no memory is lost. Verify with `hermes memory status` and `hermes plugins list`.
If you installed Nous Research's interim copy by URL (`NousResearch/hermes-plugin-holographic`, now archived), `hermes plugins update holographic` moves it onto the catalog entry; your data stays where it is.
---
### RetainDB
@@ -800,7 +781,7 @@ package command. Restart Hermes after successful dependency preparation.
| **OpenViking** (plugin catalog) | Self-hosted | Free | 6 | `hermes plugins install openviking` + server | Filesystem hierarchy + tiered loading |
| **Mem0** (plugin catalog) | Cloud/Self-hosted | Free/Paid | 4 | `hermes plugins install mem0` | Server-side LLM extraction + self-hosted/OSS modes |
| **Hindsight** (plugin catalog) | Cloud/Local | Free/Paid | 3 | `hermes plugins install hindsight` | Knowledge graph + reflect synthesis |
| **Holographic** (plugin catalog) | Local | Free | 2 | `hermes plugins install holographic` | HRR algebra + trust scoring |
| **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring |
| **RetainDB** | Cloud | $20/mo | 10 | `requests` | Delta compression |
| **ByteRover** | Local/Cloud | Free/Paid | 3 | `brv` CLI | Pre-compression extraction |
| **Supermemory** (plugin catalog) | Cloud/Self-hosted | Free/Paid | 4 | `hermes plugins install supermemory` | Context fencing + session graph ingest + multi-container |
@@ -823,11 +804,9 @@ published through the [plugin catalog](./plugins.md). Hindsight moved first (see
[Upgrading from the bundled Honcho](#upgrading-from-the-bundled-honcho)), Supermemory (see
[Migrating from bundled Supermemory](#migrating-from-bundled-supermemory)), Mem0 (see
[Migrating from bundled Mem0](#migrating-from-bundled-mem0)) and OpenViking (see
[Migrating from bundled OpenViking](#migrating-from-bundled-openviking)) and Holographic (now
maintained by itpartypattaya, see
[Migrating from bundled Holographic](#migrating-from-bundled-holographic)). RetainDB and
ByteRover leave core on October 15, 2026; their standalone repositories are unmaintained and
open for a new maintainer. Nothing changes for you: the
[Migrating from bundled OpenViking](#migrating-from-bundled-openviking)). Holographic, RetainDB
and ByteRover leave core on October 15, 2026; their standalone repositories are unmaintained
and open for a new maintainer. Nothing changes for you: the
provider name, the settings it reads, its data directory and its tools stay the same.
When a provider you have configured stops shipping with Hermes, `hermes update` installs its
catalog plugin for every profile that names it; if you update through the Desktop app, the
+1 -1
View File
@@ -525,7 +525,7 @@ Full details in [Gating agent skill writes](./skills.md#gating-agent-skill-write
## External Memory Providers
For deeper, persistent memory that goes beyond MEMORY.md and USER.md, Hermes ships with 2 external memory provider plugins — RetainDB and ByteRover — and more, such as Honcho, Hindsight, Supermemory, Mem0, OpenViking and Holographic, are available from the [plugin catalog](plugins.md) via `hermes plugins install <name>`.
For deeper, persistent memory that goes beyond MEMORY.md and USER.md, Hermes ships with 3 external memory provider plugins — Holographic, RetainDB and ByteRover — and more, such as Honcho, Hindsight, Supermemory, Mem0 and OpenViking, are available from the [plugin catalog](plugins.md) via `hermes plugins install <name>`.
External providers run **alongside** built-in memory (never replacing it) and add capabilities like knowledge graphs, semantic search, automatic fact extraction, and cross-session user modeling.
+1 -1
View File
@@ -45,7 +45,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch
- **[Fallback Providers](fallback-providers.md)** — Automatic failover to backup LLM providers when your primary model encounters errors, including independent fallback for auxiliary tasks like vision and compression.
- **[Credential Pools](credential-pools.md)** — Distribute API calls across multiple keys for the same provider. Automatic rotation on rate limits or failures.
- **[Prompt caching](../configuration#prompt-caching)** — Built-in cross-session 1-hour prefix cache for Claude on native Anthropic, OpenRouter, and Nous Portal. Always-on; no configuration required.
- **[Memory Providers](memory-providers.md)** — Plug in external memory backends (RetainDB, ByteRover, plus Honcho, Hindsight, Supermemory, Mem0, OpenViking and Holographic from the plugin catalog) for cross-session user modeling and personalization beyond the built-in memory system.
- **[Memory Providers](memory-providers.md)** — Plug in external memory backends (Holographic, RetainDB, ByteRover, plus Honcho, Hindsight, Supermemory, Mem0 and OpenViking from the plugin catalog) for cross-session user modeling and personalization beyond the built-in memory system.
- **[API Server](api-server.md)** — Expose Hermes as an OpenAI-compatible HTTP endpoint. Connect any frontend that speaks the OpenAI format — Open WebUI, LobeChat, LibreChat, and more.
- **[IDE Integration (ACP)](acp.md)** — Use Hermes inside ACP-compatible editors such as VS Code, Zed, and JetBrains. Chat, tool activity, file diffs, and terminal commands render inside your editor.
- **[Batch Processing](batch-processing.md)** — Run the agent over many prompts or tasks in parallel from the CLI, with structured outputs and trajectory capture suitable for evals or downstream training pipelines.