""" Client for communicating with the Hindsight daemon. Handles daemon lifecycle (start if needed) and API requests via the Python client. """ import logging import os import shlex import subprocess import time from pathlib import Path import httpx # Used only for health check logger = logging.getLogger(__name__) DAEMON_PORT = 8889 DAEMON_URL = f"http://127.0.0.1:{DAEMON_PORT}" DAEMON_STARTUP_TIMEOUT = 180 # seconds - needs to be long for first run (downloads dependencies) DAEMON_IDLE_TIMEOUT = 300 # 5 minutes - auto-exit after idle # CLI paths - check multiple locations CLI_INSTALL_DIRS = [ Path.home() / ".local" / "bin", # Standard location from get-cli installer Path.home() / ".hindsight" / "bin", # Alternative location ] CLI_INSTALLER_URL = "https://hindsight.vectorize.io/get-cli" def _find_hindsight_api_command() -> list[str]: """Find the command to run hindsight-api.""" # Check if we're in development mode (local hindsight-api available) # Path: daemon_client.py -> hindsight_embed/ -> hindsight-embed/ -> memory-poc/ dev_api_path = Path(__file__).parent.parent.parent / "hindsight-api" if dev_api_path.exists() and (dev_api_path / "pyproject.toml").exists(): # Use uv run with the local project return ["uv", "run", "--project", str(dev_api_path), "hindsight-api"] # Fall back to uvx for installed version return ["uvx", "hindsight-api"] def _is_daemon_running() -> bool: """Check if daemon is running and responsive.""" try: with httpx.Client(timeout=2) as client: response = client.get(f"{DAEMON_URL}/health") return response.status_code == 200 except Exception: return False def _start_daemon(config: dict) -> bool: """ Start the daemon in background. Returns True if daemon started successfully. """ import sys logger.info("Starting daemon...") # Build environment with LLM config env = os.environ.copy() if config.get("llm_api_key"): env["HINDSIGHT_API_LLM_API_KEY"] = config["llm_api_key"] if config.get("llm_provider"): env["HINDSIGHT_API_LLM_PROVIDER"] = config["llm_provider"] if config.get("llm_model"): env["HINDSIGHT_API_LLM_MODEL"] = config["llm_model"] # Use pg0 database specific to bank bank_id = config.get("bank_id", "default") env["HINDSIGHT_API_DATABASE_URL"] = f"pg0://hindsight-embed-{bank_id}" env["HINDSIGHT_API_LOG_LEVEL"] = "info" cmd = _find_hindsight_api_command() + ["--daemon", "--idle-timeout", str(DAEMON_IDLE_TIMEOUT)] # Create log directory log_dir = Path.home() / ".hindsight" log_dir.mkdir(parents=True, exist_ok=True) daemon_log = log_dir / "daemon.log" print(f"Starting daemon with command: {' '.join(cmd)}", file=sys.stderr) print(f" Log file: {daemon_log}", file=sys.stderr) try: # Start daemon with shell redirection (works across forks) # The >> appends to log file, 2>&1 redirects stderr to stdout shell_cmd = f"{' '.join(cmd)} >> {shlex.quote(str(daemon_log))} 2>&1" subprocess.Popen( shell_cmd, shell=True, env=env, start_new_session=True, ) # Wait for daemon to be ready # Note: With --daemon flag, the parent process forks and exits immediately (code 0). # The child process (actual daemon) continues running. So we can't rely on process.poll() # to detect failures - we must use the health check. start_time = time.time() last_check_time = start_time while time.time() - start_time < DAEMON_STARTUP_TIMEOUT: if _is_daemon_running(): # Health check passed - but daemon might crash during initialization # Wait a moment and verify it's still healthy (stability check) print(" Daemon responding, verifying stability...", file=sys.stderr) time.sleep(2) if _is_daemon_running(): logger.info("Daemon started successfully") return True else: # Daemon crashed after initial health check print(" Daemon crashed during initialization", file=sys.stderr) break # Periodically log progress if time.time() - last_check_time > 5: elapsed = int(time.time() - start_time) print(f" Still waiting for daemon... ({elapsed}s elapsed)", file=sys.stderr) last_check_time = time.time() time.sleep(0.5) logger.error("Daemon failed to start") # Show logs on failure if daemon_log.exists(): log_content = daemon_log.read_text() if log_content: # Show last 3000 chars of log print(f"\n Daemon log ({daemon_log}):", file=sys.stderr) print(f"{log_content[-3000:]}", file=sys.stderr) return False except FileNotFoundError as e: print(f"Command not found: {cmd[0]}", file=sys.stderr) print(f" Full command: {' '.join(cmd)}", file=sys.stderr) logger.error("hindsight-api command not found. Install with: pip install hindsight-api") return False except Exception as e: print(f"Failed to start daemon: {e}", file=sys.stderr) logger.error(f"Failed to start daemon: {e}") return False def ensure_daemon_running(config: dict) -> bool: """ Ensure daemon is running, starting it if needed. Returns True if daemon is running. """ if _is_daemon_running(): logger.debug("Daemon already running") return True return _start_daemon(config) def stop_daemon() -> bool: """Stop the running daemon and wait for it to fully stop.""" # Try to kill by PID from lockfile lockfile = Path.home() / ".hindsight" / "daemon.lock" if lockfile.exists(): try: pid = int(lockfile.read_text().strip()) os.kill(pid, 15) # SIGTERM # Wait for process to exit for _ in range(50): time.sleep(0.1) try: os.kill(pid, 0) except OSError: break # Process exited except (ValueError, OSError): pass # Wait for health check to fail (daemon fully stopped) for _ in range(30): # Wait up to 3 seconds if not _is_daemon_running(): return True time.sleep(0.1) return not _is_daemon_running() def find_cli_binary() -> Path | None: """Find the hindsight CLI binary in known locations or PATH.""" import shutil # Check standard install locations for install_dir in CLI_INSTALL_DIRS: binary = install_dir / "hindsight" if binary.exists() and os.access(binary, os.X_OK): return binary # Check PATH path_binary = shutil.which("hindsight") if path_binary: return Path(path_binary) return None def is_cli_installed() -> bool: """Check if the hindsight CLI is installed.""" return find_cli_binary() is not None def install_cli() -> bool: """ Install the hindsight CLI using the official installer. Returns True if installation succeeded. """ import subprocess import sys print("Installing hindsight CLI...") print(f" Installer URL: {CLI_INSTALLER_URL}") try: # Download and run installer result = subprocess.run( ["bash", "-c", f"curl -fsSL {CLI_INSTALLER_URL} | bash"], capture_output=True, text=True, ) if result.returncode != 0: print(f"CLI installation failed (exit code {result.returncode}):", file=sys.stderr) if result.stdout: print(f" stdout: {result.stdout}", file=sys.stderr) if result.stderr: print(f" stderr: {result.stderr}", file=sys.stderr) return False cli_binary = find_cli_binary() if cli_binary: print(f"CLI installed to {cli_binary}") return True else: print("CLI installation completed but binary not found", file=sys.stderr) print(f" stdout: {result.stdout}", file=sys.stderr) print(f" stderr: {result.stderr}", file=sys.stderr) # Check known locations for install_dir in CLI_INSTALL_DIRS: binary = install_dir / "hindsight" print(f" Checking {binary}: exists={binary.exists()}", file=sys.stderr) return False except Exception as e: print(f"CLI installation failed: {e}", file=sys.stderr) return False def ensure_cli_installed() -> bool: """Ensure CLI is installed, installing if needed.""" if is_cli_installed(): return True return install_cli() def run_cli(args: list[str], config: dict) -> int: """ Run the hindsight CLI with the given arguments. Ensures daemon is running and passes the API URL. Args: args: CLI arguments (e.g., ["memory", "retain", "bank", "content"]) config: Configuration dict with llm settings Returns: Exit code from CLI """ import subprocess import sys # Ensure CLI is installed if not ensure_cli_installed(): return 1 cli_binary = find_cli_binary() if not cli_binary: print("Error: hindsight CLI not found", file=sys.stderr) return 1 # Ensure daemon is running if not ensure_daemon_running(config): print("Error: Failed to start daemon", file=sys.stderr) return 1 # Build environment with API URL pointing to daemon env = os.environ.copy() env["HINDSIGHT_API_URL"] = DAEMON_URL # Run CLI try: result = subprocess.run( [str(cli_binary)] + args, env=env, ) return result.returncode except Exception as e: print(f"Error running CLI: {e}", file=sys.stderr) return 1