fleet-memory/hindsight-embed/hindsight_embed/daemon_client.py
Nicolò Boschi e6511e7d77
feat: refactor hindsight-embed architecture (#66)
* feat: refactor hindsight-embed architecture

* feat: refactor hindsight-embed architecture

* refactor deamin

* refactor deamin

* refactor deamin

* refactor deamin
2025-12-22 22:02:40 +01:00

298 lines
9.4 KiB
Python

"""
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 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}"
# Optimization flags for faster startup
env["HINDSIGHT_API_SKIP_LLM_VERIFICATION"] = "true"
env["HINDSIGHT_API_LOG_LEVEL"] = "warning"
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"
daemon_stderr = log_dir / "daemon.stderr"
print(f"Starting daemon with command: {' '.join(cmd)}", file=sys.stderr)
print(f" Log file: {daemon_log}", file=sys.stderr)
try:
# Start daemon in background, but capture initial stderr for debugging
with open(daemon_stderr, "w") as stderr_file:
process = subprocess.Popen(
cmd,
env=env,
stdout=subprocess.DEVNULL,
stderr=stderr_file,
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():
logger.info("Daemon started successfully")
return True
# 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 within timeout")
# Show logs on timeout
if daemon_log.exists():
log_content = daemon_log.read_text()
if log_content:
print(f"Daemon log:\n{log_content[-2000:]}", file=sys.stderr) # Last 2000 chars
if daemon_stderr.exists():
stderr_content = daemon_stderr.read_text()
if stderr_content:
print(f"Daemon stderr:\n{stderr_content}", 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."""
# 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:
return True
except (ValueError, OSError):
pass
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