Files
Nicolò Boschi 31a8c52db5 refactor: replace httpx and sync HTTP with aiohttp across production code (#4318)
Production code now talks HTTP through aiohttp only, and only asynchronously.
httpx's async client is 5-12x slower per request than aiohttp at the
concurrency we run embeddings/rerank/LLM calls with (local stub: 1.3k vs 6.8k
req/s at c=8, 527 vs 6.3k at c=64), and the sync paths needed a thread per
in-flight request.

- engine/aiohttp_session.py: LoopLocal / LoopLocalSession (one session per event
  loop, created lazily, closed-loop entries released), per_phase_timeout
  (httpx-style per-phase timeouts), raise_for_status -> UpstreamHTTPError
  (keeps body + status_code for remote_retry), close_loop_sessions() called from
  MemoryEngine.close().
- Embeddings interface is async (encode/encode_query/encode_documents); remote
  providers use aiohttp or the SDK's async client, bounded fan-out via a per-loop
  semaphore instead of a thread pool. Local models stay in a worker thread.
- Rerankers, Codex/Nous/xAI OAuth providers (async token refresh with a per-loop
  asyncio.Lock + non-blocking flock), Fireworks, Ollama-native, llama.cpp,
  LlamaParse and Iris parsers moved to aiohttp.
- Webhook SSRF guard re-implemented as an aiohttp resolver that only returns
  validated addresses (no DNS cache, no redirects, no env proxies), with IP
  literals checked before sending.
- hindsight-embed probes, supabase-tenant extension and the litellm
  integration's async hooks moved to aiohttp.
- ruff TID251 bans httpx/requests/urllib.request/urllib3/http.client in
  hindsight-api-slim and hindsight-embed production code; code-review skill
  documents the rule (tests exempt).
2026-09-11 11:36:28 +02:00

1706 lines
64 KiB
Python

"""
Hindsight Embedded CLI.
A wrapper CLI that manages a local daemon and forwards commands to hindsight-cli.
No external server required - runs everything locally with automatic daemon management.
Usage:
hindsight-embed configure # Interactive setup
hindsight-embed retain "User prefers dark mode"
hindsight-embed recall "What are user preferences?"
hindsight-embed daemon status # Check daemon status
Environment variables:
HINDSIGHT_API_LLM_API_KEY: Required when the selected LLM provider uses an API key.
HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai").
HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: provider-specific, resolved by hindsight-api).
HINDSIGHT_EMBED_API_URL: Optional. Use external API server instead of starting local daemon.
HINDSIGHT_EMBED_API_TOKEN: Optional. Authentication token for external API (sent as Bearer token).
HINDSIGHT_EMBED_API_DATABASE_URL: Optional. Database URL for daemon (default: "pg0://hindsight-embed").
HINDSIGHT_EMBED_API_VERSION: Optional. hindsight-api version to use (default: matches embed version).
Note: Only applies when starting daemon. To change version, stop daemon first.
HINDSIGHT_EMBED_CLI_VERSION: Optional. hindsight CLI version to install (default: {embed_version}).
HINDSIGHT_EMBED_CONTROL_PORT: Optional. Port for the control center web app (default: 7878).
"""
import argparse
import logging
import os
import sys
from pathlib import Path
from . import get_embed_manager
CONFIG_DIR = Path.home() / ".hindsight"
CONFIG_FILE = CONFIG_DIR / "embed"
CONFIG_FILE_ALT = CONFIG_DIR / "config.env" # Alternative config file location
# Module-level variable to store CLI profile override (set by argparse)
_cli_profile_override: str | None = None
def get_cli_profile_override() -> str | None:
"""Get the profile override from CLI flag (--profile).
Returns:
Profile name if set via CLI flag, None otherwise.
"""
return _cli_profile_override
def set_cli_profile_override(profile: str | None) -> None:
"""Set the profile override from CLI flag (--profile).
Args:
profile: Profile name to set, or None to clear.
"""
global _cli_profile_override
_cli_profile_override = profile
def setup_logging(verbose: bool = False):
"""Configure logging."""
level_str = os.environ.get("HINDSIGHT_EMBED_LOG_LEVEL", "info").lower()
if verbose:
level_str = "debug"
level_map = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
}
level = level_map.get(level_str, logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
stream=sys.stderr,
)
return logging.getLogger(__name__)
def load_config_file():
"""Load configuration from the active profile's file if it exists.
IMPORTANT: Only loads from the active profile, never from default if a specific profile is set.
Uses dynamic path resolution to support testing with temporary HOME directories.
"""
from .profile_manager import ProfileManager, resolve_active_profile
# Resolve which profile to use (respects --profile flag, env vars, active_profile file)
active_profile = resolve_active_profile()
# Get the config file path for this profile
# Use ProfileManager which resolves paths dynamically
pm = ProfileManager()
paths = pm.resolve_profile_paths(active_profile)
config_path = paths.config
# Load ONLY this profile's config, never fall back to default
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
# Handle 'export VAR=value' format
if line.startswith("export "):
line = line[7:]
key, value = line.split("=", 1)
if key not in os.environ: # Don't override env vars
os.environ[key] = value
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
def get_config() -> dict[str, str]:
"""This invocation's daemon overrides, as ``HINDSIGHT_*`` environment variables.
Every ``HINDSIGHT_*`` variable in the process environment is forwarded
verbatim; there is no whitelist of known settings. A whitelist is exactly
how ``HINDSIGHT_API_LLM_BASE_URL`` came to be dropped while KEY/MODEL
applied (issue #4094) — ``hindsight-api``, not this wrapper, owns the list
of settings that exist, and it grows every release.
No provider or model default is injected: ``hindsight-api`` resolves both
(its ``DEFAULT_LLM_PROVIDER`` is this same ``"openai"``, and
``PROVIDER_DEFAULT_MODELS`` is provider-keyed). Duplicating those tables
here would silently desync — and importing them fails in standalone venvs
(``uvx hindsight-embed``) where ``hindsight-api`` isn't installed anyway.
"""
load_config_file()
config = {key: value for key, value in os.environ.items() if key.startswith("HINDSIGHT_")}
# OPENAI_API_KEY is the one non-HINDSIGHT_ name honoured here, and only for
# providers that authenticate with an LLM API key at all.
provider = config.get(ENV_LLM_PROVIDER) or "openai"
openai_key = os.environ.get("OPENAI_API_KEY")
if openai_key and ENV_LLM_API_KEY not in config and provider not in NO_API_KEY_PROVIDERS:
config[ENV_LLM_API_KEY] = openai_key
return config
# Provider -> API-key env var (None = no key needed)
PROVIDER_API_KEYS = {
"openai": "OPENAI_API_KEY",
"groq": "GROQ_API_KEY",
"gemini": "GEMINI_API_KEY",
"ollama": None,
"vertexai": None,
"github-copilot": None,
}
NO_API_KEY_PROVIDERS = frozenset(provider for provider, key_env in PROVIDER_API_KEYS.items() if key_env is None)
def do_configure(args):
"""Configuration setup with optional profile and env vars support.
Args:
args: Parsed arguments with optional --profile, --port, and --env flags.
"""
# Get profile, port, and env vars from args
profile = getattr(args, "profile", None)
port = getattr(args, "port", None)
env_vars = getattr(args, "env", None)
# Check if we're creating a named profile with --env flags
if profile and env_vars:
# Pass port (may be None for auto-allocation/reuse)
return _do_configure_profile_with_env(profile, port, env_vars)
# Check if we're creating a named profile interactively
if profile:
# Pass port (may be None for auto-allocation/reuse)
return _do_configure_profile_interactive(profile, port)
# Default behavior: interactive configuration for default profile.
# Prefer non-interactive mode when the CI/env-driven inputs are already
# set — otherwise Windows CI hits the interactive path (pwsh makes stdin
# look like a TTY) and blocks on EOF instead of using the env vars.
if _has_non_interactive_env():
return _do_configure_from_env()
# If stdin is not a terminal (e.g., running via curl | bash),
# redirect stdin from /dev/tty for interactive prompts
original_stdin = None
if not sys.stdin.isatty():
try:
original_stdin = sys.stdin
sys.stdin = open("/dev/tty", "r")
except OSError:
# No terminal available - try non-interactive mode with env vars
return _do_configure_from_env()
try:
return _do_configure_interactive()
finally:
if original_stdin is not None:
sys.stdin.close()
sys.stdin = original_stdin
def _has_non_interactive_env() -> bool:
"""Whether the env vars required by _do_configure_from_env are already set.
Returns True when an API key is present, OR when the provider is one that
doesn't need a key. Prevents the interactive prompt from kicking in when
the user clearly wants CI/scripted behavior.
"""
if os.environ.get("HINDSIGHT_API_LLM_API_KEY") or os.environ.get("OPENAI_API_KEY"):
return True
return os.environ.get("HINDSIGHT_API_LLM_PROVIDER") in NO_API_KEY_PROVIDERS
def _do_configure_from_env():
"""Non-interactive configuration from environment variables (for CI)."""
# Check for required environment variables
provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "openai")
api_key = (
None
if provider in NO_API_KEY_PROVIDERS
else os.environ.get("HINDSIGHT_API_LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
)
# Don't gate on PROVIDER_API_KEYS — that's only the small interactive-menu
# set. hindsight-api's PROVIDER_DEFAULT_MODELS supports many more
# providers (anthropic, claude-code, bedrock, openrouter, ...). Let the
# daemon validate; rejecting here would block valid configurations.
# These providers authenticate locally rather than through an LLM API key.
if not api_key and provider not in NO_API_KEY_PROVIDERS:
print("Error: Cannot run interactive configuration without a terminal.", file=sys.stderr)
print("", file=sys.stderr)
print("For non-interactive (CI) mode, set environment variables:", file=sys.stderr)
print(" HINDSIGHT_API_LLM_API_KEY=<your-api-key>", file=sys.stderr)
print(f" HINDSIGHT_API_LLM_PROVIDER={provider} # optional, default: openai", file=sys.stderr)
print(
" HINDSIGHT_API_LLM_MODEL=<model> # optional, defaults to provider's recommended model", file=sys.stderr
)
return 1
model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
print()
print("\033[1m\033[36m Hindsight Embed - Non-interactive Configuration\033[0m")
print()
print(f" \033[2mProvider:\033[0m {provider}")
print(f" \033[2mModel:\033[0m {model or '(provider default)'}")
# Save configuration
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Persist every HINDSIGHT_API_* variable the caller set, not a hand-listed
# subset: the subset is why HINDSIGHT_API_LLM_BASE_URL never reached the
# written profile (issue #4094). HINDSIGHT_EMBED_* is deliberately excluded
# — those configure this wrapper for one invocation (API URL, component
# versions), and baking them into the profile is not what a user setting
# them for a single `configure` run asked for.
config_values = {key: value for key, value in os.environ.items() if key.startswith("HINDSIGHT_API_") and value}
config_values[ENV_LLM_PROVIDER] = provider
if api_key:
config_values[ENV_LLM_API_KEY] = api_key
else:
# Providers in NO_API_KEY_PROVIDERS authenticate locally; don't persist
# a stale key that the resolved provider will never use.
config_values.pop(ENV_LLM_API_KEY, None)
from .env_template import render_config
CONFIG_FILE.write_text(render_config(config_values), encoding="utf-8")
CONFIG_FILE.chmod(0o600)
print()
print("\033[32m ✓ Configuration saved!\033[0m")
print()
return 0
def _prompt_choice(prompt: str, choices: list[tuple[str, str]], default: int = 1) -> str | None:
"""Simple choice prompt that works with /dev/tty."""
print(f"\033[1m{prompt}\033[0m")
print()
for i, (label, _) in enumerate(choices, 1):
print(f" \033[36m{i})\033[0m {label}")
print()
try:
response = input(f"Enter choice [{default}]: ").strip()
if not response:
return choices[default - 1][1]
idx = int(response)
if 1 <= idx <= len(choices):
return choices[idx - 1][1]
return choices[default - 1][1]
except (ValueError, EOFError, KeyboardInterrupt):
return None
def _prompt_text(prompt: str, default: str = "") -> str | None:
"""Simple text prompt."""
try:
suffix = f" [{default}]" if default else ""
response = input(f"\033[1m{prompt}\033[0m{suffix}: ").strip()
return response if response else default
except (EOFError, KeyboardInterrupt):
return None
def _prompt_password(prompt: str) -> str | None:
"""Simple password prompt that works with /dev/tty."""
import termios
import tty
# Read password with echo disabled (works because sys.stdin is already /dev/tty)
fd = sys.stdin.fileno()
print(f"\033[1m{prompt}\033[0m: ", end="", flush=True)
try:
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd, termios.TCSADRAIN)
# Read character by character until newline
password = []
while True:
ch = sys.stdin.read(1)
if ch in ("\n", "\r"):
break
elif ch == "\x7f": # Backspace
if password:
password.pop()
# Erase character on screen
sys.stdout.write("\b \b")
sys.stdout.flush()
elif ch == "\x03": # Ctrl+C
raise KeyboardInterrupt
elif ch >= " ": # Printable character
password.append(ch)
print() # Newline after password
return "".join(password)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
except (EOFError, KeyboardInterrupt):
print()
return None
except Exception:
# Fallback to simple input if termios fails
try:
return input("")
except (EOFError, KeyboardInterrupt):
return None
def _prompt_confirm(prompt: str, default: bool = True) -> bool | None:
"""Simple yes/no prompt."""
suffix = "[Y/n]" if default else "[y/N]"
try:
response = input(f"\033[1m{prompt}\033[0m {suffix}: ").strip().lower()
if not response:
return default
return response in ("y", "yes")
except (EOFError, KeyboardInterrupt):
return None
def _do_configure_interactive(profile_name: str | None = None, port: int | None = None):
"""Internal interactive configuration.
Args:
profile_name: Optional profile name. If None, configures default profile.
port: Optional port for named profile. Required if profile_name is provided.
Returns:
Exit code (0 = success, 1 = error).
"""
print()
if profile_name:
print(f"\033[1m\033[36m Configuring profile '{profile_name}' (port {port})\033[0m")
else:
print("\033[1m\033[36m ╭─────────────────────────────────────╮\033[0m")
print("\033[1m\033[36m │ Hindsight Embed Configuration │\033[0m")
print("\033[1m\033[36m ╰─────────────────────────────────────╯\033[0m")
print()
# Check existing config
config_file = CONFIG_DIR / "profiles" / f"{profile_name}.env" if profile_name else CONFIG_FILE
if config_file.exists():
if not _prompt_confirm("Existing configuration found. Reconfigure?", default=False):
print("\n\033[32m✓\033[0m Keeping existing configuration.")
return 0
print()
# Provider selection
providers = [
("OpenAI (recommended)", "openai"),
("Groq (fast & free tier)", "groq"),
("Google Gemini", "gemini"),
("Ollama (local, no API key)", "ollama"),
("GitHub Copilot (signed-in account, no LLM API key)", "github-copilot"),
]
provider = _prompt_choice("Select your LLM provider:", providers, default=1)
if provider is None:
print("\n\033[33m⚠\033[0m Configuration cancelled.")
return 1
env_key = PROVIDER_API_KEYS[provider]
print()
# API key
api_key = ""
if env_key:
existing = os.environ.get(env_key, "")
if existing:
masked = existing[:8] + "..." + existing[-4:] if len(existing) > 12 else "***"
if _prompt_confirm(f"Found API key in ${env_key} ({masked}). Use it?", default=True):
api_key = existing
print()
if not api_key:
api_key = _prompt_password("Enter your API key")
if not api_key:
print("\n\033[31m✗\033[0m API key is required.", file=sys.stderr)
return 1
print()
# Empty = let the daemon pick the provider default (PROVIDER_DEFAULT_MODELS)
model = _prompt_text("Model name (leave empty for provider default)")
if model is None:
return 1
print()
# Save configuration
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_dict = {
"HINDSIGHT_API_LLM_PROVIDER": provider,
}
if model:
config_dict["HINDSIGHT_API_LLM_MODEL"] = model
if api_key:
config_dict["HINDSIGHT_API_LLM_API_KEY"] = api_key
if profile_name:
# Create named profile
from .profile_manager import ProfileManager
pm = ProfileManager()
try:
pm.create_profile(profile_name, port, config_dict)
except ValueError as e:
print(f"\n\033[31m✗\033[0m Error creating profile: {e}", file=sys.stderr)
return 1
else:
# Save to default profile
from .env_template import render_config
CONFIG_FILE.write_text(render_config(config_dict), encoding="utf-8")
CONFIG_FILE.chmod(0o600)
# Stop existing daemon if running (it needs to pick up new config)
from . import daemon_client
daemon_profile = profile_name if profile_name else None
if daemon_client.is_daemon_running(daemon_profile):
print("\n \033[2mRestarting daemon with new configuration...\033[0m")
daemon_client.stop_daemon(daemon_profile)
# Start daemon with new config
if daemon_client.ensure_daemon_running(dict(config_dict), daemon_profile):
print(" \033[32m✓ Daemon started\033[0m")
else:
print(" \033[33m⚠ Failed to start daemon (will start on first command)\033[0m")
print()
print("\033[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m")
print("\033[32m ✓ Configuration saved!\033[0m")
print("\033[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m")
print()
print(f" \033[2mConfig:\033[0m {CONFIG_FILE}")
print()
print(" \033[2mTest with:\033[0m")
print(' \033[36mhindsight-embed retain "Alice works at Google as a software engineer"\033[0m')
print(' \033[36mhindsight-embed recall "Alice"\033[0m')
print()
return 0
def do_daemon(args, config: dict, logger):
"""Handle daemon subcommands."""
from pathlib import Path
from . import daemon_client
from .profile_manager import ProfileManager
# Get profile from args
profile = args.profile
# Get profile-specific paths
pm = ProfileManager()
paths = pm.resolve_profile_paths(profile or "")
daemon_log_path = paths.log
port = paths.port
if args.daemon_command == "start":
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
if daemon_client.is_daemon_running(profile):
# Build title with profile and port
if profile:
already_running_title = (
f"[bold yellow]Daemon Already Running[/bold yellow] [dim]({profile} @ :{port})[/dim]"
)
else:
already_running_title = f"[bold yellow]Daemon Already Running[/bold yellow] [dim](:{port})[/dim]"
console.print(
Panel(
Text("Daemon is already running", style="yellow"),
title=already_running_title,
border_style="yellow",
)
)
return 0
if daemon_client.ensure_daemon_running(config, profile):
# Start UI if --ui flag was passed
if getattr(args, "ui", False):
from .daemon_embed_manager import DaemonEmbedManager
from .profile_manager import resolve_active_profile
# Use the same profile resolution as the daemon
resolved_profile = profile if profile is not None else resolve_active_profile()
manager = DaemonEmbedManager()
ui_started = manager.start_ui(resolved_profile, None, "0.0.0.0")
if not ui_started:
console.print(
Panel(
Text("Daemon is running but UI failed to start", style="yellow"),
title="[bold yellow]UI Warning[/bold yellow]",
border_style="yellow",
)
)
return 0
else:
console.print(
Panel(
Text("Failed to start daemon", style="red"),
title="[bold red]✗ Error[/bold red]",
border_style="red",
)
)
return 1
elif args.daemon_command == "stop":
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
if not daemon_client.is_daemon_running(profile):
# Build title for not running status
if profile:
not_running_title = f"[bold]Daemon Status[/bold] [dim]({profile})[/dim]"
else:
not_running_title = "[bold]Daemon Status[/bold]"
console.print(
Panel(
Text("Daemon is not running", style="dim"),
title=not_running_title,
border_style="dim",
)
)
return 0
if daemon_client.stop_daemon(profile):
# Build title with profile
if profile:
stopped_title = f"[bold green]✓ Daemon Stopped[/bold green] [dim]({profile})[/dim]"
else:
stopped_title = "[bold green]✓ Daemon Stopped[/bold green]"
console.print(
Panel(
Text("Daemon stopped successfully", style="green"),
title=stopped_title,
border_style="green",
)
)
return 0
else:
console.print(
Panel(
Text("Failed to stop daemon", style="red"),
title="[bold red]✗ Error[/bold red]",
border_style="red",
)
)
return 1
elif args.daemon_command == "status":
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
if daemon_client.is_daemon_running(profile):
status_text = Text()
status_text.append("Daemon is running\n\n", style="green bold")
status_text.append(" URL: ", style="dim")
status_text.append(f"http://127.0.0.1:{port}\n", style="cyan")
status_text.append(" Logs: ", style="dim")
status_text.append(f"{daemon_log_path}\n", style="")
# Check if using pg0 and show database location
database_url = os.getenv("HINDSIGHT_EMBED_API_DATABASE_URL")
if not database_url:
# Default: use profile-specific pg0 (shared utility ensures consistency)
database_url = get_embed_manager().get_database_url(profile)
if database_url.startswith("pg0://"):
pg0_name = database_url.replace("pg0://", "")
pg0_path = Path.home() / ".pg0" / "instances" / pg0_name
status_text.append(" Database: ", style="dim")
status_text.append(f"{pg0_path}", style="")
# Build title with profile and port
if profile:
status_title = f"[bold green]✓ Daemon Running[/bold green] [dim]({profile} @ :{port})[/dim]"
else:
status_title = f"[bold green]✓ Daemon Running[/bold green] [dim](:{port})[/dim]"
console.print(
Panel(
status_text,
title=status_title,
border_style="green",
padding=(1, 2),
)
)
return 0
else:
# Build title for not running status
if profile:
not_running_title = f"[bold]Daemon Status[/bold] [dim]({profile})[/dim]"
else:
not_running_title = "[bold]Daemon Status[/bold]"
console.print(
Panel(
Text("Daemon is not running", style="dim"),
title=not_running_title,
border_style="dim",
)
)
return 1
elif args.daemon_command == "logs":
if not daemon_log_path.exists():
print("No daemon logs found", file=sys.stderr)
print(f" Expected at: {daemon_log_path}")
return 1
if args.follow:
# Follow mode - like tail -f
import subprocess
try:
subprocess.run(["tail", "-f", str(daemon_log_path)])
except KeyboardInterrupt:
pass
return 0
else:
# Show last N lines
try:
with open(daemon_log_path, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
for line in lines[-args.lines :]:
print(line, end="")
return 0
except Exception as e:
print(f"Error reading logs: {e}", file=sys.stderr)
return 1
else:
print("Usage: hindsight-embed daemon {start|stop|status|logs}", file=sys.stderr)
return 1
def do_ui(args, config: dict, logger):
"""Handle UI subcommands."""
from . import daemon_client
from .profile_manager import ProfileManager
profile = args.profile
ui_port = getattr(args, "port", None)
hostname = getattr(args, "hostname", "0.0.0.0")
# Resolve default UI port (from the profile's .env, else API + offset)
pm = ProfileManager()
paths = pm.resolve_profile_paths(profile or "")
default_ui_port = paths.ui_port
if args.ui_command == "start":
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
effective_port = ui_port or default_ui_port
if daemon_client.is_ui_running(profile, effective_port):
title = (
f"[bold yellow]UI Already Running[/bold yellow] [dim]({profile or 'default'} @ :{effective_port})[/dim]"
)
console.print(
Panel(
Text("UI is already running", style="yellow"),
title=title,
border_style="yellow",
)
)
return 0
# Ensure daemon is running first
if not daemon_client.is_daemon_running(profile):
console.print("[dim]Daemon not running, starting it first...[/dim]")
if not daemon_client.ensure_daemon_running(config, profile):
console.print(
Panel(
Text("Failed to start daemon (required for UI)", style="red"),
title="[bold red]✗ Error[/bold red]",
border_style="red",
)
)
return 1
if daemon_client.start_ui(profile, ui_port, hostname):
return 0
else:
return 1
elif args.ui_command == "stop":
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
effective_port = ui_port or default_ui_port
if not daemon_client.is_ui_running(profile, effective_port):
title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]"
console.print(
Panel(
Text("UI is not running", style="dim"),
title=title,
border_style="dim",
)
)
return 0
if daemon_client.stop_ui(profile, ui_port):
title = f"[bold green]✓ UI Stopped[/bold green] [dim]({profile or 'default'})[/dim]"
console.print(
Panel(
Text("UI stopped successfully", style="green"),
title=title,
border_style="green",
)
)
return 0
else:
console.print(
Panel(
Text("Failed to stop UI", style="red"),
title="[bold red]✗ Error[/bold red]",
border_style="red",
)
)
return 1
elif args.ui_command == "status":
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
console = Console()
effective_port = ui_port or default_ui_port
if daemon_client.is_ui_running(profile, effective_port):
status_text = Text()
status_text.append("UI is running\n\n", style="green bold")
status_text.append(" URL: ", style="dim")
# localhost, not a loopback literal: the UI can be bound to ::1 only.
status_text.append(f"http://localhost:{effective_port}\n", style="cyan")
status_text.append(" Logs: ", style="dim")
status_text.append(f"{paths.ui_log}", style="")
title = f"[bold green]✓ UI Running[/bold green] [dim]({profile or 'default'} @ :{effective_port})[/dim]"
console.print(
Panel(
status_text,
title=title,
border_style="green",
padding=(1, 2),
)
)
return 0
else:
title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]"
console.print(
Panel(
Text("UI is not running", style="dim"),
title=title,
border_style="dim",
)
)
return 1
elif args.ui_command == "logs":
ui_log_path = paths.ui_log
if not ui_log_path.exists():
print("No UI logs found", file=sys.stderr)
print(f" Expected at: {ui_log_path}")
return 1
if args.follow:
import subprocess
try:
subprocess.run(["tail", "-f", str(ui_log_path)])
except KeyboardInterrupt:
pass
return 0
else:
try:
with open(ui_log_path, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
for line in lines[-args.lines :]:
print(line, end="")
return 0
except Exception as e:
print(f"Error reading logs: {e}", file=sys.stderr)
return 1
else:
print("Usage: hindsight-embed ui {start|stop|status|logs}", file=sys.stderr)
return 1
def _do_configure_profile_with_env(profile_name: str, port: int | None, env_vars: list[str]) -> int:
"""Configure a named profile with environment variables (non-interactive).
Args:
profile_name: Name of the profile to create/update.
port: Port number for the daemon (None to auto-allocate/reuse existing).
env_vars: List of KEY=VALUE strings.
Returns:
Exit code (0 = success, 1 = error).
"""
from .profile_manager import ProfileManager
# Parse env vars
config = {}
for env_str in env_vars:
if "=" not in env_str:
print(f"Error: Invalid --env format '{env_str}'. Expected KEY=VALUE", file=sys.stderr)
return 1
key, value = env_str.split("=", 1)
key = key.strip()
value = value.strip()
# Validate key format
if not key.startswith("HINDSIGHT_EMBED_") and not key.startswith("HINDSIGHT_API_"):
print(
f"Warning: Key '{key}' doesn't start with HINDSIGHT_EMBED_ or HINDSIGHT_API_",
file=sys.stderr,
)
config[key] = value
# Create profile
pm = ProfileManager()
# Determine port: use provided, reuse existing, or allocate new
if port is None:
# Check if profile exists and get its port
existing_profile = pm.get_profile(profile_name)
if existing_profile:
port = existing_profile.port
else:
port = pm._allocate_port(profile_name)
try:
pm.create_profile(profile_name, port, config)
except ValueError as e:
print(f"Error creating profile: {e}", file=sys.stderr)
return 1
print()
print(f"\033[32m✓ Profile '{profile_name}' configured successfully!\033[0m")
print()
profile_path = CONFIG_DIR / "profiles" / f"{profile_name}.env"
print(f" \033[2mConfig:\033[0m {profile_path}")
print(f" \033[2mPort:\033[0m {port}")
print()
print(" \033[2mUse with:\033[0m")
print(f" \033[36mhindsight-embed daemon start --profile {profile_name}\033[0m")
print(f' \033[36mhindsight-embed --profile {profile_name} memory recall default "query"\033[0m')
print()
return 0
def _do_configure_profile_interactive(profile_name: str, port: int | None) -> int:
"""Configure a named profile interactively.
Args:
profile_name: Name of the profile to create/update.
port: Port number for the daemon (None to auto-allocate/reuse existing).
Returns:
Exit code (0 = success, 1 = error).
"""
from .profile_manager import ProfileManager
# Determine port: use provided, reuse existing, or allocate new
if port is None:
pm = ProfileManager()
# Check if profile exists and get its port
existing_profile = pm.get_profile(profile_name)
if existing_profile:
port = existing_profile.port
else:
port = pm._allocate_port(profile_name)
print()
print(f"\033[1m\033[36m Configuring profile '{profile_name}' (port {port})\033[0m")
print()
# Use the same interactive flow as default profile but save to named profile
return _do_configure_interactive(profile_name, port)
def do_control(args) -> int:
"""Handle control-center subcommands (start/stop/status/logs).
The control center is a persistent, localhost-only web app that writes
profile config (the LLM wizard) and supervises the daemon. It runs as its
own detached process, so it survives daemon restarts.
"""
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from .control_center import (
control_status,
resolve_control_port,
start_control_center,
stop_control_center,
)
from .control_center.lifecycle import log_file
console = Console()
port = getattr(args, "port", None) or resolve_control_port()
command = args.control_command
if command == "start":
result = start_control_center(port, open_browser=not getattr(args, "no_open", False))
if not result.ok:
console.print(
Panel(
Text(result.message, style="red"), title="[bold red]✗ Control Center[/bold red]", border_style="red"
)
)
return 1
body = Text()
body.append(
"Control center already running\n\n" if result.already_running else "Control center started\n\n",
style="green bold",
)
body.append(" Open: ", style="dim")
body.append(f"{result.url}\n", style="cyan")
body.append(" Logs: ", style="dim")
body.append(str(log_file()), style="")
console.print(
Panel(
body,
title=f"[bold green]✓ Control Center[/bold green] [dim](:{port})[/dim]",
border_style="green",
padding=(1, 2),
)
)
return 0
if command == "stop":
if stop_control_center(port):
console.print(
Panel(
Text("Control center stopped", style="green"),
title="[bold green]✓ Control Center[/bold green]",
border_style="green",
)
)
return 0
console.print(
Panel(
Text("Failed to stop control center", style="red"),
title="[bold red]✗ Error[/bold red]",
border_style="red",
)
)
return 1
if command == "status":
status = control_status(port)
if status.running:
body = Text()
body.append("Control center is running\n\n", style="green bold")
body.append(" URL: ", style="dim")
body.append(f"http://localhost:{status.port}\n", style="cyan")
console.print(
Panel(
body,
title=f"[bold green]✓ Control Center[/bold green] [dim](:{status.port})[/dim]",
border_style="green",
padding=(1, 2),
)
)
return 0
console.print(
Panel(
Text("Control center is not running", style="dim"),
title="[bold]Control Center[/bold]",
border_style="dim",
)
)
return 1
if command == "logs":
log_path = log_file()
if not log_path.exists():
print("No control center logs found", file=sys.stderr)
print(f" Expected at: {log_path}")
return 1
if getattr(args, "follow", False):
import subprocess
try:
subprocess.run(["tail", "-f", str(log_path)])
except KeyboardInterrupt:
pass
return 0
with open(log_path, encoding="utf-8", errors="replace") as f:
for line in f.readlines()[-getattr(args, "lines", 50) :]:
print(line, end="")
return 0
print("Usage: hindsight-embed control {start|stop|status|logs}", file=sys.stderr)
return 1
def do_profile_command(args: list[str]) -> int:
"""Handle profile subcommands.
Args:
args: Command arguments (after 'profile').
Returns:
Exit code (0 = success, 1 = error).
"""
from .profile_manager import ProfileManager, resolve_active_profile, validate_profile_exists
parser = argparse.ArgumentParser(prog="hindsight-embed profile")
subparsers = parser.add_subparsers(dest="profile_command", required=True)
# List command
list_parser = subparsers.add_parser("list", help="List all profiles")
list_parser.add_argument(
"-o", "--output", choices=["text", "json"], default="text", help="Output format (text or json)"
)
# Create command
create_parser = subparsers.add_parser("create", help="Create a new profile")
create_parser.add_argument("name", help="Profile name")
create_parser.add_argument("--port", type=int, required=True, help="Port for the daemon")
create_parser.add_argument("--env", action="append", help="Environment variable (KEY=VALUE, can be repeated)")
create_parser.add_argument("--merge", action="store_true", help="Merge with existing profile if it exists")
# Set-env command
set_env_parser = subparsers.add_parser("set-env", help="Set/update an environment variable in a profile")
set_env_parser.add_argument("name", help="Profile name")
set_env_parser.add_argument("key", help="Environment variable key")
set_env_parser.add_argument("value", help="Environment variable value")
# Remove-env command
remove_env_parser = subparsers.add_parser("remove-env", help="Remove an environment variable from a profile")
remove_env_parser.add_argument("name", help="Profile name")
remove_env_parser.add_argument("key", help="Environment variable key to remove")
# Delete command
delete_parser = subparsers.add_parser("delete", help="Delete a profile")
delete_parser.add_argument("name", help="Profile name to delete")
# Set-active command
set_active_parser = subparsers.add_parser("set-active", help="Set active profile")
set_active_parser.add_argument("name", nargs="?", help="Profile name (omit to clear)")
set_active_parser.add_argument("--none", action="store_true", help="Clear active profile")
# Show command
show_parser = subparsers.add_parser("show", help="Show current active profile")
show_parser.add_argument(
"-o", "--output", choices=["text", "json"], default="text", help="Output format (text or json)"
)
try:
parsed_args = parser.parse_args(args)
except SystemExit as e:
return e.code or 1
pm = ProfileManager()
if parsed_args.profile_command == "list":
# List all profiles
profiles = pm.list_profiles()
if parsed_args.output == "json":
# JSON output
import json
profiles_data = []
for profile in profiles:
config_path = str(CONFIG_DIR / "profiles" / f"{profile.name}.env") if profile.name else str(CONFIG_FILE)
profiles_data.append(
{
"name": profile.name or "default",
"port": profile.port,
"config": config_path,
"created_at": profile.created_at,
"last_used": profile.last_used,
"is_active": profile.is_active,
"daemon_running": profile.daemon_running,
}
)
print(json.dumps(profiles_data, indent=2))
return 0
# Text output
if not profiles:
print("No profiles configured.")
print()
print("Create one with:")
print(" hindsight-embed configure --profile my-app --port 9100 --env HINDSIGHT_API_LLM_PROVIDER=...")
return 0
print()
print("\033[1mProfiles:\033[0m")
print()
for profile in profiles:
name = profile.name or "default"
active_marker = " \033[32m✓ active\033[0m" if profile.is_active else ""
daemon_marker = " \033[36m● running\033[0m" if profile.daemon_running else ""
print(f" \033[1m{name}\033[0m{active_marker}{daemon_marker}")
print(f" Port: {profile.port}")
if profile.name: # Named profile
config_path = CONFIG_DIR / "profiles" / f"{profile.name}.env"
print(f" Config: {config_path}")
else: # Default profile
config_path = CONFIG_FILE
print(f" Config: {config_path}")
print()
return 0
elif parsed_args.profile_command == "create":
# Create new profile
profile_name = parsed_args.name
port = parsed_args.port
env_vars = parsed_args.env or []
merge = parsed_args.merge
# Normalize "default" to empty string
if profile_name == "default":
profile_name = ""
# Check if profile exists
profile_exists = pm.profile_exists(profile_name)
if profile_exists and not merge:
display_name = profile_name or "default"
print(f"Error: Profile '{display_name}' already exists.", file=sys.stderr)
print(" Use --merge to update the profile, or delete it first with:", file=sys.stderr)
print(f" hindsight-embed profile delete {display_name}", file=sys.stderr)
return 1
# Parse new env vars
new_config = {}
for env_str in env_vars:
if "=" not in env_str:
print(f"Error: Invalid --env format '{env_str}'. Expected KEY=VALUE", file=sys.stderr)
return 1
key, value = env_str.split("=", 1)
new_config[key.strip()] = value.strip()
# If merging, read existing config and merge
config = {}
if merge and profile_exists:
if profile_name:
config_path = CONFIG_DIR / "profiles" / f"{profile_name}.env"
else:
config_path = CONFIG_FILE
if config_path.exists():
for line in config_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
if k != "PORT": # Don't copy PORT, we'll set it explicitly
config[k] = v
# Merge new config into existing
config.update(new_config)
# Create/update profile
try:
pm.create_profile(profile_name, port, config)
display_name = profile_name or "default"
action = "updated" if (merge and profile_exists) else "created"
print(f"\033[32m✓\033[0m Profile '{display_name}' {action} successfully!")
print()
if profile_name:
config_path = CONFIG_DIR / "profiles" / f"{profile_name}.env"
else:
config_path = CONFIG_FILE
print(f" \033[2mConfig:\033[0m {config_path}")
print(f" \033[2mPort:\033[0m {port}")
return 0
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif parsed_args.profile_command == "set-env":
# Set/update env variable in profile
profile_name = parsed_args.name
key = parsed_args.key
value = parsed_args.value
# Normalize "default" to empty string
if profile_name == "default":
profile_name = ""
if not pm.profile_exists(profile_name):
display_name = profile_name or "default"
print(f"Error: Profile '{display_name}' does not exist.", file=sys.stderr)
return 1
# Read current config
if profile_name:
config_path = CONFIG_DIR / "profiles" / f"{profile_name}.env"
else:
config_path = CONFIG_FILE
# Parse existing config
config = {}
if config_path.exists():
for line in config_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
config[k] = v
# Update the key
config[key] = value
# Get port from config or resolve from profile
port = int(config.get("PORT", pm.resolve_profile_paths(profile_name).port))
# Write back
try:
pm.create_profile(profile_name, port, {k: v for k, v in config.items() if k != "PORT"})
display_name = profile_name or "default"
print(f"\033[32m✓\033[0m Set {key}={value} in profile '{display_name}'")
return 0
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif parsed_args.profile_command == "remove-env":
# Remove env variable from profile
profile_name = parsed_args.name
key = parsed_args.key
# Normalize "default" to empty string
if profile_name == "default":
profile_name = ""
if not pm.profile_exists(profile_name):
display_name = profile_name or "default"
print(f"Error: Profile '{display_name}' does not exist.", file=sys.stderr)
return 1
# Read current config
if profile_name:
config_path = CONFIG_DIR / "profiles" / f"{profile_name}.env"
else:
config_path = CONFIG_FILE
# Parse existing config
config = {}
if config_path.exists():
for line in config_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
config[k] = v
# Remove the key
if key not in config:
display_name = profile_name or "default"
print(f"Error: Key '{key}' not found in profile '{display_name}'", file=sys.stderr)
return 1
del config[key]
# Get port from config or resolve from profile
port = int(config.get("PORT", pm.resolve_profile_paths(profile_name).port))
# Write back
try:
pm.create_profile(profile_name, port, {k: v for k, v in config.items() if k != "PORT"})
display_name = profile_name or "default"
print(f"\033[32m✓\033[0m Removed {key} from profile '{display_name}'")
return 0
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif parsed_args.profile_command == "delete":
# Delete profile
profile_name = parsed_args.name
# Normalize "default" to empty string
if profile_name == "default":
profile_name = ""
if not pm.profile_exists(profile_name):
display_name = profile_name or "default"
print(f"Error: Profile '{display_name}' does not exist.", file=sys.stderr)
return 1
# Check if daemon is running
profile_info = pm.get_profile(profile_name)
if profile_info and profile_info.daemon_running:
display_name = profile_name or "default"
print(f"Warning: Daemon is running for profile '{display_name}'")
try:
confirm = input("Stop daemon and delete profile? [y/N]: ").strip().lower()
if confirm not in ("y", "yes"):
print("Cancelled.")
return 0
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 0
# Stop daemon
from . import daemon_client
daemon_client.stop_daemon(profile_name)
# Delete profile
try:
pm.delete_profile(profile_name)
display_name = profile_name or "default"
print(f"\033[32m✓\033[0m Profile '{display_name}' deleted.")
return 0
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif parsed_args.profile_command == "set-active":
# Set active profile
if parsed_args.none:
pm.set_active_profile(None)
print("\033[32m✓\033[0m Active profile cleared.")
return 0
if not parsed_args.name:
print("Error: Specify profile name or use --none to clear.", file=sys.stderr)
return 1
profile_name = parsed_args.name
try:
pm.set_active_profile(profile_name)
print(f"\033[32m✓\033[0m Active profile set to '{profile_name}'.")
return 0
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
elif parsed_args.profile_command == "show":
# Show current active profile
# Resolve using full priority chain
active_profile = resolve_active_profile()
# Validate profile exists
validate_profile_exists(active_profile)
display_name = active_profile if active_profile else "default"
# Determine source
source = "default"
if not active_profile:
source = "default"
elif os.getenv("HINDSIGHT_EMBED_PROFILE"):
source = "HINDSIGHT_EMBED_PROFILE"
elif get_cli_profile_override():
source = "cli_flag"
elif pm.get_active_profile():
source = "active_profile_file"
# Get config path
paths = pm.resolve_profile_paths(active_profile)
if parsed_args.output == "json":
# JSON output
import json
data = {
"name": display_name,
"source": source,
"config": str(paths.config),
"port": paths.port,
}
print(json.dumps(data, indent=2))
return 0
# Text output
print()
print(f"\033[1mActive profile:\033[0m {display_name}")
print()
if source == "default":
print(" \033[2mSource:\033[0m Default (no profile specified)")
elif source == "HINDSIGHT_EMBED_PROFILE":
print(" \033[2mSource:\033[0m HINDSIGHT_EMBED_PROFILE environment variable")
elif source == "cli_flag":
print(" \033[2mSource:\033[0m --profile flag")
elif source == "active_profile_file":
print(" \033[2mSource:\033[0m Active profile file")
print(f" \033[2mConfig:\033[0m {paths.config}")
print(f" \033[2mPort:\033[0m {paths.port}")
print()
return 0
return 1
def main():
"""Main entry point."""
# Windows defaults stdout/stderr to the legacy cp1252 codec, which crashes
# on the Unicode glyphs (✓, box-drawing, etc.) used throughout Rich-rendered
# output. Reconfigure to UTF-8 before the first print. `errors="replace"`
# keeps the CLI from dying on an unexpected character (e.g. a redirected
# pipe) — glyphs that can't be rendered become '?' rather than raising.
if sys.platform == "win32":
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8", errors="replace")
# Use argparse to properly parse global flags
# Create a parent parser for global --profile/-p flag
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument("-p", "--profile", help="Profile name to use")
# Parse known args to extract --profile value
global_args, remaining_args = parent_parser.parse_known_args()
global_profile = global_args.profile
if global_profile == "default":
global_profile = None
# Set the CLI profile override so it's available to resolve_active_profile()
# This must happen BEFORE any config loading (load_config_file, get_config, etc.)
set_cli_profile_override(global_profile)
# Check for built-in commands first
# Find the first non-flag argument (the actual command)
command = None
if remaining_args:
command = remaining_args[0]
# Handle configure
if command == "configure":
# Parse configure arguments
parser = argparse.ArgumentParser(prog="hindsight-embed configure")
parser.add_argument("-p", "--profile", help="Profile name to create/update")
parser.add_argument(
"--port",
type=int,
help="Port for the daemon (required for named profiles, default profile uses 8888)",
)
parser.add_argument(
"--env",
action="append",
help="Environment variable (KEY=VALUE, can be repeated)",
)
parser.add_argument(
"--web",
action="store_true",
help="Launch the browser-based control center wizard instead of the terminal prompts",
)
args = parser.parse_args(remaining_args[1:]) # Skip 'configure' itself
# If --profile was consumed by parent_parser, use global_profile
if not args.profile and global_profile:
args.profile = global_profile
logger = setup_logging(False)
if getattr(args, "web", False):
# Delegate to the control center: a persistent local web UI
# that writes config and supervises the daemon.
control_args = argparse.Namespace(control_command="start", port=None, no_open=False)
sys.exit(do_control(control_args))
exit_code = do_configure(args)
sys.exit(exit_code)
# Handle profile subcommands
if command == "profile":
exit_code = do_profile_command(remaining_args[1:]) # Skip 'profile' itself
sys.exit(exit_code)
# Handle daemon subcommands
if command == "daemon":
# Parse daemon subcommand (profile already extracted globally)
parser = argparse.ArgumentParser(prog="hindsight-embed daemon")
subparsers = parser.add_subparsers(dest="daemon_command")
start_parser = subparsers.add_parser("start", help="Start the daemon")
start_parser.add_argument("--ui", action="store_true", help="Also start the web UI after daemon is ready")
subparsers.add_parser("stop", help="Stop the daemon")
subparsers.add_parser("status", help="Check daemon status")
logs_parser = subparsers.add_parser("logs", help="View daemon logs")
logs_parser.add_argument("--follow", "-f", action="store_true")
logs_parser.add_argument("--lines", "-n", type=int, default=50)
args = parser.parse_args(remaining_args[1:]) # Skip 'daemon' itself
# Use globally extracted profile
args.profile = global_profile
logger = setup_logging(False)
config = get_config()
exit_code = do_daemon(args, config, logger)
sys.exit(exit_code)
# Handle UI subcommands
if command == "ui":
parser = argparse.ArgumentParser(prog="hindsight-embed ui")
subparsers = parser.add_subparsers(dest="ui_command")
start_parser = subparsers.add_parser("start", help="Start the UI")
start_parser.add_argument("--port", type=int, help="Port for the UI (default: daemon_port + 10000)")
start_parser.add_argument(
"--hostname", "-H", default="0.0.0.0", help="Hostname to bind to (default: 0.0.0.0)"
)
stop_parser = subparsers.add_parser("stop", help="Stop the UI")
stop_parser.add_argument("--port", type=int, help="Port the UI is running on")
status_parser = subparsers.add_parser("status", help="Check UI status")
status_parser.add_argument("--port", type=int, help="Port to check")
logs_parser = subparsers.add_parser("logs", help="View UI logs")
logs_parser.add_argument("--follow", "-f", action="store_true")
logs_parser.add_argument("--lines", "-n", type=int, default=50)
args = parser.parse_args(remaining_args[1:])
args.profile = global_profile
logger = setup_logging(False)
config = get_config()
exit_code = do_ui(args, config, logger)
sys.exit(exit_code)
# Handle control-center subcommands
if command == "control":
parser = argparse.ArgumentParser(prog="hindsight-embed control")
subparsers = parser.add_subparsers(dest="control_command")
start_parser = subparsers.add_parser("start", help="Start the control center web app")
start_parser.add_argument("--port", type=int, help="Port for the control center (default: 7878)")
start_parser.add_argument("--no-open", action="store_true", help="Don't open the browser automatically")
stop_parser = subparsers.add_parser("stop", help="Stop the control center")
stop_parser.add_argument("--port", type=int, help="Port the control center is running on")
status_parser = subparsers.add_parser("status", help="Check control center status")
status_parser.add_argument("--port", type=int, help="Port to check")
logs_parser = subparsers.add_parser("logs", help="View control center logs")
logs_parser.add_argument("--follow", "-f", action="store_true")
logs_parser.add_argument("--lines", "-n", type=int, default=50)
args = parser.parse_args(remaining_args[1:])
if not args.control_command:
parser.print_help()
sys.exit(1)
setup_logging(False)
sys.exit(do_control(args))
# Handle --help / -h
if command in ("--help", "-h"):
print_help()
sys.exit(0)
# Check for common mistakes - these are daemon subcommands, not top-level commands
if command in ("start", "stop", "status", "logs"):
print(f"error: '{command}' is not a direct command", file=sys.stderr)
print(f"\nDid you mean: hindsight-embed daemon {command}", file=sys.stderr)
if global_profile:
print(f" (with --profile {global_profile})", file=sys.stderr)
sys.exit(1)
# Forward all other commands to hindsight-cli
config = get_config()
from . import daemon_client
# Forward to hindsight-cli (handles daemon startup and CLI installation)
# Pass the globally extracted profile
# remaining_args already has --profile/-p filtered out
exit_code = daemon_client.run_cli(remaining_args, config, global_profile)
sys.exit(exit_code)
# No command - show help
print_help()
sys.exit(1)
def print_help():
"""Print help message."""
print("""Hindsight Embedded CLI - local memory operations with automatic daemon management.
Usage: hindsight-embed [-p PROFILE] <command> [options]
Profile management:
profile create NAME --port PORT [--env KEY=VALUE ...] Create a new profile
profile set-env NAME KEY VALUE Set/update environment variable
profile remove-env NAME KEY Remove environment variable
profile list [-o json] List all profiles
profile show [-o json] Show current active profile
profile set-active NAME Set active profile
profile delete NAME Delete a profile
Daemon management:
daemon start Start the background daemon
daemon stop Stop the daemon
daemon status Check daemon status
daemon logs [-f] [-n] View daemon logs
UI (control plane):
ui start [--port PORT] [--hostname HOST] Start the web UI (default port: daemon_port + 10000)
ui stop [--port PORT] Stop the web UI
ui status [--port PORT] Check UI status
ui logs [-f] [-n] View UI logs
Control center (config wizard + daemon supervisor):
control start [--port PORT] [--no-open] Start the local control center (default port: 7878)
control stop [--port PORT] Stop the control center
control status [--port PORT] Check control center status
control logs [-f] [-n] View control center logs
CLI commands (forwarded to hindsight-cli):
memory retain <bank> <content> Store a memory
memory recall <bank> <query> Search memories
memory reflect <bank> <query> Generate contextual answer
bank list List memory banks
... Run 'hindsight --help' for all commands
Global options:
-p, --profile PROFILE Profile to use for commands
Examples:
# Create a profile
hindsight-embed profile create my-app --port 9100 --env HINDSIGHT_API_LLM_PROVIDER=openai
# Manage environment variables
hindsight-embed profile set-env my-app HINDSIGHT_API_LLM_MODEL gpt-4
hindsight-embed profile remove-env my-app HINDSIGHT_API_LLM_MODEL
# Use profile with commands
hindsight-embed -p my-app daemon start
hindsight-embed -p my-app memory retain default "User prefers dark mode"
hindsight-embed --profile my-app bank list
Note: 'configure' command is deprecated, use 'profile create' instead.
""")
if __name__ == "__main__":
main()