"""Retrieval Order Enforcer — L0 through L5 memory hierarchy. Ensures the agent checks durable memory before falling back to free generation. Gracefully degrades if any layer is unavailable (missing files, etc). Layer order: L0: Identity (~/.mempalace/identity.txt) L1: Palace rooms (SovereignStore — SQLite + FTS5 + HRR, zero API calls) L2: Session scratch (~/.hermes/scratchpad/{session_id}.json) L3: Gitea artifacts (API search for issues/PRs) L4: Procedures (skills directory search) L5: Free generation (only if L0-L4 produced nothing) Refs: Epic #367, Sub-issue #369, Wiring: #383 """ from __future__ import annotations import json import os import re from pathlib import Path from typing import Optional # --------------------------------------------------------------------------- # Sovereign Store (replaces mempalace CLI subprocess) # --------------------------------------------------------------------------- try: from .sovereign_store import SovereignStore except ImportError: try: from sovereign_store import SovereignStore except ImportError: SovereignStore = None # type: ignore[misc,assignment] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- IDENTITY_PATH = Path.home() / ".mempalace" / "identity.txt" SCRATCHPAD_DIR = Path.home() / ".hermes" / "scratchpad" SKILLS_DIR = Path.home() / ".hermes" / "skills" SOVEREIGN_DB = Path.home() / ".hermes" / "palace" / "sovereign.db" # Patterns that indicate a recall-style query RECALL_PATTERNS = re.compile( r"(?i)\b(" r"what did|status of|remember|last time|yesterday|previously|" r"we discussed|we talked|we worked|you said|you mentioned|" r"remind me|what was|what were|how did|when did|" r"earlier today|last session|before this" r")\b" ) # Singleton store instance (lazy-init) _store: Optional["SovereignStore"] = None def _get_store() -> Optional["SovereignStore"]: """Lazy-init the SovereignStore singleton.""" global _store if _store is not None: return _store if SovereignStore is None: return None try: _store = SovereignStore(db_path=str(SOVEREIGN_DB)) return _store except Exception: return None # --------------------------------------------------------------------------- # L0: Identity # --------------------------------------------------------------------------- def load_identity() -> str: """Read the agent identity file. Returns empty string on failure.""" try: if IDENTITY_PATH.exists(): text = IDENTITY_PATH.read_text(encoding="utf-8").strip() # Cap at ~200 tokens to keep wake-up lean if len(text.split()) > 200: text = " ".join(text.split()[:200]) + "..." return text except (OSError, PermissionError): pass return "" # --------------------------------------------------------------------------- # L1: Palace search (now via SovereignStore — zero subprocess, zero API) # --------------------------------------------------------------------------- def search_palace(query: str, room: Optional[str] = None) -> str: """Search the sovereign memory store for relevant memories. Uses SovereignStore (SQLite + FTS5 + HRR) for hybrid keyword + semantic search. No subprocess calls, no ONNX, no API keys. Gracefully degrades to empty string if store is unavailable. """ store = _get_store() if store is None: return "" try: results = store.search(query, room=room, limit=5, min_trust=0.2) if not results: return "" lines = [] for r in results: trust = r.get("trust_score", 0.5) room_name = r.get("room", "general") content = r.get("content", "") lines.append(f" [{room_name}] (trust:{trust:.2f}) {content}") return "\n".join(lines) except Exception: return "" # --------------------------------------------------------------------------- # L2: Session scratchpad # --------------------------------------------------------------------------- def load_scratchpad(session_id: str) -> str: """Load the session scratchpad as formatted text.""" try: scratch_file = SCRATCHPAD_DIR / f"{session_id}.json" if scratch_file.exists(): data = json.loads(scratch_file.read_text(encoding="utf-8")) if isinstance(data, dict) and data: lines = [] for k, v in data.items(): lines.append(f" {k}: {v}") return "\n".join(lines) except (OSError, json.JSONDecodeError): pass return "" # --------------------------------------------------------------------------- # L3: Gitea artifact search # --------------------------------------------------------------------------- def _load_gitea_token() -> str: """Read the Gitea API token.""" token_path = Path.home() / ".hermes" / "gitea_token_vps" try: if token_path.exists(): return token_path.read_text(encoding="utf-8").strip() except OSError: pass return "" def search_gitea(query: str) -> str: """Search Gitea issues/PRs for context. Returns formatted text or empty string.""" token = _load_gitea_token() if not token: return "" api_base = "https://forge.alexanderwhitestone.com/api/v1" # Extract key terms for search (first 3 significant words) terms = [w for w in query.split() if len(w) > 3][:3] search_q = " ".join(terms) if terms else query[:50] try: import urllib.request import urllib.parse url = ( f"{api_base}/repos/search?" f"q={urllib.parse.quote(search_q)}&limit=3" ) req = urllib.request.Request(url, headers={ "Authorization": f"token {token}", "Accept": "application/json", }) with urllib.request.urlopen(req, timeout=8) as resp: data = json.loads(resp.read().decode()) if data.get("data"): lines = [] for repo in data["data"][:3]: lines.append(f" {repo['full_name']}: {repo.get('description', 'no desc')}") return "\n".join(lines) except Exception: pass return "" # --------------------------------------------------------------------------- # L4: Procedures (skills search) # --------------------------------------------------------------------------- def search_skills(query: str) -> str: """Search skills directory for matching procedures.""" try: if not SKILLS_DIR.exists(): return "" query_lower = query.lower() terms = [w for w in query_lower.split() if len(w) > 3] if not terms: return "" matches = [] for skill_dir in SKILLS_DIR.iterdir(): if not skill_dir.is_dir(): continue skill_md = skill_dir / "SKILL.md" if skill_md.exists(): try: content = skill_md.read_text(encoding="utf-8").lower() if any(t in content for t in terms): title = skill_dir.name matches.append(f" skill: {title}") except OSError: continue if matches: return "\n".join(matches[:5]) except OSError: pass return "" # --------------------------------------------------------------------------- # Main enforcer # --------------------------------------------------------------------------- def is_recall_query(query: str) -> bool: """Detect whether a query is asking for recalled/historical information.""" return bool(RECALL_PATTERNS.search(query)) def enforce_retrieval_order( query: str, session_id: Optional[str] = None, skip_if_not_recall: bool = True, ) -> dict: """Check palace layers before allowing free generation. Args: query: The user's query text. session_id: Current session ID for scratchpad access. skip_if_not_recall: If True (default), skip enforcement for non-recall queries and return empty result. Returns: dict with keys: retrieved_from: Highest layer that produced results (e.g. 'L1') context: Aggregated context string tokens: Approximate word count of context layers_checked: List of layers that were consulted """ result = { "retrieved_from": None, "context": "", "tokens": 0, "layers_checked": [], } # Gate: skip for non-recall queries if configured if skip_if_not_recall and not is_recall_query(query): return result # L0: Identity (always prepend) identity = load_identity() if identity: result["context"] += f"## Identity\n{identity}\n\n" result["layers_checked"].append("L0") # L1: Palace search (SovereignStore — zero API, zero subprocess) palace_results = search_palace(query) if palace_results: result["context"] += f"## Palace Memory\n{palace_results}\n\n" result["retrieved_from"] = "L1" result["layers_checked"].append("L1") # L2: Scratchpad if session_id: scratch = load_scratchpad(session_id) if scratch: result["context"] += f"## Session Notes\n{scratch}\n\n" if not result["retrieved_from"]: result["retrieved_from"] = "L2" result["layers_checked"].append("L2") # L3: Gitea artifacts (only if still no context from L1/L2) if not result["retrieved_from"]: artifacts = search_gitea(query) if artifacts: result["context"] += f"## Gitea Context\n{artifacts}\n\n" result["retrieved_from"] = "L3" result["layers_checked"].append("L3") # L4: Procedures (only if still no context) if not result["retrieved_from"]: procedures = search_skills(query) if procedures: result["context"] += f"## Related Skills\n{procedures}\n\n" result["retrieved_from"] = "L4" result["layers_checked"].append("L4") # L5: Free generation (no context found — just mark it) if not result["retrieved_from"]: result["retrieved_from"] = "L5" result["layers_checked"].append("L5") result["tokens"] = len(result["context"].split()) return result