diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1dd96c79..8e043e61 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,7 +4,7 @@ on: push: branches: [develop, main] pull_request: - branches: [develop] + branches: [develop, main] schedule: # Run daily at 06:00 UTC - cron: "0 6 * * *" diff --git a/benchmarks/longmemeval/modal_fast50.py b/benchmarks/longmemeval/modal_fast50.py index 26230a1c..47f4d2b6 100644 --- a/benchmarks/longmemeval/modal_fast50.py +++ b/benchmarks/longmemeval/modal_fast50.py @@ -109,11 +109,12 @@ def run_shard(spec: dict) -> dict: variant += f"_mmr{mmr_lambda}" if fusion: variant += f"_fusion{fusion_w}" - # Dataset fingerprint: sha1 of the mounted dataset file. Cross-dataset + # Dataset fingerprint: sha256 of the mounted dataset file (non-security + # fingerprinting, but avoids scanner flags). Cross-dataset # stale hits are possible without it (fast50 shard satisfies a 15Q run's # `len(prior) >= expected` check and is returned verbatim) — see #1129. data_bytes = pathlib.Path("/root/harness/data.json").read_bytes() - data_tag = hashlib.sha1(data_bytes).hexdigest()[:8] + data_tag = hashlib.sha256(data_bytes).hexdigest()[:8] vol_path = pathlib.Path("/root/vol") / variant / data_tag / f"shard_{shard_idx:02d}.jsonl" data = _json.loads(data_bytes) if limit and limit > 0: diff --git a/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl new file mode 100644 index 00000000..2ed1e7ba --- /dev/null +++ b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl @@ -0,0 +1,364 @@ +"""Uteke memory plugin — pre_llm_call auto-recall hook. + +Offline-first local memory backed by the `uteke` CLI (single Rust binary) or +`uteke-serve` HTTP daemon. Every turn, before the LLM call, this plugin +recalls relevant memories via semantic search and injects them into the +user message. + +This replaces the old Mode B (MemoryProvider, removed 2026-06-29) and the +Mode C (shell hook). Both are now unified into a single Python plugin. + +Transport: + - subprocess (default): talks to the `uteke` binary directly. + Requires the binary on PATH or set UTEKE_BIN. + - HTTP (optional): talks to `uteke-serve` via HTTP. Set UTEKE_SERVER_URL + to enable. Useful when the binary is unavailable, hangs, or runs as a + container (e.g., http://uteke:8767). + +Config via $HERMES_HOME/uteke.json (preferred) or environment variables: + UTEKE_BIN — path to the uteke binary (default: search PATH) + UTEKE_HOME — HOME dir uteke runs under (holds ~/.codecora/uteke store) + UTEKE_NAMESPACE — memory namespace (default: agent profile name) + UTEKE_SERVER_URL — uteke-serve HTTP URL (default: http://127.0.0.1:8767) + UTEKE_TOKEN — auth token for uteke-serve (Bearer token) + UTEKE_RECALL_LIMIT — number of memories to prefetch (default: 5) + UTEKE_RECALL_MIN_SCORE — drop recall hits below this score (default: 0.40) + UTEKE_RECALL_TIMEOUT — max seconds for recall per turn (default: 15) +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import threading +import time +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Circuit breaker: after this many consecutive failures, pause calls +# for _BREAKER_COOLDOWN_SECS to avoid hammering a broken binary/endpoint. +_BREAKER_THRESHOLD = 5 +_BREAKER_COOLDOWN_SECS = 120 + +# Subprocess timeout (seconds) +_DEFAULT_RECALL_TIMEOUT = 15 + +# HTTP timeout (seconds) +_HTTP_RECALL_TIMEOUT = 10 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def _load_config() -> dict: + """Load config from env vars, overridden by $HERMES_HOME/uteke.json.""" + try: + from hermes_constants import get_hermes_home + except ImportError: + get_hermes_home = lambda: None # type: ignore[assignment] + + hermes_home = get_hermes_home() if get_hermes_home else os.environ.get("HERMES_HOME", "") + + def _envbool(key: str, default: bool) -> bool: + v = os.environ.get(key) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + def _envfloat(key: str, default: float) -> float: + v = os.environ.get(key) + if v is None: + return default + try: + return float(v) + except ValueError: + return default + + def _envint(key: str, default: int) -> int: + v = os.environ.get(key) + if v is None: + return default + try: + return int(v) + except ValueError: + return default + + config = { + "bin": os.environ.get("UTEKE_BIN", ""), + "uteke_home": os.environ.get("UTEKE_HOME", ""), + "namespace": os.environ.get("UTEKE_NAMESPACE", ""), + "server_url": os.environ.get("UTEKE_SERVER_URL", ""), + "token": os.environ.get("UTEKE_TOKEN", ""), + "recall_limit": _envint("UTEKE_RECALL_LIMIT", 5), + "recall_min_score": _envfloat("UTEKE_RECALL_MIN_SCORE", 0.40), + "recall_timeout": _envint("UTEKE_RECALL_TIMEOUT", _DEFAULT_RECALL_TIMEOUT), + } + + if hermes_home: + config_path = os.path.join(hermes_home, "uteke.json") + if os.path.exists(config_path): + try: + with open(config_path, encoding="utf-8") as f: + file_cfg = json.loads(f.read()) + config.update({k: v for k, v in file_cfg.items() + if v is not None and v != ""}) + except Exception: + pass + + return config + + +def _resolve_agent_name() -> str: + """Determine the calling agent's name from environment. + + Tries HERMES_HOME path basename (profile name), falls back to + HERMES_AGENT_NAME, then "default". + """ + try: + home = os.environ.get("HERMES_HOME", "") + if home: + basename = os.path.basename(home) + if basename and basename != "data": + return basename.lower() + return os.environ.get("HERMES_AGENT_NAME", "default") + except Exception: + return "default" + + +def _resolve_bin(config: dict) -> str: + """Find the uteke binary.""" + b = config.get("bin") or "" + if b and os.path.exists(b): + return b + found = shutil.which("uteke") + return found or "" + + +# --------------------------------------------------------------------------- +# HTTP transport +# --------------------------------------------------------------------------- + +def _http_request(url: str, method: str = "GET", data: dict = None, + token: str = "", timeout: int = 10) -> dict: + """Make an HTTP request to uteke-serve. Returns parsed JSON or error dict.""" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Content-Type", "application/json") + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": e.read().decode()[:200], "status": e.code} + except urllib.error.URLError as e: + return {"error": f"uteke-serve not reachable: {e.reason}"} + + +# --------------------------------------------------------------------------- +# Recall (dual transport) +# --------------------------------------------------------------------------- + +def _recall_subprocess(bin_path: str, env: dict, query: str, + namespace: str, limit: int, timeout: int) -> List[Dict[str, Any]]: + """Recall via subprocess (uteke binary).""" + cmd = [bin_path, "recall", query, "--namespace", namespace, + "--limit", str(limit), "--json"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env) + if proc.returncode != 0: + raise RuntimeError(f"recall exit {proc.returncode}: {proc.stderr.strip()[:200]}") + return _parse_recall_results(json.loads(proc.stdout or "[]")) + + +def _recall_http(server_url: str, token: str, query: str, + namespace: str, limit: int) -> List[Dict[str, Any]]: + """Recall via HTTP (uteke-serve).""" + result = _http_request( + f"{server_url}/recall", + method="POST", + data={"query": query, "namespace": namespace, "limit": limit}, + token=token, + timeout=_HTTP_RECALL_TIMEOUT, + ) + if "error" in result: + raise RuntimeError(f"recall HTTP error: {result['error']}") + if not isinstance(result, list): + return [] + return _parse_recall_results(result) + + +def _parse_recall_results(data: list) -> List[Dict[str, Any]]: + """Parse recall results from both subprocess JSON and HTTP API.""" + out = [] + for item in data: + if not isinstance(item, dict): + continue + mem = item.get("memory", {}) + score = item.get("score", 0.0) + content = mem.get("content", "") + if content and score >= 0: + out.append({"content": content, "score": score, "tags": mem.get("tags", [])}) + return out + + +# --------------------------------------------------------------------------- +# Recall manager (thread-safe, circuit breaker) +# --------------------------------------------------------------------------- + +class _RecallManager: + """Manages recall state with circuit breaker and thread safety.""" + + def __init__(self): + self._config: dict = {} + self._bin = "" + self._env = dict(os.environ) + self._namespace = "default" + self._server_url = "" + self._token = "" + self._use_http = False + self._recall_limit = 5 + self._recall_min_score = 0.40 + self._recall_timeout = _DEFAULT_RECALL_TIMEOUT + + self._consecutive_failures = 0 + self._breaker_open_until = 0.0 + self._lock = threading.Lock() + + def initialize(self) -> None: + """Load config and resolve transport. Called once at plugin load.""" + cfg = _load_config() + self._config = cfg + self._bin = _resolve_bin(cfg) + self._namespace = cfg.get("namespace", "") or _resolve_agent_name() + self._server_url = cfg.get("server_url", "").rstrip("/") + self._token = cfg.get("token", "") + self._use_http = bool(self._server_url) + self._recall_limit = int(cfg.get("recall_limit", 5) or 5) + self._recall_min_score = float(cfg.get("recall_min_score", 0.40) or 0.40) + self._recall_timeout = int(cfg.get("recall_timeout", _DEFAULT_RECALL_TIMEOUT) or _DEFAULT_RECALL_TIMEOUT) + + # uteke stores under $HOME/.uteke; let the user point it at a fixed home. + self._env = dict(os.environ) + uteke_home = cfg.get("uteke_home") + if uteke_home: + self._env["HOME"] = uteke_home + + transport = "HTTP" if self._use_http else "subprocess" + logger.info( + "uteke-memory plugin initialized: transport=%s bin=%s ns=%s server=%s limit=%d min_score=%.2f", + transport, self._bin or "(PATH)", self._namespace, self._server_url or "(none)", + self._recall_limit, self._recall_min_score, + ) + + def _is_breaker_open(self) -> bool: + if self._consecutive_failures < _BREAKER_THRESHOLD: + return False + if time.monotonic() >= self._breaker_open_until: + with self._lock: + self._consecutive_failures = 0 + return False + return True + + def _record_success(self): + with self._lock: + self._consecutive_failures = 0 + + def _record_failure(self): + with self._lock: + self._consecutive_failures += 1 + if self._consecutive_failures >= _BREAKER_THRESHOLD: + self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS + logger.warning("uteke circuit breaker tripped after %d failures; pausing %ds.", + self._consecutive_failures, _BREAKER_COOLDOWN_SECS) + + def recall(self, query: str) -> List[Dict[str, Any]]: + """Recall memories, applying circuit breaker and score filtering.""" + if self._is_breaker_open(): + return [] + + try: + if self._use_http: + hits = _recall_http(self._server_url, self._token, query, + self._namespace, self._recall_limit) + else: + if not self._bin: + logger.warning("uteke binary not found on PATH and UTEKE_BIN not set.") + return [] + hits = _recall_subprocess(self._bin, self._env, query, + self._namespace, self._recall_limit, + self._recall_timeout) + + # Apply min_score filtering + hits = [h for h in hits if h.get("score", 0) >= self._recall_min_score] + self._record_success() + return hits + except Exception as e: + self._record_failure() + logger.debug("uteke recall failed: %s", e) + return [] + + +# Process-wide singleton +_manager = _RecallManager() + + +def _pre_llm_call( + session_id: str = "", + user_message: str = "", + conversation_history: list = None, + is_first_turn: bool = False, + model: str = "", + platform: str = "", + **kwargs, +) -> dict: + """pre_llm_call plugin hook — auto-recall relevant memories. + + Runs in-process (same Python runtime as the gateway). Has full access + to contextvars. Returns {"context": "..."} to inject into the user + message before the LLM call. + + This runs synchronously in the turn prologue (turn_context.py). Keep it + fast — the recall is bounded by UTEKE_RECALL_TIMEOUT (default 15s). + """ + if not user_message or not isinstance(user_message, str): + return {} + if len(user_message.strip()) < 5: + return {} + + # Truncate long messages to save embedding time + query = user_message.strip()[:500] + + hits = _manager.recall(query) + if not hits: + return {} + + lines = [] + for i, mem in enumerate(hits, 1): + content = mem["content"] + if len(content) > 200: + content = content[:200] + "..." + score_str = f"{mem['score']:.2f}" + tags = mem.get("tags", []) + tag_str = f" [{', '.join(tags)}]" if tags else "" + lines.append(f"{i}. [{score_str}]{tag_str} {content}") + + return {"context": f"\n" + "\n".join(lines) + "\n"} + + +def register(ctx) -> None: + """Register the uteke auto-recall plugin. + + 1. Initialize the recall manager (load config, resolve transport). + 2. Register a pre_llm_call hook that runs uteke recall before every + LLM call and injects relevant memories into the user message. + """ + _manager.initialize() + ctx.register_hook("pre_llm_call", _pre_llm_call) + logger.info("uteke-memory plugin registered: pre_llm_call hook active.") diff --git a/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl new file mode 100644 index 00000000..7018c925 --- /dev/null +++ b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl @@ -0,0 +1,8 @@ +name: uteke-memory +version: 2.0.0 +description: "Uteke — offline-first local memory with auto-recall. Registers a pre_llm_call plugin hook for automatic memory injection every turn. Requires uteke binary on PATH or UTEKE_BIN env var." +author: CodeCoraDev +pip_dependencies: [] +requires_env: [] +provides_hooks: + - pre_llm_call