From 2f202b3797e946a6f49bdd50bd42f50913d71f3f Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Tue, 21 Jul 2026 09:18:14 +1000 Subject: [PATCH 1/2] feat(memory): evidence-gated episodes with local FTS recall Add a built-in episode tool beside MEMORY.md/USER.md and session_search. Episodes store durable lessons under HERMES_HOME/memories/episodes with a mandatory evidence field, stay out of the system prompt, and are searchable via stdlib SQLite FTS5 over episodes plus optional HERMES_HOME roots. Does not require Honcho or any external memory provider. Subagents cannot write episodes (same class as memory). Overlap: not shared FastAPI memory, portable pack skill, or git-as-memory skill. Signed-off-by: joelbrilliant --- hermes_cli/config_defaults.py | 8 + tests/tools/test_episode_memory.py | 249 +++++++ tools/delegate_tool.py | 5 +- tools/episode_memory.py | 792 +++++++++++++++++++++ tools/memory_tool.py | 5 +- toolsets.py | 12 +- website/docs/user-guide/features/memory.md | 42 +- 7 files changed, 1094 insertions(+), 19 deletions(-) create mode 100644 tests/tools/test_episode_memory.py create mode 100644 tools/episode_memory.py diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index dd1cfe88c013a..3d929161bafe0 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1546,6 +1546,14 @@ "write_approval": False, "memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token "user_char_limit": 1375, # ~500 tokens at 2.75 chars/token + # Evidence-gated episode log (not system-prompt injected). Built-in; + # independent of external memory.provider plugins. + "episodes_enabled": True, + # FTS corpus roots under HERMES_HOME for episode(action=recall). + # "episodes" indexes the episode log; "memories" also indexes sibling + # markdown such as MEMORY.md and USER.md. Add "skills" explicitly to + # include installed skill markdown. + "episode_corpus_roots": ["episodes", "memories"], # External memory provider plugin (empty = built-in only). # Set to a provider name to activate: "openviking", "mem0", # "hindsight", "holographic", "retaindb", "byterover". diff --git a/tests/tools/test_episode_memory.py b/tests/tools/test_episode_memory.py new file mode 100644 index 0000000000000..702941efba1ce --- /dev/null +++ b/tests/tools/test_episode_memory.py @@ -0,0 +1,249 @@ +"""Tests for tools/episode_memory.py — evidence-gated episodes + FTS recall.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +from pathlib import Path + +import pytest + +from tools.episode_memory import ( + EPISODE_SCHEMA, + episode_tool, + get_episode, + list_episodes, + recall_episodes, + rebuild_episode_index, + remember_episode, +) +from hermes_cli.config_defaults import DEFAULT_CONFIG + + +@pytest.fixture +def episode_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Avoid config.yaml lookups pulling real user config mid-test. + monkeypatch.setattr( + "tools.episode_memory._episodes_enabled", + lambda: True, + ) + monkeypatch.setattr( + "tools.episode_memory._corpus_roots", + lambda: ("episodes", "memories"), + ) + return home + + +class TestEpisodeSchema: + def test_requires_evidence_in_description(self): + desc = EPISODE_SCHEMA["description"].lower() + assert "evidence" in desc + assert "not injected" in desc or "not" in desc and "system prompt" in desc + assert "session_search" in desc + assert "honcho" in desc # explicit non-dependency + + def test_actions_listed(self): + actions = EPISODE_SCHEMA["parameters"]["properties"]["action"]["enum"] + for name in ("remember", "recall", "list", "get", "reindex"): + assert name in actions + + def test_defaults_live_in_config_defaults(self): + memory = DEFAULT_CONFIG["memory"] + assert memory["episodes_enabled"] is True + assert memory["episode_corpus_roots"] == ["episodes", "memories"] + + def test_core_toolset_and_delegate_boundaries(self): + from tools.delegate_tool import DELEGATE_BLOCKED_TOOLS + from toolsets import TOOLSETS, _HERMES_CORE_TOOLS + + assert "episode" in _HERMES_CORE_TOOLS + assert "episode" in TOOLSETS["memory"]["tools"] + assert "episode" in DELEGATE_BLOCKED_TOOLS + + +class TestRemember: + def test_refuses_without_evidence(self, episode_home): + result = remember_episode( + content="Gateway reconnect needs backoff", + evidence="", + ) + assert result["success"] is False + assert "evidence" in result["error"].lower() + + def test_refuses_empty_content(self, episode_home): + result = remember_episode(content=" ", evidence="abc123") + assert result["success"] is False + + def test_writes_markdown_and_indexes(self, episode_home): + result = remember_episode( + content="Gateway reconnect flakes when the PTY bridge races approval prompts.", + evidence="commit deadbeef", + source="review", + tags=["gateway", "desktop"], + ) + assert result["success"] is True + path = Path(result["path"]) + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "evidence: commit deadbeef" in text + assert "source: review" in text + assert "Gateway reconnect flakes" in text + + hits = recall_episodes("gateway reconnect flakes", k=3) + assert hits["success"] is True + assert hits["count"] >= 1 + assert any("reconnect" in h["snippet"].lower() or "gateway" in h["path"].lower() + for h in hits["hits"]) + + def test_status_shaped_warns_but_writes(self, episode_home): + result = remember_episode( + content="Phase 3 done and queue is at zero for the sprint.", + evidence="/tmp/log.txt", + ) + assert result["success"] is True + assert result.get("warnings") + + def test_missing_fts_keeps_durable_write( + self, + episode_home, + monkeypatch, + ): + def unavailable(_path): + raise sqlite3.OperationalError("no such module: fts5") + + monkeypatch.setattr("tools.episode_memory._connect_index", unavailable) + + result = remember_episode( + content="Gateway reconnect retries must preserve the durable event.", + evidence="commit deadbeef", + ) + + assert result["success"] is True + assert Path(result["path"]).exists() + assert result["index"]["available"] is False + assert result["index"]["code"] == "fts_unavailable" + assert result.get("warnings") + + def test_secret_shaped_refused(self, episode_home): + result = remember_episode( + content="token ghp_abcdefghijklmnopqrstuvwxyz0123456789 is bad", + evidence="note", + ) + assert result["success"] is False + assert "secret" in result["error"].lower() + + +class TestRecallAndList: + def test_recall_empty_index_message(self, episode_home): + result = recall_episodes("nothing here yet") + assert result["success"] is True + assert result["hits"] == [] + + def test_list_and_get(self, episode_home): + written = remember_episode( + content="Desktop updater must preserve local commits on macOS.", + evidence="PR #64576", + source="agent", + ) + listed = list_episodes(limit=5) + assert listed["count"] >= 1 + got = get_episode(written["path"]) + assert got["success"] is True + assert "updater" in got["content"].lower() + assert got["meta"].get("evidence") == "PR #64576" + + def test_get_rejects_path_outside_home(self, episode_home, tmp_path): + outsider = tmp_path / "outside.md" + outsider.write_text("nope", encoding="utf-8") + got = get_episode(str(outsider)) + assert got["success"] is False + + @pytest.mark.parametrize("protected_name", [".env", "auth.json"]) + def test_get_rejects_protected_files_under_home( + self, + episode_home, + protected_name, + ): + protected = episode_home / protected_name + protected.write_text("credential material", encoding="utf-8") + + got = get_episode(str(protected)) + + assert got["success"] is False + assert "episode directory" in got["error"] + + def test_get_and_list_reject_symlink_outside_episode_dir( + self, + episode_home, + ): + episodes = episode_home / "memories" / "episodes" + episodes.mkdir(parents=True) + protected = episode_home / "auth.json" + protected.write_text("credential material", encoding="utf-8") + link = episodes / "credential.md" + try: + link.symlink_to(protected) + except OSError: + pytest.skip("symlinks unavailable") + + got = get_episode(str(link)) + listed = list_episodes() + + assert got["success"] is False + assert listed["episodes"] == [] + + def test_recall_degrades_when_fts_is_unavailable( + self, + episode_home, + monkeypatch, + ): + def unavailable(_path): + raise sqlite3.OperationalError("no such module: fts5") + + monkeypatch.setattr("tools.episode_memory._connect_index", unavailable) + + result = recall_episodes("gateway reconnect") + + assert result["success"] is True + assert result["degraded"] is True + assert result["hits"] == [] + assert result["index"]["code"] == "fts_unavailable" + + def test_indexes_memories_root(self, episode_home): + mem = episode_home / "memories" + mem.mkdir(parents=True, exist_ok=True) + (mem / "MEMORY.md").write_text( + "Studio runs hermes gateway via launchd label ai.hermes.gateway\n", + encoding="utf-8", + ) + rebuild_episode_index() + hits = recall_episodes("launchd hermes gateway", k=5) + assert hits["success"] is True + assert hits["count"] >= 1 + assert any(h["source"] == "memories" for h in hits["hits"]) + + +class TestEpisodeToolDispatch: + def test_tool_json_remember_and_recall(self, episode_home): + raw = episode_tool( + action="remember", + content="Toolset panel flake is a Radix menu timing class under CI contention.", + evidence="https://github.com/NousResearch/hermes-agent/actions/runs/29725368589", + source="agent", + tags="desktop,ci", + ) + data = json.loads(raw) + assert data["success"] is True + + raw2 = episode_tool(action="recall", query="Radix menu timing", k=3) + data2 = json.loads(raw2) + assert data2["success"] is True + assert data2["count"] >= 1 + + def test_unknown_action(self, episode_home): + data = json.loads(episode_tool(action="explode")) + assert data["success"] is False diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 30151b429c425..52b0218a042a7 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -49,6 +49,7 @@ "delegate_task", # no recursive delegation "clarify", # no user interaction "memory", # no writes to shared MEMORY.md + "episode", # no writes to shared episode log "send_message", # no cross-platform side effects "cronjob", # no scheduling more work in the parent's name ] @@ -3745,10 +3746,10 @@ def _build_top_level_description() -> str: "status) and verify it yourself — fetch the URL, stat the file, read " "back the content — before telling the user the operation succeeded.\n" "- Leaf subagents (role='leaf', the default) CANNOT call: " - "delegate_task, clarify, memory, send_message.\n" + "delegate_task, clarify, memory, episode, send_message.\n" "- Orchestrator subagents (role='orchestrator') retain " "delegate_task so they can spawn their own workers, but still " - "cannot use clarify, memory, or send_message. " + "cannot use clarify, memory, episode, or send_message. " f"Orchestrators are bounded by max_spawn_depth={max_depth} for this " f"user and can be disabled globally via " "delegation.orchestrator_enabled=false.\n" diff --git a/tools/episode_memory.py b/tools/episode_memory.py new file mode 100644 index 0000000000000..8ff6810291617 --- /dev/null +++ b/tools/episode_memory.py @@ -0,0 +1,792 @@ +"""Evidence-gated episode memory + FTS recall. + +Episodes are durable lessons stored under ``$HERMES_HOME/memories/episodes/``. +They are **not** injected into the system prompt (unlike MEMORY.md / USER.md). +Writes require evidence (path, commit SHA, PR/issue URL, or log path) so +unverified claims cannot re-enter later wearing authority. + +Recall is local SQLite FTS5 over episodes (and, when enabled, other +HERMES_HOME markdown roots). Stdlib only. No embeddings, no external service, +no Honcho dependency. + +Design intent (user demand): fill the gap between tiny always-on MEMORY.md, +chat-only session_search, and external memory providers. Related prior art +outside core: joelbrilliant/agent-memory-kit (edge install). Non-overlap with +open PRs for shared FastAPI memory (#23684), portable pack skill (#20692), +and git-as-memory skill (#28636). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sqlite3 +import time +from datetime import date +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from hermes_constants import display_hermes_home, get_hermes_home +from tools.registry import registry, tool_error + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + + +def get_episodes_dir() -> Path: + return get_hermes_home() / "memories" / "episodes" + + +def get_episode_index_path() -> Path: + return get_hermes_home() / "memories" / "episodes.db" + + +WRITE_DISCIPLINE = ( + "Episodes are durable lessons that stay useful in a month, not status " + "snapshots. evidence is mandatory (file path, commit SHA, PR/issue URL, " + "or log path). Queue/phase/completion notes belong in a tracker or " + "session_search, not here. An unverified claim in a memory store is worse " + "than no memory." +) + +_STATUS_SHAPED = re.compile( + r"(?i)\b(" + r"phase\s+\d+\s+(done|complete|finished)|" + r"queue\s+(is\s+)?at|" + r"status\s*update|" + r"todo\s*:\s*|" + r"wip\b|" + r"in\s+progress\b" + r")\b" +) + +_SECRET_PATTERNS = [ + re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}"), + re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"), + re.compile(r"AKIA[0-9A-Z]{16}"), + re.compile(r"ghp_[A-Za-z0-9]{36,}"), + re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY"), + re.compile( + r"(?im)^(?:export\s+)?[A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S{16,}" + ), +] + +MAX_EPISODE_CHARS = 4000 +MAX_INDEX_FILE_BYTES = 1024 * 1024 +DEFAULT_CORPUS_ROOTS = ("episodes", "memories") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _slugify(content: str) -> str: + words = re.findall(r"[A-Za-z0-9]+", content.lower()) + slug = "-".join(words[:6]) or "episode" + slug = re.sub(r"[^a-z0-9-]", "", slug) + slug = re.sub(r"-+", "-", slug).strip("-") + return slug or "episode" + + +def _unique_episode_path(base_dir: Path, date_str: str, slug: str) -> Path: + path = base_dir / f"{date_str}-{slug}.md" + if not path.exists(): + return path + n = 2 + while True: + candidate = base_dir / f"{date_str}-{slug}-{n}.md" + if not candidate.exists(): + return candidate + n += 1 + + +def _has_secret(content: str) -> bool: + return any(p.search(content) for p in _SECRET_PATTERNS) + + +def _parse_frontmatter(text: str) -> Tuple[Dict[str, str], str]: + if not text.startswith("---\n"): + return {}, text + end = text.find("\n---\n", 4) + if end < 0: + return {}, text + meta_block = text[4:end] + body = text[end + 5 :] + meta: Dict[str, str] = {} + for line in meta_block.splitlines(): + if ":" not in line: + continue + key, _, val = line.partition(":") + meta[key.strip()] = val.strip() + return meta, body + + +def _looks_status_shaped(content: str) -> bool: + return bool(_STATUS_SHAPED.search(content)) + + +def _episodes_enabled() -> bool: + """Read memory.episodes_enabled from config (default True).""" + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + mem = cfg.get("memory") or {} + if "episodes_enabled" not in mem: + return True + return bool(mem.get("episodes_enabled")) + except Exception: + return True + + +def _corpus_roots() -> Sequence[str]: + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + mem = cfg.get("memory") or {} + roots = mem.get("episode_corpus_roots") + if isinstance(roots, list) and roots: + return tuple(str(r) for r in roots) + except Exception: + pass + return DEFAULT_CORPUS_ROOTS + + +# --------------------------------------------------------------------------- +# Index +# --------------------------------------------------------------------------- + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(" + "content, path UNINDEXED, source UNINDEXED, mtime UNINDEXED, " + "tokenize='porter unicode61')" + ) + + +def _connect_index(path: Path) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path)) + try: + _ensure_schema(conn) + except Exception: + conn.close() + raise + return conn + + +def _is_fts_unavailable_error(exc: BaseException) -> bool: + error = str(exc).lower() + return ( + ("no such module" in error and "fts5" in error) + or "no such tokenizer" in error + ) + + +def _index_unavailable_status(exc: BaseException) -> Dict[str, Any]: + fts_unavailable = _is_fts_unavailable_error(exc) + return { + "available": False, + "degraded": True, + "code": "fts_unavailable" if fts_unavailable else "index_error", + "error": f"Episode search index unavailable: {exc}", + } + + +def _iter_corpus_files(roots: Sequence[str]) -> List[Tuple[Path, str]]: + """Return (absolute_path, source_tag) pairs under HERMES_HOME.""" + home = get_hermes_home() + out: List[Tuple[Path, str]] = [] + seen: set = set() + + for root_name in roots: + root_name = root_name.strip().strip("/") + if not root_name or ".." in root_name: + continue + if root_name == "episodes": + base = get_episodes_dir() + source = "episodes" + else: + base = home / root_name + source = root_name.split("/")[0] + + if not base.exists(): + continue + + if base.is_file() and base.suffix == ".md": + candidates = [base] + else: + candidates = sorted(base.rglob("*.md")) + + for path in candidates: + try: + resolved = path.resolve() + except OSError: + continue + # Stay inside HERMES_HOME + try: + resolved.relative_to(home.resolve()) + except ValueError: + continue + # Skip backups / nested junk + parts = set(resolved.parts) + if any(p in parts for p in (".git", "node_modules", ".archive", "backups")): + continue + key = str(resolved) + if key in seen: + continue + seen.add(key) + out.append((resolved, source)) + return out + + +def rebuild_episode_index( + index_path: Optional[Path] = None, + roots: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """Full rebuild of the FTS index. Safe to call anytime.""" + index_path = index_path or get_episode_index_path() + roots = roots or _corpus_roots() + files = _iter_corpus_files(roots) + + # Atomic replace via temp file + tmp_path = index_path.with_suffix(".db.tmp") + if tmp_path.exists(): + tmp_path.unlink() + + try: + conn = _connect_index(tmp_path) + except sqlite3.Error as exc: + tmp_path.unlink(missing_ok=True) + return { + "success": False, + **_index_unavailable_status(exc), + "message": ( + "Episode search indexing is unavailable. Existing episode files " + "remain available through list and get." + ), + } + + indexed = 0 + skipped = 0 + try: + for path, source in files: + try: + size = path.stat().st_size + if size <= 0 or size > MAX_INDEX_FILE_BYTES: + skipped += 1 + continue + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + skipped += 1 + continue + if _has_secret(text): + skipped += 1 + logger.info("episode index skipped secret-shaped file: %s", path) + continue + mtime = int(path.stat().st_mtime) + conn.execute( + "INSERT INTO docs (content, path, source, mtime) VALUES (?, ?, ?, ?)", + (text, str(path), source, mtime), + ) + indexed += 1 + conn.commit() + finally: + conn.close() + + os.replace(str(tmp_path), str(index_path)) + return { + "success": True, + "available": True, + "degraded": False, + "indexed": indexed, + "skipped": skipped, + "index": str(index_path), + "roots": list(roots), + } + + +def _index_episode_file( + path: Path, + index_path: Optional[Path] = None, +) -> Dict[str, Any]: + index_path = index_path or get_episode_index_path() + try: + text = path.read_text(encoding="utf-8") + mtime = int(path.stat().st_mtime) + except OSError as exc: + logger.debug("episode index insert failed to read %s: %s", path, exc) + return _index_unavailable_status(exc) + + try: + conn = _connect_index(index_path) + except sqlite3.Error as exc: + logger.warning("episode index unavailable: %s", exc) + return _index_unavailable_status(exc) + + try: + # Drop prior row for same path if any, then insert. + conn.execute("DELETE FROM docs WHERE path = ?", (str(path.resolve()),)) + conn.execute( + "INSERT INTO docs (content, path, source, mtime) VALUES (?, ?, ?, ?)", + (text, str(path.resolve()), "episodes", mtime), + ) + conn.commit() + except sqlite3.Error as exc: + logger.warning("episode index insert failed: %s", exc) + return _index_unavailable_status(exc) + finally: + conn.close() + return {"available": True, "degraded": False} + + +def _fts_match_expr(query: str) -> str: + terms = [] + for term in query.split(): + cleaned = term.replace('"', '""') + if cleaned: + terms.append(f'"{cleaned}"') + return " ".join(terms) + + +def recall_episodes( + query: str, + k: int = 5, + source: Optional[str] = None, + index_path: Optional[Path] = None, + auto_rebuild: bool = True, +) -> Dict[str, Any]: + """BM25 recall over the local episode/corpus index.""" + query = (query or "").strip() + if not query: + return {"success": False, "error": "query is required for recall."} + + k = max(1, min(int(k or 5), 20)) + index_path = index_path or get_episode_index_path() + + if not index_path.exists() and auto_rebuild: + rebuild = rebuild_episode_index(index_path=index_path) + if not rebuild.get("success"): + index_status = { + key: rebuild[key] + for key in ("available", "degraded", "code", "error") + if key in rebuild + } + return { + "success": True, + "query": query, + "hits": [], + "count": 0, + "degraded": True, + "index": index_status, + "message": ( + "Episode search is unavailable. Durable episode files remain " + "available through list and get." + ), + } + + if not index_path.exists(): + return { + "success": True, + "hits": [], + "message": ( + f"No episode index at {display_hermes_home()}/memories/episodes.db yet. " + "Write an episode with action=remember first." + ), + } + + match_expr = _fts_match_expr(query) + if not match_expr: + return {"success": True, "hits": [], "query": query} + + conn = sqlite3.connect(str(index_path)) + try: + sql = ( + "SELECT path, source, mtime, bm25(docs) AS score, " + "snippet(docs, 0, '', '', ' ... ', 40) AS snip " + "FROM docs WHERE docs MATCH ?" + ) + params: List[Any] = [match_expr] + if source: + sql += " AND source = ?" + params.append(source) + sql += " ORDER BY bm25(docs) LIMIT ?" + params.append(k) + + try: + rows = conn.execute(sql, params).fetchall() + except sqlite3.Error as exc: + if _is_fts_unavailable_error(exc): + return { + "success": True, + "query": query, + "hits": [], + "count": 0, + "degraded": True, + "index": _index_unavailable_status(exc), + "message": ( + "Episode search is unavailable. Durable episode files remain " + "available through list and get." + ), + } + return { + "success": False, + "error": f"FTS query failed: {exc}", + "hint": "Try simpler terms, or action=reindex.", + } + finally: + conn.close() + + hits = [] + for path, src, mtime, score, snip in rows: + hits.append( + { + "path": path, + "source": src, + "mtime": mtime, + "score": float(score) if score is not None else None, + "snippet": (snip or "").strip(), + } + ) + return {"success": True, "query": query, "hits": hits, "count": len(hits)} + + +# --------------------------------------------------------------------------- +# Remember / list / get +# --------------------------------------------------------------------------- + + +def remember_episode( + content: str, + evidence: str, + source: str = "agent", + tags: Optional[Sequence[str]] = None, + episodes_dir: Optional[Path] = None, + index_path: Optional[Path] = None, +) -> Dict[str, Any]: + """Write an evidence-gated episode markdown file and index it.""" + content = (content or "").strip() + evidence = (evidence or "").strip() + source = (source or "agent").strip() or "agent" + + if not evidence: + return { + "success": False, + "error": "REFUSED: evidence is required.", + "discipline": WRITE_DISCIPLINE, + } + if not content: + return {"success": False, "error": "REFUSED: empty content."} + if len(content) > MAX_EPISODE_CHARS: + return { + "success": False, + "error": ( + f"Episode content is {len(content)} chars; max is {MAX_EPISODE_CHARS}. " + "Write a tighter lesson." + ), + } + if _has_secret(content) or _has_secret(evidence): + return { + "success": False, + "error": "REFUSED: content or evidence looks secret-shaped. Do not store secrets.", + } + + # Soft warn on status-shaped content (still allow if evidence present — + # the model may be wrong about the heuristic). Surface the warning. + warnings: List[str] = [] + if _looks_status_shaped(content): + warnings.append( + "Content looks status-shaped (phase/queue/WIP). Prefer a durable lesson. " + + WRITE_DISCIPLINE + ) + + tag_list = [] + if tags: + for t in tags: + t = str(t).strip() + if t: + tag_list.append(t) + + episodes_dir = episodes_dir or get_episodes_dir() + episodes_dir.mkdir(parents=True, exist_ok=True) + + date_str = date.today().isoformat() + path = _unique_episode_path(episodes_dir, date_str, _slugify(content)) + + frontmatter = [ + "---", + f"date: {date_str}", + f"source: {source}", + f"evidence: {evidence}", + f"tags: [{', '.join(tag_list)}]", + "---", + "", + ] + file_text = "\n".join(frontmatter) + content + "\n" + path.write_text(file_text, encoding="utf-8") + + index_status = _index_episode_file(path, index_path=index_path) + + result: Dict[str, Any] = { + "success": True, + "path": str(path), + "date": date_str, + "source": source, + "evidence": evidence, + "tags": tag_list, + "index": index_status, + } + if index_status["available"]: + result["message"] = ( + f"Episode written to {path.name}. It is searchable via " + "episode(action='recall') and is NOT injected into the system prompt." + ) + else: + result["message"] = ( + f"Episode written to {path.name}. Search indexing is unavailable, " + "but the durable file is available through episode(action='list') " + "and episode(action='get')." + ) + warnings.append(index_status["error"]) + if warnings: + result["warnings"] = warnings + return result + + +def list_episodes( + limit: int = 20, + episodes_dir: Optional[Path] = None, +) -> Dict[str, Any]: + episodes_dir = episodes_dir or get_episodes_dir() + limit = max(1, min(int(limit or 20), 100)) + if not episodes_dir.exists(): + return {"success": True, "episodes": [], "count": 0} + + try: + resolved_dir = episodes_dir.resolve() + except OSError: + return {"success": True, "episodes": [], "count": 0} + + files = [] + for candidate in episodes_dir.glob("*.md"): + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(resolved_dir) + if resolved.is_file(): + files.append(resolved) + except (OSError, ValueError): + continue + files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + + items = [] + for path in files[:limit]: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + meta, body = _parse_frontmatter(text) + preview = " ".join(body.strip().split())[:160] + items.append( + { + "path": str(path), + "date": meta.get("date"), + "source": meta.get("source"), + "evidence": meta.get("evidence"), + "tags": meta.get("tags"), + "preview": preview, + } + ) + return {"success": True, "episodes": items, "count": len(items)} + + +def get_episode(path: str) -> Dict[str, Any]: + if not path or not str(path).strip(): + return {"success": False, "error": "path is required for get."} + p = Path(path).expanduser() + if not p.is_absolute(): + # Allow bare filename under episodes dir + p = get_episodes_dir() / p + try: + episodes_dir = get_episodes_dir().resolve() + resolved = p.resolve() + resolved.relative_to(episodes_dir) + except (OSError, ValueError): + return { + "success": False, + "error": "path must stay under the episode directory.", + } + if resolved.suffix.lower() != ".md": + return {"success": False, "error": "episode path must end in .md."} + if not resolved.exists() or not resolved.is_file(): + return {"success": False, "error": f"Episode not found: {resolved}"} + try: + text = resolved.read_text(encoding="utf-8") + except OSError as exc: + return {"success": False, "error": f"Failed to read episode: {exc}"} + meta, body = _parse_frontmatter(text) + return { + "success": True, + "path": str(resolved), + "meta": meta, + "content": body.strip(), + } + + +# --------------------------------------------------------------------------- +# Tool entry point +# --------------------------------------------------------------------------- + + +def episode_tool( + action: str = "recall", + content: Optional[str] = None, + evidence: Optional[str] = None, + source: Optional[str] = None, + tags: Optional[Any] = None, + query: Optional[str] = None, + k: int = 5, + path: Optional[str] = None, + limit: int = 20, +) -> str: + """Dispatch episode tool actions. Always returns a JSON string.""" + if not _episodes_enabled(): + return tool_error( + "Episode memory is disabled (memory.episodes_enabled: false).", + success=False, + ) + + action = (action or "").strip().lower() + tag_list: Optional[List[str]] = None + if isinstance(tags, str): + tag_list = [t.strip() for t in tags.split(",") if t.strip()] + elif isinstance(tags, list): + tag_list = [str(t).strip() for t in tags if str(t).strip()] + + if action == "remember": + result = remember_episode( + content=content or "", + evidence=evidence or "", + source=source or "agent", + tags=tag_list, + ) + elif action == "recall": + result = recall_episodes(query=query or "", k=k, source=source) + elif action == "list": + result = list_episodes(limit=limit) + elif action == "get": + result = get_episode(path or "") + elif action == "reindex": + result = rebuild_episode_index() + else: + result = { + "success": False, + "error": f"Unknown action '{action}'. Use: remember, recall, list, get, reindex.", + } + return json.dumps(result, ensure_ascii=False) + + +def check_episode_requirements() -> bool: + return True + + +EPISODE_SCHEMA = { + "name": "episode", + "description": ( + "Evidence-gated episode memory for durable lessons. Episodes are stored under " + f"{display_hermes_home()}/memories/episodes/ and are NOT injected into the system " + "prompt (unlike the memory tool's MEMORY.md / USER.md). Use this when a lesson " + "should survive for months without burning the always-on memory budget.\n\n" + "ACTIONS:\n" + "- remember: write a lesson. Requires content + evidence (file path, commit SHA, " + "PR/issue URL, or log path). Optional source and tags.\n" + "- recall: FTS search over episodes (and configured HERMES_HOME markdown roots). " + "Pass query, optional k (default 5), optional source filter " + "(episodes|memories|skills).\n" + "- list: recent episodes.\n" + "- get: read one episode by path or filename.\n" + "- reindex: rebuild the local FTS index.\n\n" + "WHEN: verified checkpoints only (after a fix is proven, a review is accepted, " + "or the user asks to remember a durable lesson). The one-month test: will this " + "still be true and useful in a month?\n\n" + "SKIP: status/queue/phase snapshots, unverified claims, secrets, chat replay " + "(use session_search), standing preferences (use memory), procedures (use skills).\n\n" + "Does not require Honcho or any external memory provider." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["remember", "recall", "list", "get", "reindex"], + "description": "Episode action to perform.", + }, + "content": { + "type": "string", + "description": "Lesson text for action=remember.", + }, + "evidence": { + "type": "string", + "description": ( + "REQUIRED for remember: file path, commit SHA, PR/issue URL, or log path " + "that proves the lesson." + ), + }, + "source": { + "type": "string", + "description": ( + "For remember: who wrote it (e.g. agent, user, review). " + "For recall: optional source filter (episodes, memories, skills)." + ), + }, + "tags": { + "description": "Optional tags for remember (array of strings, or comma-separated).", + }, + "query": { + "type": "string", + "description": "Search query for action=recall.", + }, + "k": { + "type": "integer", + "description": "Max recall hits (default 5, max 20).", + }, + "path": { + "type": "string", + "description": "Episode path or filename for action=get.", + }, + "limit": { + "type": "integer", + "description": "Max items for action=list (default 20).", + }, + }, + "required": ["action"], + }, +} + + +registry.register( + name="episode", + toolset="memory", + schema=EPISODE_SCHEMA, + handler=lambda args, **kw: episode_tool( + action=args.get("action", "recall"), + content=args.get("content"), + evidence=args.get("evidence"), + source=args.get("source"), + tags=args.get("tags"), + query=args.get("query"), + k=args.get("k", 5) or 5, + path=args.get("path"), + limit=args.get("limit", 20) or 20, + ), + check_fn=check_episode_requirements, + emoji="📎", +) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 899da791474e6..7fd52bf60a5dd 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -1171,7 +1171,10 @@ def apply_memory_pending(payload: Dict[str, Any], store: "MemoryStore") -> Dict[ "notes (environment, conventions, tool quirks, lessons).\n\n" "SKIP: trivial/obvious info, easily re-discovered facts, raw data dumps, task progress, " "completed-work logs, temporary TODO state (use session_search for those). Reusable " - "procedures belong in a skill, not memory." + "procedures belong in a skill, not memory. Durable lessons with proof belong in the " + "episode tool (evidence-gated, not system-prompt injected) rather than stuffing " + "MEMORY.md. Prefer facts that will still be true in a month; do not write phase/queue " + "status snapshots here." ), "parameters": { "type": "object", diff --git a/toolsets.py b/toolsets.py index 75219f719cf75..aa337edd0d62b 100644 --- a/toolsets.py +++ b/toolsets.py @@ -51,7 +51,7 @@ # Text-to-speech "text_to_speech", # Planning & memory - "todo", "memory", + "todo", "memory", "episode", # NOTE: the desktop Project tools (project_list/create/switch) are # deliberately NOT here. They only make sense where a GUI can follow the # move, so they live in the `project` toolset and are enabled solely by the @@ -210,8 +210,8 @@ }, "memory": { - "description": "Persistent memory across sessions (personal notes + user profile)", - "tools": ["memory"], + "description": "Persistent memory across sessions (personal notes, user profile, evidence-gated episodes)", + "tools": ["memory", "episode"], "includes": [] }, @@ -359,7 +359,7 @@ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", - "todo", "memory", + "todo", "memory", "episode", "session_search", "clarify", "execute_code", "delegate_task", ], @@ -391,7 +391,7 @@ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", - "todo", "memory", + "todo", "memory", "episode", "session_search", "execute_code", "delegate_task", ], @@ -417,7 +417,7 @@ "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", # Planning & memory - "todo", "memory", + "todo", "memory", "episode", # Session history search "session_search", # Code execution + delegation diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 3cfc20fe5c781..43e309d56d5e4 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -193,18 +193,35 @@ hermes sessions list # Browse past sessions See [Session Search Tool](/user-guide/sessions#session-search-tool) for the three calling shapes (discovery / scroll / browse) and the response format. -### session_search vs memory +### session_search vs memory vs episodes + +| Feature | Persistent Memory | Episodes | Session Search | +|---------|------------------|----------|----------------| +| **Capacity** | ~1,300 tokens total | Unlimited (on disk) | Unlimited (all sessions) | +| **Speed** | Instant (in system prompt) | ~ms FTS5 query | ~20ms FTS5 query, ~1ms scroll | +| **Cost** | Token cost in every prompt | Free until recalled | Free — no LLM calls | +| **Use case** | Key facts always available | Durable lessons with proof | Finding specific past conversations | +| **Write gate** | Optional user approval | **Evidence required** | Automatic — all sessions stored | +| **Management** | Manually curated by agent | Checkpoint-only lessons | Automatic | +| **Token cost** | Fixed per session (~1,300 tokens) | On-demand | On-demand | + +**Memory** is for critical facts that should always be in context. **Episodes** are for lessons that should survive for months without burning that budget — every write needs evidence (path, commit SHA, PR/issue URL, or log path). **Session search** is for "did we discuss X last week?" queries. + +### Episode tool + +```text +episode(action="remember", content="...", evidence="path|sha|url", source="agent", tags=["fix"]) +episode(action="recall", query="gateway reconnect flake", k=5) +episode(action="list") +episode(action="get", path="2026-07-21-gateway-reconnect.md") +episode(action="reindex") +``` + +Episodes live in `~/.hermes/memories/episodes/` as markdown files with frontmatter. They are **not** injected into the system prompt. Recall uses a local SQLite FTS5 index (stdlib only) over episodes and optional HERMES_HOME roots (`memories` by default; add `skills` via config). -| Feature | Persistent Memory | Session Search | -|---------|------------------|----------------| -| **Capacity** | ~1,300 tokens total | Unlimited (all sessions) | -| **Speed** | Instant (in system prompt) | ~20ms FTS5 query, ~1ms scroll | -| **Cost** | Token cost in every prompt | Free — no LLM calls | -| **Use case** | Key facts always available | Finding specific past conversations | -| **Management** | Manually curated by agent | Automatic — all sessions stored | -| **Token cost** | Fixed per session (~1,300 tokens) | On-demand (searched when needed) | +Write only at verified checkpoints. Prefer lessons that will still be true in a month. Do not store phase/queue status, secrets, or unverified claims. -**Memory** is for critical facts that should always be in context. **Session search** is for "did we discuss X last week?" queries where the agent needs to recall specifics from past conversations. +If the local SQLite build has no FTS5 support, `remember` still saves the durable markdown file and reports search as degraded. `list` and `get` remain available. ## Learning Journey (`/journey`) @@ -234,6 +251,11 @@ memory: memory_char_limit: 2200 # ~800 tokens user_char_limit: 1375 # ~500 tokens write_approval: false # false = write freely (default) | true = require approval + episodes_enabled: true # evidence-gated episode log (default on) + episode_corpus_roots: # FTS roots under HERMES_HOME for episode recall + - episodes + - memories + # - skills # optional: index installed skill markdown too ``` ## Controlling memory writes (`write_approval`) From 1a5700c52f9764311b1a6d25a4bcb989a98f4881 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Thu, 30 Jul 2026 14:34:52 +1000 Subject: [PATCH 2/2] chore: retrigger CI Signed-off-by: joelbrilliant