fleet-memory/hindsight-all/hindsight/server.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

280 lines
8.1 KiB
Python

"""
Server module for running Hindsight in a background thread.
Provides a simple way to start and stop the Hindsight HTTP API server
without blocking the main thread.
"""
import asyncio
import logging
import socket
import threading
import time
from typing import Optional
import uvicorn
from uvicorn import Config
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
logger = logging.getLogger(__name__)
def _find_free_port() -> int:
"""Find a free port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
return port
class Server:
"""
Hindsight server that runs in a background thread.
Example:
```python
from hindsight import Server
server = Server(
db_url="pg0",
llm_provider="groq",
llm_api_key="your-api-key",
llm_model="openai/gpt-oss-120b"
)
server.start()
print(f"Server running at {server.url}")
# Use the server...
server.stop()
```
"""
def __init__(
self,
db_url: str = "pg0",
llm_provider: str = "groq",
llm_api_key: str = "",
llm_model: str = "openai/gpt-oss-120b",
llm_base_url: Optional[str] = None,
host: str = "127.0.0.1",
port: Optional[int] = None,
mcp_enabled: bool = False,
log_level: str = "info",
):
"""
Initialize the Hindsight server.
Args:
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
host: Host to bind to (default: 127.0.0.1)
port: Port to bind to (default: auto-select free port)
mcp_enabled: Whether to enable MCP server
log_level: Uvicorn log level (default: warning)
"""
self.db_url = db_url
self.llm_provider = llm_provider
self.llm_api_key = llm_api_key
self.llm_model = llm_model
self.llm_base_url = llm_base_url
self.host = host
self.port = port or _find_free_port()
self.mcp_enabled = mcp_enabled
self.log_level = log_level
self._memory: Optional[MemoryEngine] = None
self._server: Optional[uvicorn.Server] = None
self._thread: Optional[threading.Thread] = None
self._started = threading.Event()
self._stopped = threading.Event()
@property
def url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"
def _run_server(self):
"""Run the server in a background thread."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Create MemoryEngine
self._memory = MemoryEngine(
db_url=self.db_url,
memory_llm_provider=self.llm_provider,
memory_llm_api_key=self.llm_api_key,
memory_llm_model=self.llm_model,
memory_llm_base_url=self.llm_base_url,
)
# Create FastAPI app
app = create_app(
memory=self._memory,
mcp_api_enabled=self.mcp_enabled,
initialize_memory=True,
)
# Create uvicorn config and server
config = Config(
app=app,
host=self.host,
port=self.port,
log_level=self.log_level,
loop="asyncio",
)
self._server = uvicorn.Server(config)
# Signal that we're starting
self._started.set()
# Run the server
loop.run_until_complete(self._server.serve())
except Exception as e:
logger.error(f"Server error: {e}")
raise
finally:
# Cleanup
if self._memory:
loop.run_until_complete(self._memory.close())
loop.close()
self._stopped.set()
def start(self, timeout: float = 30.0) -> "Server":
"""
Start the server in a background thread.
Args:
timeout: Maximum time to wait for server to start (seconds)
Returns:
self (for chaining)
Raises:
RuntimeError: If server fails to start within timeout
"""
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("Server is already running")
self._started.clear()
self._stopped.clear()
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
# Wait for server to start
self._started.wait(timeout=timeout)
# Give uvicorn a moment to actually bind to the port
start_time = time.time()
while time.time() - start_time < timeout:
try:
with socket.create_connection((self.host, self.port), timeout=1):
logger.info(f"Hindsight server started at {self.url}")
return self
except (ConnectionRefusedError, socket.timeout, OSError):
time.sleep(0.1)
raise RuntimeError(f"Server failed to start within {timeout} seconds")
def stop(self, timeout: float = 10.0) -> None:
"""
Stop the server.
Args:
timeout: Maximum time to wait for server to stop (seconds)
"""
if self._server is None:
return
# Signal uvicorn to shutdown
self._server.should_exit = True
# Wait for thread to finish
if self._thread is not None:
self._thread.join(timeout=timeout)
if self._thread.is_alive():
logger.warning("Server thread did not stop cleanly")
self._server = None
self._thread = None
logger.info("Hindsight server stopped")
def __enter__(self) -> "Server":
"""Context manager entry."""
return self.start()
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit."""
self.stop()
def start_server(
db_url: str = "pg0",
llm_provider: str = "groq",
llm_api_key: str = "",
llm_model: str = "openai/gpt-oss-120b",
llm_base_url: Optional[str] = None,
host: str = "127.0.0.1",
port: Optional[int] = None,
mcp_enabled: bool = False,
log_level: str = "warning",
timeout: float = 30.0,
) -> Server:
"""
Start a Hindsight server in a background thread.
This is a convenience function that creates and starts a Server instance.
Args:
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
host: Host to bind to (default: 127.0.0.1)
port: Port to bind to (default: auto-select free port)
mcp_enabled: Whether to enable MCP server
log_level: Uvicorn log level (default: warning)
timeout: Maximum time to wait for server to start (seconds)
Returns:
Running Server instance
Example:
```python
from hindsight import start_server, Client
server = start_server(
db_url="pg0",
llm_provider="groq",
llm_api_key="your-api-key",
llm_model="openai/gpt-oss-120b"
)
client = Client(base_url=server.url)
client.put(agent_id="assistant", content="User likes Python")
server.stop()
```
"""
server = Server(
db_url=db_url,
llm_provider=llm_provider,
llm_api_key=llm_api_key,
llm_model=llm_model,
llm_base_url=llm_base_url,
host=host,
port=port,
mcp_enabled=mcp_enabled,
log_level=log_level,
)
return server.start(timeout=timeout)