fleet-memory/hindsight-embed/hindsight_embed/daemon_embed_manager.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

395 lines
16 KiB
Python

"""
Concrete implementation of EmbedManager using daemon-based architecture.
This module provides the production implementation of the embed management interface,
consolidating daemon lifecycle, profile management, and database URL resolution.
"""
import logging
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Optional
import httpx
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.text import Text
from .embed_manager import EmbedManager
from .profile_manager import ProfileManager, resolve_active_profile
logger = logging.getLogger(__name__)
console = Console(stderr=True)
# Suppress noisy httpx logs
logging.getLogger("httpx").setLevel(logging.WARNING)
# Constants
DAEMON_STARTUP_TIMEOUT = 180 # seconds
DEFAULT_DAEMON_IDLE_TIMEOUT = 300 # 5 minutes
class DaemonEmbedManager(EmbedManager):
"""Production embed manager using daemon-based architecture with profile isolation."""
def __init__(self):
"""Initialize the daemon embed manager."""
self._profile_manager = ProfileManager()
def _sanitize_profile_name(self, profile: str | None) -> str:
"""Sanitize profile name for use in database names and file paths."""
if profile is None:
return "default"
return re.sub(r"[^a-zA-Z0-9_-]", "-", profile)
def get_database_url(self, profile: str, db_url: Optional[str] = None) -> str:
"""
Get the database URL for this profile.
Args:
profile: Profile name
db_url: Optional override database URL
Returns:
Database connection string
"""
if db_url and db_url != "pg0":
return db_url
safe_profile = self._sanitize_profile_name(profile)
return f"pg0://hindsight-embed-{safe_profile}"
def get_url(self, profile: str) -> str:
"""
Get the URL for the daemon serving this profile.
Args:
profile: Profile name
Returns:
URL string (e.g., "http://127.0.0.1:54321")
Raises:
RuntimeError: If daemon is not running
"""
paths = self._profile_manager.resolve_profile_paths(profile)
return f"http://127.0.0.1:{paths.port}"
def is_running(self, profile: str) -> bool:
"""Check if daemon is running and responsive."""
daemon_url = self.get_url(profile)
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 _find_api_command(self) -> list[str]:
"""Find the command to run hindsight-api."""
# Check if we're in development mode
dev_api_path = Path(__file__).parent.parent.parent / "hindsight-api-slim"
if dev_api_path.exists() and (dev_api_path / "pyproject.toml").exists():
return ["uv", "run", "--project", str(dev_api_path), "hindsight-api"]
# Fall back to uvx for installed version
from . import __version__
api_version = os.getenv("HINDSIGHT_EMBED_API_VERSION", __version__)
return ["uvx", f"hindsight-api@{api_version}"]
def _start_daemon(self, config: dict, profile: str) -> bool:
"""Start the daemon in background."""
paths = self._profile_manager.resolve_profile_paths(profile)
profile_label = f"profile '{profile}'" if profile else "default profile"
daemon_log = paths.log
port = paths.port
# Load profile's .env file and merge with provided config
# This fixes issue #305 where profile env vars were ignored
profile_config = self._profile_manager.load_profile_config(profile)
# Merge: profile config first, then override with explicitly provided config
merged_config = {**profile_config, **config}
config = merged_config
# Build environment with LLM config
# Support both formats: simple keys ("llm_api_key") and env var format ("HINDSIGHT_API_LLM_API_KEY")
env = os.environ.copy()
# Map of simple key -> env var key
key_mapping = {
"llm_api_key": "HINDSIGHT_API_LLM_API_KEY",
"llm_provider": "HINDSIGHT_API_LLM_PROVIDER",
"llm_model": "HINDSIGHT_API_LLM_MODEL",
"llm_base_url": "HINDSIGHT_API_LLM_BASE_URL",
"log_level": "HINDSIGHT_API_LOG_LEVEL",
"idle_timeout": "HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT",
}
for simple_key, env_key in key_mapping.items():
# Check both simple format and env var format
value = config.get(simple_key) or config.get(env_key)
if value:
env[env_key] = str(value)
# Use profile-specific database (check config for override)
db_override = config.get("HINDSIGHT_EMBED_API_DATABASE_URL") or env.get("HINDSIGHT_EMBED_API_DATABASE_URL")
if db_override:
env["HINDSIGHT_API_DATABASE_URL"] = db_override
else:
env["HINDSIGHT_API_DATABASE_URL"] = self.get_database_url(profile)
database_url = env["HINDSIGHT_API_DATABASE_URL"]
is_pg0 = database_url.startswith("pg0://")
# Set defaults if not provided
if "HINDSIGHT_API_LOG_LEVEL" not in env:
env["HINDSIGHT_API_LOG_LEVEL"] = "info"
if "HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT" not in env:
env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(DEFAULT_DAEMON_IDLE_TIMEOUT)
# On macOS, force CPU for embeddings/reranker to avoid MPS issues
import platform
if platform.system() == "Darwin":
if "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU" not in env:
env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
if "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU" not in env:
env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
# Get idle timeout from env
idle_timeout = int(env.get("HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT", str(DEFAULT_DAEMON_IDLE_TIMEOUT)))
# Create log directory
daemon_log.parent.mkdir(parents=True, exist_ok=True)
env["HINDSIGHT_API_DAEMON_LOG"] = str(daemon_log)
# Build command
cmd = self._find_api_command() + [
"--daemon",
"--idle-timeout",
str(idle_timeout),
"--port",
str(port),
]
try:
# Start daemon
subprocess.Popen(
cmd,
env=env,
start_new_session=True,
)
# Wait for daemon to be ready with rich UI
start_time = time.time()
last_check_time = start_time
last_log_position = 0
log_lines = [f"Starting daemon for {profile_label}...", ""]
title = f"[bold cyan]Starting Daemon[/bold cyan] [dim]({profile} @ :{port})[/dim]"
with Live(console=console, auto_refresh=False) as live:
content = Text("\n".join(log_lines), style="dim")
panel = Panel(content, title=title, border_style="cyan", padding=(1, 2))
live.update(panel)
live.refresh()
while time.time() - start_time < DAEMON_STARTUP_TIMEOUT:
# Tail daemon logs
if daemon_log.exists():
try:
with open(daemon_log, "r") as f:
f.seek(last_log_position)
new_lines = f.readlines()
last_log_position = f.tell()
for line in new_lines:
line = line.rstrip()
if line:
log_lines.append(line)
log_lines = log_lines[-4:]
except Exception:
pass
if self.is_running(profile):
log_lines.append("")
log_lines.append("✓ Daemon responding, verifying stability...")
content = Text("\n".join(log_lines), style="dim")
panel = Panel(content, title=title, border_style="cyan", padding=(1, 2))
live.update(panel)
live.refresh()
time.sleep(2)
if self.is_running(profile):
log_lines.append("✓ Daemon started successfully!")
log_lines.append("")
log_lines.append(f"Logs: {daemon_log}")
if is_pg0:
pg0_name = database_url.replace("pg0://", "")
pg0_path = Path.home() / ".pg0" / "instances" / pg0_name
log_lines.append(f"Database: {pg0_path}")
content = Text("\n".join(log_lines), style="dim")
success_title = (
f"[bold green]✓ Daemon Started[/bold green] [dim]({profile} @ :{port})[/dim]"
)
panel = Panel(content, title=success_title, border_style="green", padding=(1, 2))
live.update(panel)
live.refresh()
console.print()
# Register profile in metadata so CLI can discover it
if profile:
self._register_profile(profile, port, config)
return True
else:
log_lines.append("")
log_lines.append("✗ Daemon crashed during initialization")
content = Text("\n".join(log_lines), style="dim")
fail_title = f"[bold red]✗ Daemon Failed[/bold red] [dim]({profile} @ :{port})[/dim]"
panel = Panel(content, title=fail_title, border_style="red", padding=(1, 2))
live.update(panel)
live.refresh()
console.print()
break
# Periodic progress
if time.time() - last_check_time > 3:
elapsed = int(time.time() - start_time)
status_msg = f"⏳ Waiting for daemon... ({elapsed}s elapsed)"
if log_lines and log_lines[-1].startswith(""):
log_lines[-1] = status_msg
else:
log_lines.append(status_msg)
last_check_time = time.time()
content = Text("\n".join(log_lines), style="dim")
panel = Panel(content, title=title, border_style="cyan", padding=(1, 2))
live.update(panel)
live.refresh()
time.sleep(0.5)
# Timeout
log_lines.append("")
log_lines.append("✗ Daemon failed to start (timeout)")
log_lines.append("")
log_lines.append(f"See full log: {daemon_log}")
content = Text("\n".join(log_lines), style="dim")
timeout_title = f"[bold red]✗ Daemon Failed (Timeout)[/bold red] [dim]({profile} @ :{port})[/dim]"
panel = Panel(content, title=timeout_title, border_style="red", padding=(1, 2))
console.print(panel)
console.print()
return False
except FileNotFoundError as e:
error_msg = (
f"Command not found: {cmd[0]}\nFull command: {' '.join(cmd)}\n\n"
"Install hindsight-api with: pip install hindsight-api"
)
error_panel = Panel(
Text(error_msg, style="red"),
title="[bold red]✗ Command Not Found[/bold red]",
border_style="red",
padding=(1, 2),
)
console.print(error_panel)
console.print()
return False
except Exception as e:
error_msg = f"Failed to start daemon: {e}\n\nCommand: {' '.join(cmd)}\nLog file: {daemon_log}"
error_panel = Panel(
Text(error_msg, style="red"),
title="[bold red]✗ Startup Error[/bold red]",
border_style="red",
padding=(1, 2),
)
console.print(error_panel)
console.print()
return False
def _register_profile(self, profile: str, port: int, config: dict) -> None:
"""Register a named profile in metadata so it's discoverable by the CLI.
Only saves HINDSIGHT_API_* config keys (not internal daemon keys).
Silently ignores errors to avoid blocking daemon startup.
"""
try:
api_config = {k: v for k, v in config.items() if k.startswith("HINDSIGHT_API_")}
self._profile_manager.create_profile(profile, port, api_config)
except Exception as e:
logger.debug(f"Failed to register profile '{profile}' in metadata: {e}")
def ensure_running(self, config: dict, profile: str) -> bool:
"""
Ensure daemon is running, starting it if needed.
Args:
config: Environment configuration dict (HINDSIGHT_API_* vars)
profile: Profile name for isolation
Returns:
True if daemon is running (started or already running), False on failure
"""
if self.is_running(profile):
logger.debug(f"Daemon already running for profile '{profile}'")
if profile:
paths = self._profile_manager.resolve_profile_paths(profile)
self._register_profile(profile, paths.port, config)
return True
return self._start_daemon(config, profile)
def stop(self, profile: str) -> bool:
"""
Stop the daemon for this profile.
Args:
profile: Profile name
Returns:
True if stopped successfully, False otherwise
"""
if not self.is_running(profile):
logger.debug(f"Daemon not running for profile '{profile}'")
return True
# Get port
paths = self._profile_manager.resolve_profile_paths(profile)
port = paths.port
# Find PID by port
try:
result = subprocess.run(
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
pid = int(result.stdout.strip().split()[0])
logger.debug(f"Found daemon PID {pid} on port {port}")
# Send SIGTERM
os.kill(pid, 15)
# Wait for process to exit
for _ in range(50):
time.sleep(0.1)
try:
os.kill(pid, 0)
except OSError:
break
else:
logger.warning(f"Could not find PID for port {port}")
except (subprocess.TimeoutExpired, ValueError, OSError, FileNotFoundError) as e:
logger.warning(f"Could not find/kill daemon by port: {e}")
# Wait for health check to fail
for _ in range(30):
if not self.is_running(profile):
return True
time.sleep(0.1)
return not self.is_running(profile)