* 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)
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""
|
|
Embedded Hindsight client with automatic daemon lifecycle management.
|
|
|
|
This module provides HindsightEmbedded, a client that uses the same daemon
|
|
management interface as hindsight-embed CLI, ensuring full compatibility.
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
# Daemon starts automatically on first use
|
|
client = HindsightEmbedded(
|
|
profile="myapp",
|
|
llm_provider="groq",
|
|
llm_api_key="your-api-key",
|
|
)
|
|
|
|
# Use just like HindsightClient
|
|
client.retain(bank_id="alice", content="Alice loves AI")
|
|
results = client.recall(bank_id="alice", query="What does Alice like?")
|
|
|
|
# Optional cleanup
|
|
client.close()
|
|
```
|
|
|
|
Using context manager:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
with HindsightEmbedded(profile="myapp") as client:
|
|
client.retain(bank_id="alice", content="Alice loves AI")
|
|
# Daemon managed automatically
|
|
```
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
from typing import Optional
|
|
|
|
from hindsight_client import Hindsight
|
|
from hindsight_embed import get_embed_manager
|
|
|
|
from .api_namespaces import BanksAPI, DirectivesAPI, MemoriesAPI, MentalModelsAPI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HindsightEmbedded:
|
|
"""
|
|
Hindsight client with automatic daemon lifecycle management.
|
|
|
|
This client uses the same daemon management interface as hindsight-embed CLI,
|
|
ensuring full compatibility and shared profiles. The daemon is started automatically
|
|
on first use and manages profile-specific databases.
|
|
|
|
Profile data is stored in: ~/.pg0/instances/hindsight-embed-{profile}/
|
|
|
|
All methods from HindsightClient are available:
|
|
- retain(), retain_batch()
|
|
- recall()
|
|
- reflect()
|
|
- create_bank(), set_mission(), delete_bank()
|
|
- create_mental_model(), list_mental_models(), etc.
|
|
- create_directive(), list_directives(), etc.
|
|
- And all async variants (aretain, arecall, areflect, etc.)
|
|
|
|
Args:
|
|
profile: Profile name for data isolation (default: "default")
|
|
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
|
|
database_url: Optional database URL override (default: profile-specific pg0)
|
|
idle_timeout: Seconds before daemon auto-exits when idle (default: 300)
|
|
log_level: Daemon log level (default: "info")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
profile: str = "default",
|
|
llm_provider: str = "groq",
|
|
llm_api_key: str = "",
|
|
llm_model: str = "openai/gpt-oss-120b",
|
|
llm_base_url: Optional[str] = None,
|
|
database_url: Optional[str] = None,
|
|
idle_timeout: int = 300,
|
|
log_level: str = "info",
|
|
):
|
|
"""
|
|
Initialize the embedded client (daemon starts on first use).
|
|
|
|
Args:
|
|
profile: Profile name for data isolation
|
|
llm_provider: LLM provider
|
|
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
|
|
database_url: Optional database URL override
|
|
idle_timeout: Seconds before daemon auto-exits when idle
|
|
log_level: Daemon log level
|
|
"""
|
|
self.profile = profile
|
|
|
|
# Build config dict for daemon (matches CLI format)
|
|
self.config = {
|
|
"HINDSIGHT_API_LLM_PROVIDER": llm_provider,
|
|
"HINDSIGHT_API_LLM_API_KEY": llm_api_key,
|
|
"HINDSIGHT_API_LLM_MODEL": llm_model,
|
|
"HINDSIGHT_API_LOG_LEVEL": log_level,
|
|
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT": str(idle_timeout),
|
|
}
|
|
|
|
if llm_base_url:
|
|
self.config["HINDSIGHT_API_LLM_BASE_URL"] = llm_base_url
|
|
|
|
if database_url:
|
|
self.config["HINDSIGHT_EMBED_API_DATABASE_URL"] = database_url
|
|
|
|
self._client: Optional[Hindsight] = None
|
|
self._lock = threading.Lock()
|
|
self._started = False
|
|
self._closed = False
|
|
self._manager = get_embed_manager()
|
|
|
|
# API namespaces (initialized once, lazily)
|
|
self._banks_api: Optional[BanksAPI] = None
|
|
self._mental_models_api: Optional[MentalModelsAPI] = None
|
|
self._directives_api: Optional[DirectivesAPI] = None
|
|
self._memories_api: Optional[MemoriesAPI] = None
|
|
|
|
def _ensure_started(self):
|
|
"""Ensure daemon is running (thread-safe)."""
|
|
if self._started and self._client is not None:
|
|
return
|
|
|
|
with self._lock:
|
|
# Double-check after acquiring lock
|
|
if self._started and self._client is not None:
|
|
return
|
|
|
|
if self._closed:
|
|
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
|
|
|
|
# Use embed manager interface for daemon management
|
|
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
|
|
success = self._manager.ensure_running(self.config, self.profile)
|
|
if not success:
|
|
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
|
|
|
|
# Get daemon URL and create client
|
|
daemon_url = self._manager.get_url(self.profile)
|
|
self._client = Hindsight(base_url=daemon_url)
|
|
self._started = True
|
|
logger.info(f"Connected to daemon at {daemon_url}")
|
|
|
|
def _cleanup(self, stop_daemon_on_close: bool = False):
|
|
"""
|
|
Cleanup client resources (idempotent).
|
|
|
|
Args:
|
|
stop_daemon_on_close: If True, stops the daemon. Otherwise, daemon continues
|
|
running (it will auto-stop after idle timeout).
|
|
"""
|
|
if self._closed:
|
|
return
|
|
|
|
with self._lock:
|
|
if self._closed:
|
|
return
|
|
|
|
if self._client is not None:
|
|
self._client.close()
|
|
self._client = None
|
|
|
|
# Optionally stop daemon (daemon has idle timeout, so not required)
|
|
if stop_daemon_on_close and self._started:
|
|
logger.info(f"Stopping daemon for profile '{self.profile}'...")
|
|
self._manager.stop(self.profile)
|
|
|
|
self._closed = True
|
|
|
|
def close(self, stop_daemon: bool = False):
|
|
"""
|
|
Explicitly close the client.
|
|
|
|
Args:
|
|
stop_daemon: If True, stops the daemon. Otherwise, daemon continues running
|
|
and will auto-stop after idle timeout (default: False).
|
|
|
|
Note:
|
|
The daemon may be shared with other clients or the CLI, so stopping it
|
|
might affect other users. By default, we rely on the daemon's idle timeout.
|
|
"""
|
|
self._cleanup(stop_daemon_on_close=stop_daemon)
|
|
|
|
def __getattr__(self, name: str):
|
|
"""
|
|
Proxy all method calls to the underlying Hindsight client.
|
|
|
|
This allows HindsightEmbedded to expose all HindsightClient methods
|
|
without manually wrapping each one.
|
|
"""
|
|
# Ensure server is started before proxying
|
|
self._ensure_started()
|
|
|
|
# Get the attribute from the underlying client
|
|
attr = getattr(self._client, name)
|
|
|
|
# If it's a callable, wrap it to ensure server is started
|
|
# (shouldn't be needed since _ensure_started already called, but defensive)
|
|
if callable(attr):
|
|
|
|
def wrapper(*args, **kwargs):
|
|
self._ensure_started()
|
|
return attr(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return attr
|
|
|
|
def __enter__(self):
|
|
"""Context manager entry - ensures server is started."""
|
|
self._ensure_started()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
"""Context manager exit - stops the server."""
|
|
self.close()
|
|
|
|
def __del__(self):
|
|
"""Cleanup on garbage collection."""
|
|
self._cleanup()
|
|
|
|
@property
|
|
def banks(self) -> BanksAPI:
|
|
"""
|
|
Access bank management operations.
|
|
|
|
Each method call ensures the daemon is running before executing.
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
embedded = HindsightEmbedded(profile="myapp", ...)
|
|
|
|
# Create a bank
|
|
embedded.banks.create(bank_id="test", name="Test Bank")
|
|
|
|
# Set mission
|
|
embedded.banks.set_mission(bank_id="test", mission="Help users")
|
|
```
|
|
"""
|
|
if self._banks_api is None:
|
|
self._banks_api = BanksAPI(self)
|
|
return self._banks_api
|
|
|
|
@property
|
|
def mental_models(self) -> MentalModelsAPI:
|
|
"""
|
|
Access mental model operations.
|
|
|
|
Each method call ensures the daemon is running before executing.
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
embedded = HindsightEmbedded(profile="myapp", ...)
|
|
|
|
# Create a mental model
|
|
embedded.mental_models.create(
|
|
bank_id="test",
|
|
name="User Preferences",
|
|
content="User prefers dark mode"
|
|
)
|
|
|
|
# List mental models
|
|
models = embedded.mental_models.list(bank_id="test")
|
|
```
|
|
"""
|
|
if self._mental_models_api is None:
|
|
self._mental_models_api = MentalModelsAPI(self)
|
|
return self._mental_models_api
|
|
|
|
@property
|
|
def directives(self) -> DirectivesAPI:
|
|
"""
|
|
Access directive operations.
|
|
|
|
Each method call ensures the daemon is running before executing.
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
embedded = HindsightEmbedded(profile="myapp", ...)
|
|
|
|
# Create a directive
|
|
embedded.directives.create(
|
|
bank_id="test",
|
|
name="Response Style",
|
|
content="Always be concise and friendly"
|
|
)
|
|
|
|
# List directives
|
|
directives = embedded.directives.list(bank_id="test")
|
|
```
|
|
"""
|
|
if self._directives_api is None:
|
|
self._directives_api = DirectivesAPI(self)
|
|
return self._directives_api
|
|
|
|
@property
|
|
def memories(self) -> MemoriesAPI:
|
|
"""
|
|
Access memory listing operations.
|
|
|
|
Each method call ensures the daemon is running before executing.
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
embedded = HindsightEmbedded(profile="myapp", ...)
|
|
|
|
# List memories
|
|
memories = embedded.memories.list(
|
|
bank_id="test",
|
|
type="world",
|
|
limit=50
|
|
)
|
|
```
|
|
"""
|
|
if self._memories_api is None:
|
|
self._memories_api = MemoriesAPI(self)
|
|
return self._memories_api
|
|
|
|
@property
|
|
def client(self) -> Hindsight:
|
|
"""
|
|
Get the underlying Hindsight client for direct access.
|
|
|
|
WARNING: Using this property directly means daemon restarts won't be
|
|
handled automatically. Prefer using the API namespaces (banks, mental_models,
|
|
directives, memories) or direct method calls on HindsightEmbedded instead.
|
|
|
|
Ensures daemon is started before returning the client.
|
|
|
|
Returns:
|
|
Hindsight: The underlying client instance
|
|
|
|
Example:
|
|
```python
|
|
from hindsight import HindsightEmbedded
|
|
|
|
embedded = HindsightEmbedded(profile="myapp", ...)
|
|
|
|
# Direct access (not recommended - daemon crashes won't be handled)
|
|
client = embedded.client
|
|
banks = client.list_banks() # If daemon crashes, this will fail
|
|
```
|
|
"""
|
|
self._ensure_started()
|
|
return self._client
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
"""Get the daemon URL (starts daemon if needed)."""
|
|
self._ensure_started()
|
|
return self._manager.get_url(self.profile)
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
"""Check if the client is initialized."""
|
|
return self._started and not self._closed and self._client is not None
|