"""Hindsight-embed daemon lifecycle management. Port of: HindsightEmbedManager in embed-manager.js, adapted for Python subprocess calls from ephemeral hook processes. Manages three connection modes (same as Openclaw): 1. External API — user provides hindsightApiUrl (skip daemon entirely) 2. Existing local server — user already has hindsight running 3. Auto-managed daemon — plugin starts/stops hindsight-embed In Claude Code's ephemeral model, daemon state is tracked via files in $CLAUDE_PLUGIN_DATA/state/. The daemon itself is a background OS process managed by hindsight-embed's built-in daemon command. """ import os import platform import subprocess import time import urllib.error import urllib.request from .llm import detect_llm_config, get_llm_env_vars from .state import read_state, write_state DAEMON_STATE_FILE = "daemon.json" PROFILE_NAME = "claude-code" def _get_embed_command(config: dict) -> list: """Get the command to run hindsight-embed. Port of: getEmbedCommand() in embed-manager.js """ embed_path = config.get("embedPackagePath") if embed_path: return ["uv", "run", "--directory", embed_path, "hindsight-embed"] version = config.get("embedVersion", "latest") package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest" return ["uvx", package] def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess: """Run a hindsight-embed command and return the result.""" cmd = _get_embed_command(config) + args run_env = dict(os.environ) if env: run_env.update(env) return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, env=run_env, ) def _is_embed_available(config: dict) -> bool: """Quick check if hindsight-embed is available on PATH. Avoids the slow uvx download path when the tool isn't installed. """ import shutil embed_path = config.get("embedPackagePath") if embed_path: return os.path.isdir(embed_path) # Check for uvx or hindsight-embed on PATH return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None def _check_health(base_url: str, timeout: int = 2) -> bool: """Quick health check against a Hindsight server.""" try: url = f"{base_url.rstrip('/')}/health" req = urllib.request.Request(url, method="GET") with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status == 200 except Exception: return False def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str: """Determine the API URL, optionally starting daemon if needed. Returns the API URL to use for recall/retain, handling all three modes. Connection mode priority: 1. External API (hindsightApiUrl configured) 2. Existing local server (check port health) 3. Auto-managed daemon (only if allow_daemon_start=True) The allow_daemon_start flag prevents the recall hook (10s timeout) from blocking on a 30s daemon startup. Only the retain hook (async, 15s) should attempt daemon start. """ # Mode 1: External API external_url = config.get("hindsightApiUrl") if external_url: if debug_fn: debug_fn(f"Using external API: {external_url}") return external_url # Mode 2 & 3: Local server port = config.get("apiPort", 9077) base_url = f"http://127.0.0.1:{port}" # Check if something is already running on this port if _check_health(base_url): if debug_fn: debug_fn(f"Existing server healthy on port {port}") return base_url # Mode 3: Auto-start daemon (only when allowed) if not allow_daemon_start: raise RuntimeError( f"No Hindsight server on port {port}. Set hindsightApiUrl for external " f"API, start hindsight-embed manually, or wait for the retain hook to " f"auto-start the daemon." ) if debug_fn: debug_fn(f"No server on port {port}, attempting daemon start") try: _ensure_daemon_running(config, port, debug_fn) except Exception as e: if debug_fn: debug_fn(f"Daemon start failed: {e}") raise RuntimeError( "No Hindsight server available. Set hindsightApiUrl for external API, " "or ensure hindsight-embed is installed for local daemon mode." ) from e return base_url def _ensure_daemon_running(config: dict, port: int, debug_fn=None): """Start the hindsight-embed daemon if not already running. Port of: HindsightEmbedManager.start() in embed-manager.js """ # Fast-fail if hindsight-embed toolchain is not available if not _is_embed_available(config): raise RuntimeError( "hindsight-embed not found (uvx not on PATH). " "Install with: pip install hindsight-embed, or set hindsightApiUrl." ) base_url = f"http://127.0.0.1:{port}" # Detect LLM config (needed for daemon's fact extraction) try: llm_config = detect_llm_config(config) except RuntimeError as e: raise RuntimeError(f"Cannot start daemon: {e}") from e llm_env = get_llm_env_vars(llm_config) # Build daemon environment daemon_env = dict(llm_env) idle_timeout = config.get("daemonIdleTimeout", 300) daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout) # On macOS, force CPU for embeddings/reranker (mirrors Openclaw) if platform.system() == "Darwin": daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1" daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1" # Step 1: Configure profile if debug_fn: debug_fn(f'Configuring "{PROFILE_NAME}" profile...') profile_args = [ "profile", "create", PROFILE_NAME, "--merge", "--port", str(port), ] for env_name, env_val in daemon_env.items(): if env_val: profile_args.extend(["--env", f"{env_name}={env_val}"]) try: result = _run_embed(config, profile_args, daemon_env, timeout=10) if result.returncode != 0: if debug_fn: debug_fn(f"Profile create stderr: {result.stderr.strip()}") raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}") if debug_fn: debug_fn("Profile configured") except subprocess.TimeoutExpired: raise RuntimeError("Profile create timed out") except FileNotFoundError: raise RuntimeError( "hindsight-embed not found. Install with: pip install hindsight-embed " "or set hindsightApiUrl for external API mode." ) # Step 2: Start daemon if debug_fn: debug_fn("Starting daemon...") try: result = _run_embed( config, ["daemon", "--profile", PROFILE_NAME, "start"], daemon_env, timeout=10, ) if debug_fn: debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}") if result.returncode != 0 and "already running" not in result.stderr.lower(): raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}") except subprocess.TimeoutExpired: raise RuntimeError("Daemon start timed out") # Step 3: Wait for ready (poll health endpoint) if debug_fn: debug_fn("Waiting for daemon to be ready...") for attempt in range(30): if _check_health(base_url): if debug_fn: debug_fn(f"Daemon ready after {attempt + 1} attempts") # Track that we started this daemon write_state( DAEMON_STATE_FILE, { "port": port, "started_by_plugin": True, "started_at": time.time(), "pid": os.getpid(), }, ) return time.sleep(1) raise RuntimeError("Daemon failed to become ready within 30 seconds") def stop_daemon(config: dict, debug_fn=None): """Stop the daemon if it was started by this plugin. Called during cleanup. Only stops if we started it (tracked in state). """ state = read_state(DAEMON_STATE_FILE) if not state or not state.get("started_by_plugin"): if debug_fn: debug_fn("Daemon not started by plugin, skipping stop") return if debug_fn: debug_fn("Stopping daemon...") try: result = _run_embed( config, ["daemon", "--profile", PROFILE_NAME, "stop"], timeout=10, ) if debug_fn: debug_fn(f"Daemon stop: {result.stdout.strip()}") except Exception as e: if debug_fn: debug_fn(f"Daemon stop error: {e}") # Clear state write_state(DAEMON_STATE_FILE, {})