Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdef0d80f4 | ||
|
|
7db1eb1b3a | ||
|
|
c96ae58c28 | ||
|
|
d349376df7 | ||
|
|
b403b94795 | ||
|
|
fe5d3a999b | ||
|
|
6168a77846 | ||
|
|
da44a5e839 | ||
|
|
32bca12c6f | ||
|
|
26850a0156 | ||
|
|
2a0c490c9e |
@@ -42,6 +42,10 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-embed
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv build --out-dir dist
|
||||
|
||||
# Publish in order (client and api first, then hindsight-all which depends on them)
|
||||
- name: Publish hindsight-client to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -67,6 +71,12 @@ jobs:
|
||||
packages-dir: ./hindsight-integrations/litellm/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-embed to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-embed/dist
|
||||
skip-existing: true
|
||||
|
||||
# Upload artifacts for GitHub release
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -77,6 +87,7 @@ jobs:
|
||||
hindsight-api/dist/*
|
||||
hindsight/dist/*
|
||||
hindsight-integrations/litellm/dist/*
|
||||
hindsight-embed/dist/*
|
||||
retention-days: 1
|
||||
|
||||
release-typescript-client:
|
||||
@@ -416,6 +427,7 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
path: hindsight-api
|
||||
- name: hindsight-client
|
||||
path: hindsight-clients/python
|
||||
- name: hindsight-embed
|
||||
path: hindsight-embed
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -563,6 +565,46 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-embed:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_EMBED_LLM_PROVIDER: groq
|
||||
HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv sync --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-embed-${{ hashFiles('hindsight-embed/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-embed-
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Run smoke test
|
||||
working-directory: ./hindsight-embed
|
||||
run: ./test.sh
|
||||
|
||||
test-doc-examples:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-rust-cli
|
||||
|
||||
@@ -14,6 +14,7 @@ This document captures architectural decisions and coding conventions for the Hi
|
||||
hindsight/ # Python package for embedded usage
|
||||
hindsight-api/ # FastAPI server (core memory engine)
|
||||
hindsight-cli/ # Rust CLI client
|
||||
hindsight-embed/ # Embedded CLI (no server needed)
|
||||
hindsight-control-plane/ # Next.js admin UI
|
||||
hindsight-docs/ # Docusaurus documentation site
|
||||
hindsight-dev/ # Development tools and benchmarks
|
||||
@@ -148,4 +149,5 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved
|
||||
|
||||
# Branding
|
||||
## Colors
|
||||
- Primary: gradient from #0074d9 to #009296
|
||||
- Primary: gradient from #0074d9 to #009296
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.1.11
|
||||
appVersion: "0.1.11"
|
||||
version: 0.1.12
|
||||
appVersion: "0.1.12"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -1802,9 +1802,11 @@ def _register_routes(app: FastAPI):
|
||||
input_summary = []
|
||||
for i, item in enumerate(request.items):
|
||||
content_preview = item.content[:100] + "..." if len(item.content) > 100 else item.content
|
||||
input_summary.append(f" [{i}] content={content_preview!r}, context={item.context}, timestamp={item.timestamp}")
|
||||
input_summary.append(
|
||||
f" [{i}] content={content_preview!r}, context={item.context}, timestamp={item.timestamp}"
|
||||
)
|
||||
input_debug = "\n".join(input_summary)
|
||||
|
||||
|
||||
error_detail = (
|
||||
f"{str(e)}\n\n"
|
||||
f"Input ({len(request.items)} items):\n{input_debug}\n\n"
|
||||
|
||||
@@ -33,6 +33,10 @@ ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
|
||||
# Default values
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
@@ -107,6 +111,10 @@ class HindsightConfig:
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
lazy_reranker: bool
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "HindsightConfig":
|
||||
"""Create configuration from environment variables."""
|
||||
@@ -133,6 +141,9 @@ class HindsightConfig:
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
# Optimization flags
|
||||
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
|
||||
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
|
||||
)
|
||||
|
||||
def get_llm_base_url(self) -> str:
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Daemon mode support for Hindsight API.
|
||||
|
||||
Provides idle timeout and lockfile management for running as a background daemon.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default daemon configuration
|
||||
DEFAULT_DAEMON_PORT = 8889
|
||||
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
|
||||
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
|
||||
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
|
||||
|
||||
|
||||
class IdleTimeoutMiddleware:
|
||||
"""ASGI middleware that tracks activity and exits after idle timeout."""
|
||||
|
||||
def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT):
|
||||
self.app = app
|
||||
self.idle_timeout = idle_timeout
|
||||
self.last_activity = time.time()
|
||||
self._checker_task = None
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
# Update activity timestamp on each request
|
||||
self.last_activity = time.time()
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def start_idle_checker(self):
|
||||
"""Start the background task that checks for idle timeout."""
|
||||
self._checker_task = asyncio.create_task(self._check_idle())
|
||||
|
||||
async def _check_idle(self):
|
||||
"""Background task that exits the process after idle timeout."""
|
||||
# If idle_timeout is 0, don't auto-exit
|
||||
if self.idle_timeout <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
idle_time = time.time() - self.last_activity
|
||||
if idle_time > self.idle_timeout:
|
||||
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
|
||||
# Give a moment for any in-flight requests
|
||||
await asyncio.sleep(1)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
class DaemonLock:
|
||||
"""
|
||||
File-based lock to prevent multiple daemon instances.
|
||||
|
||||
Uses fcntl.flock for atomic locking on Unix systems.
|
||||
"""
|
||||
|
||||
def __init__(self, lockfile: Path = LOCKFILE_PATH):
|
||||
self.lockfile = lockfile
|
||||
self._fd = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""
|
||||
Try to acquire the daemon lock.
|
||||
|
||||
Returns True if lock acquired, False if another daemon is running.
|
||||
"""
|
||||
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
self._fd = open(self.lockfile, "w")
|
||||
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# Write PID for debugging
|
||||
self._fd.write(str(os.getpid()))
|
||||
self._fd.flush()
|
||||
return True
|
||||
except (IOError, OSError):
|
||||
# Lock is held by another process
|
||||
if self._fd:
|
||||
self._fd.close()
|
||||
self._fd = None
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
"""Release the daemon lock."""
|
||||
if self._fd:
|
||||
try:
|
||||
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
|
||||
self._fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._fd = None
|
||||
# Remove lockfile
|
||||
try:
|
||||
self.lockfile.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""Check if the lock is held by another process."""
|
||||
if not self.lockfile.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
fd = open(self.lockfile, "r")
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# We got the lock, so no one else has it
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
|
||||
fd.close()
|
||||
return False
|
||||
except (IOError, OSError):
|
||||
return True
|
||||
|
||||
def get_pid(self) -> int | None:
|
||||
"""Get the PID of the daemon holding the lock."""
|
||||
if not self.lockfile.exists():
|
||||
return None
|
||||
try:
|
||||
with open(self.lockfile, "r") as f:
|
||||
return int(f.read().strip())
|
||||
except (ValueError, IOError):
|
||||
return None
|
||||
|
||||
|
||||
def daemonize():
|
||||
"""
|
||||
Fork the current process into a background daemon.
|
||||
|
||||
Uses double-fork technique to properly detach from terminal.
|
||||
"""
|
||||
# First fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
|
||||
# Create new session
|
||||
os.setsid()
|
||||
|
||||
# Second fork to prevent zombie processes
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# Redirect standard file descriptors to log file
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
# Redirect stdin to /dev/null
|
||||
with open("/dev/null", "r") as devnull:
|
||||
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||
|
||||
# Redirect stdout/stderr to log file
|
||||
log_fd = open(DAEMON_LOG_PATH, "a")
|
||||
os.dup2(log_fd.fileno(), sys.stdout.fileno())
|
||||
os.dup2(log_fd.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
"""Check if a daemon is running and responsive on the given port."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex(("127.0.0.1", port))
|
||||
sock.close()
|
||||
return result == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
"""Stop a running daemon by sending SIGTERM to the process."""
|
||||
lock = DaemonLock()
|
||||
pid = lock.get_pid()
|
||||
|
||||
if pid is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
import signal
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
# Wait for process to exit
|
||||
for _ in range(50): # Wait up to 5 seconds
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
except OSError:
|
||||
return True # Process exited
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
@@ -3,11 +3,13 @@ LLM wrapper for unified configuration across providers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from google import genai
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
@@ -157,7 +159,6 @@ class LLMProvider:
|
||||
"""
|
||||
async with _global_llm_semaphore:
|
||||
start_time = time.time()
|
||||
import json
|
||||
|
||||
# Handle Gemini provider separately
|
||||
if self.provider == "gemini":
|
||||
@@ -165,6 +166,20 @@ class LLMProvider:
|
||||
messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time
|
||||
)
|
||||
|
||||
# Handle Ollama with native API for structured output (better schema enforcement)
|
||||
if self.provider == "ollama" and response_format is not None:
|
||||
return await self._call_ollama_native(
|
||||
messages,
|
||||
response_format,
|
||||
max_completion_tokens,
|
||||
temperature,
|
||||
max_retries,
|
||||
initial_backoff,
|
||||
max_backoff,
|
||||
skip_validation,
|
||||
start_time,
|
||||
)
|
||||
|
||||
call_params = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
@@ -227,7 +242,7 @@ class LLMProvider:
|
||||
response = await self._client.chat.completions.create(**call_params)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
@@ -324,6 +339,129 @@ class LLMProvider:
|
||||
raise last_exception
|
||||
raise RuntimeError("LLM call failed after all retries with no exception captured")
|
||||
|
||||
async def _call_ollama_native(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any,
|
||||
max_completion_tokens: int | None,
|
||||
temperature: float | None,
|
||||
max_retries: int,
|
||||
initial_backoff: float,
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
) -> Any:
|
||||
"""
|
||||
Call Ollama using native API with JSON schema enforcement.
|
||||
|
||||
Ollama's native API supports passing a full JSON schema in the 'format' parameter,
|
||||
which provides better structured output control than the OpenAI-compatible API.
|
||||
"""
|
||||
# Get the JSON schema from the Pydantic model
|
||||
schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None
|
||||
|
||||
# Build the base URL for Ollama's native API
|
||||
# Default OpenAI-compatible URL is http://localhost:11434/v1
|
||||
# Native API is at http://localhost:11434/api/chat
|
||||
base_url = self.base_url or "http://localhost:11434/v1"
|
||||
if base_url.endswith("/v1"):
|
||||
native_url = base_url[:-3] + "/api/chat"
|
||||
else:
|
||||
native_url = base_url.rstrip("/") + "/api/chat"
|
||||
|
||||
# Build request payload
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
# Add schema as format parameter for structured output
|
||||
if schema:
|
||||
payload["format"] = schema
|
||||
|
||||
# Add optional parameters with optimized defaults for Ollama
|
||||
# Benchmarking shows num_ctx=16384 + num_batch=512 is optimal
|
||||
options = {
|
||||
"num_ctx": 16384, # 16k context window for larger prompts
|
||||
"num_batch": 512, # Optimal batch size for prompt processing
|
||||
}
|
||||
if max_completion_tokens:
|
||||
options["num_predict"] = max_completion_tokens
|
||||
if temperature is not None:
|
||||
options["temperature"] = temperature
|
||||
payload["options"] = options
|
||||
|
||||
last_exception = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await client.post(native_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
# Validate against Pydantic model or return raw JSON
|
||||
if skip_validation:
|
||||
return json_data
|
||||
else:
|
||||
return response_format.model_validate(json_data)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}"
|
||||
)
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
except httpx.RequestError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("Ollama call failed after all retries")
|
||||
|
||||
async def _call_gemini(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
@@ -335,8 +473,6 @@ class LLMProvider:
|
||||
start_time: float,
|
||||
) -> Any:
|
||||
"""Handle Gemini-specific API calls."""
|
||||
import json
|
||||
|
||||
# Convert OpenAI-style messages to Gemini format
|
||||
system_instruction = None
|
||||
gemini_contents = []
|
||||
|
||||
@@ -202,6 +202,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
run_migrations: bool = True,
|
||||
operation_validator: "OperationValidatorExtension | None" = None,
|
||||
tenant_extension: "TenantExtension | None" = None,
|
||||
skip_llm_verification: bool | None = None,
|
||||
lazy_reranker: bool | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the temporal + semantic memory system.
|
||||
@@ -227,17 +229,29 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
If provided, retain/recall/reflect operations will be validated.
|
||||
tenant_extension: Optional extension for multi-tenancy and API key authentication.
|
||||
If provided, operations require a RequestContext for authentication.
|
||||
skip_llm_verification: Skip LLM connection verification during initialization.
|
||||
Defaults to HINDSIGHT_API_SKIP_LLM_VERIFICATION env var or False.
|
||||
lazy_reranker: Delay reranker initialization until first use. Useful for retain-only
|
||||
operations that don't need the cross-encoder. Defaults to
|
||||
HINDSIGHT_API_LAZY_RERANKER env var or False.
|
||||
"""
|
||||
# Load config from environment for any missing parameters
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Apply optimization flags from config if not explicitly provided
|
||||
self._skip_llm_verification = (
|
||||
skip_llm_verification if skip_llm_verification is not None else config.skip_llm_verification
|
||||
)
|
||||
self._lazy_reranker = lazy_reranker if lazy_reranker is not None else config.lazy_reranker
|
||||
|
||||
# Apply defaults from config
|
||||
db_url = db_url or config.database_url
|
||||
memory_llm_provider = memory_llm_provider or config.llm_provider
|
||||
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
|
||||
if not memory_llm_api_key:
|
||||
# Ollama doesn't require an API key
|
||||
if not memory_llm_api_key and memory_llm_provider != "ollama":
|
||||
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
|
||||
memory_llm_model = memory_llm_model or config.llm_model
|
||||
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
|
||||
@@ -593,6 +607,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
|
||||
else:
|
||||
await cross_encoder.initialize()
|
||||
# Mark reranker as initialized
|
||||
self._cross_encoder_reranker._initialized = True
|
||||
|
||||
async def init_query_analyzer():
|
||||
"""Initialize query analyzer model."""
|
||||
@@ -601,16 +617,26 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
async def verify_llm():
|
||||
"""Verify LLM connection is working."""
|
||||
await self._llm_config.verify_connection()
|
||||
if not self._skip_llm_verification:
|
||||
await self._llm_config.verify_connection()
|
||||
|
||||
# Run pg0 and all model initializations in parallel
|
||||
await asyncio.gather(
|
||||
# Build list of initialization tasks
|
||||
init_tasks = [
|
||||
start_pg0(),
|
||||
init_embeddings(),
|
||||
init_cross_encoder(),
|
||||
init_query_analyzer(),
|
||||
verify_llm(),
|
||||
)
|
||||
]
|
||||
|
||||
# Only init cross-encoder eagerly if not using lazy initialization
|
||||
if not self._lazy_reranker:
|
||||
init_tasks.append(init_cross_encoder())
|
||||
|
||||
# Only verify LLM if not skipping
|
||||
if not self._skip_llm_verification:
|
||||
init_tasks.append(verify_llm())
|
||||
|
||||
# Run pg0 and selected model initializations in parallel
|
||||
await asyncio.gather(*init_tasks)
|
||||
|
||||
# Run database migrations if enabled
|
||||
if self._run_migrations:
|
||||
@@ -1640,6 +1666,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
step_start = time.time()
|
||||
reranker_instance = self._cross_encoder_reranker
|
||||
|
||||
# Ensure reranker is initialized (for lazy initialization mode)
|
||||
await reranker_instance.ensure_initialized()
|
||||
|
||||
# Rerank using cross-encoder
|
||||
scored_results = reranker_instance.rerank(query, merged_candidates)
|
||||
|
||||
|
||||
@@ -26,6 +26,23 @@ class CrossEncoderReranker:
|
||||
|
||||
cross_encoder = create_cross_encoder_from_env()
|
||||
self.cross_encoder = cross_encoder
|
||||
self._initialized = False
|
||||
|
||||
async def ensure_initialized(self):
|
||||
"""Ensure the cross-encoder model is initialized (for lazy initialization)."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
import asyncio
|
||||
|
||||
cross_encoder = self.cross_encoder
|
||||
# For local providers, run in thread pool to avoid blocking event loop
|
||||
if cross_encoder.provider_name == "local":
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
|
||||
else:
|
||||
await cross_encoder.initialize()
|
||||
self._initialized = True
|
||||
|
||||
def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
|
||||
"""
|
||||
|
||||
@@ -89,6 +89,38 @@ class TaskBackend(ABC):
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class SyncTaskBackend(TaskBackend):
|
||||
"""
|
||||
Synchronous task backend that executes tasks immediately.
|
||||
|
||||
This is useful for embedded/CLI usage where we don't want background
|
||||
workers that prevent clean exit. Tasks are executed inline rather than
|
||||
being queued.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
"""No-op for sync backend."""
|
||||
self._initialized = True
|
||||
logger.debug("SyncTaskBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Execute the task immediately (synchronously).
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary to execute
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
await self._execute_task(task_dict)
|
||||
|
||||
async def shutdown(self):
|
||||
"""No-op for sync backend."""
|
||||
self._initialized = False
|
||||
logger.debug("SyncTaskBackend shutdown")
|
||||
|
||||
|
||||
class AsyncIOQueueBackend(TaskBackend):
|
||||
"""
|
||||
Task backend implementation using asyncio queues.
|
||||
|
||||
@@ -4,6 +4,9 @@ Command-line interface for Hindsight API.
|
||||
Run the server with:
|
||||
hindsight-api
|
||||
|
||||
Run as background daemon:
|
||||
hindsight-api --daemon
|
||||
|
||||
Stop with Ctrl+C.
|
||||
"""
|
||||
|
||||
@@ -21,9 +24,13 @@ from . import MemoryEngine
|
||||
from .api import create_app
|
||||
from .banner import print_banner
|
||||
from .config import HindsightConfig, get_config
|
||||
|
||||
print()
|
||||
print_banner()
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
DaemonLock,
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
|
||||
# Filter deprecation warnings from third-party libraries
|
||||
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
|
||||
@@ -106,8 +113,52 @@ def main():
|
||||
parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
|
||||
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
|
||||
|
||||
# Daemon mode options
|
||||
parser.add_argument(
|
||||
"--daemon",
|
||||
action="store_true",
|
||||
help=f"Run as background daemon (uses port {DEFAULT_DAEMON_PORT}, auto-exits after idle)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--idle-timeout",
|
||||
type=int,
|
||||
default=DEFAULT_IDLE_TIMEOUT,
|
||||
help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Daemon mode handling
|
||||
if args.daemon:
|
||||
# Use fixed daemon port
|
||||
args.port = DEFAULT_DAEMON_PORT
|
||||
args.host = "127.0.0.1" # Only bind to localhost for security
|
||||
|
||||
# Check if another daemon is already running
|
||||
daemon_lock = DaemonLock()
|
||||
if not daemon_lock.acquire():
|
||||
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Fork into background
|
||||
daemonize()
|
||||
|
||||
# Re-acquire lock in child process
|
||||
daemon_lock = DaemonLock()
|
||||
if not daemon_lock.acquire():
|
||||
sys.exit(1)
|
||||
|
||||
# Register cleanup to release lock
|
||||
def release_lock():
|
||||
daemon_lock.release()
|
||||
|
||||
atexit.register(release_lock)
|
||||
|
||||
# Print banner (not in daemon mode)
|
||||
if not args.daemon:
|
||||
print()
|
||||
print_banner()
|
||||
|
||||
# Configure Python logging based on log level
|
||||
# Update config with CLI override if provided
|
||||
if args.log_level != config.log_level:
|
||||
@@ -128,9 +179,12 @@ def main():
|
||||
log_level=args.log_level,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
graph_retriever=config.graph_retriever,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
)
|
||||
config.configure_logging()
|
||||
config.log_config()
|
||||
if not args.daemon:
|
||||
config.log_config()
|
||||
|
||||
# Register cleanup handlers
|
||||
atexit.register(_cleanup)
|
||||
@@ -149,6 +203,12 @@ def main():
|
||||
initialize_memory=True,
|
||||
)
|
||||
|
||||
# Wrap with idle timeout middleware in daemon mode
|
||||
idle_middleware = None
|
||||
if args.daemon:
|
||||
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
|
||||
app = idle_middleware
|
||||
|
||||
# Prepare uvicorn config
|
||||
uvicorn_config = {
|
||||
"app": app,
|
||||
@@ -172,18 +232,38 @@ def main():
|
||||
if args.ssl_certfile:
|
||||
uvicorn_config["ssl_certfile"] = args.ssl_certfile
|
||||
|
||||
from .banner import print_startup_info
|
||||
# Print startup info (not in daemon mode)
|
||||
if not args.daemon:
|
||||
from .banner import print_startup_info
|
||||
|
||||
print_startup_info(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
database_url=config.database_url,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_model=config.llm_model,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
reranker_provider=config.reranker_provider,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
)
|
||||
print_startup_info(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
database_url=config.database_url,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_model=config.llm_model,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
reranker_provider=config.reranker_provider,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
)
|
||||
|
||||
# Start idle checker in daemon mode
|
||||
if idle_middleware is not None:
|
||||
# Start the idle checker in a background thread with its own event loop
|
||||
import threading
|
||||
|
||||
def run_idle_checker():
|
||||
import time
|
||||
|
||||
time.sleep(2) # Wait for uvicorn to start
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(idle_middleware._check_idle())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=run_idle_checker, daemon=True).start()
|
||||
|
||||
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -26,6 +26,9 @@ MODEL_MATRIX = [
|
||||
("gemini", "gemini-2.5-flash"),
|
||||
("gemini", "gemini-2.5-flash-lite"),
|
||||
("gemini", "gemini-3-pro-preview"),
|
||||
# Ollama models (local)
|
||||
("ollama", "gemma3:12b"),
|
||||
("ollama", "gemma3:1b"),
|
||||
]
|
||||
|
||||
|
||||
@@ -48,12 +51,18 @@ async def test_llm_provider_memory_operations(provider: str, model: str):
|
||||
All models must pass this test.
|
||||
"""
|
||||
api_key = get_api_key_for_provider(provider)
|
||||
if not api_key:
|
||||
|
||||
# Skip Ollama tests in CI (no models available)
|
||||
if provider == "ollama" and os.getenv("CI"):
|
||||
pytest.skip(f"Skipping {provider}/{model}: Ollama not available in CI")
|
||||
|
||||
# Other providers need an API key
|
||||
if provider != "ollama" and not api_key:
|
||||
pytest.skip(f"Skipping {provider}/{model}: no API key available")
|
||||
|
||||
llm = LLMProvider(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
api_key=api_key or "",
|
||||
base_url="",
|
||||
model=model,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -85,9 +85,21 @@ class Hindsight:
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Close the API client."""
|
||||
"""Close the API client (sync version - use aclose() in async code)."""
|
||||
if self._api_client:
|
||||
_run_async(self._api_client.close())
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context - schedule but don't wait
|
||||
# The caller should use aclose() instead
|
||||
loop.create_task(self._api_client.close())
|
||||
except RuntimeError:
|
||||
# No running loop - safe to run synchronously
|
||||
_run_async(self._api_client.close())
|
||||
|
||||
async def aclose(self):
|
||||
"""Close the API client (async version)."""
|
||||
if self._api_client:
|
||||
await self._api_client.close()
|
||||
|
||||
# Simplified methods for main operations
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hindsight-client"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "Python client for Hindsight - Semantic memory system with personality-driven thinking"
|
||||
authors = [
|
||||
{name = "Hindsight Team"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking",
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-control-plane",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"description": "Control plane for Hindsight - Semantic memory system",
|
||||
"bin": {
|
||||
"hindsight-control-plane": "./bin/cli.js"
|
||||
|
||||
@@ -28,6 +28,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pydantic
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
# Configure logging from environment variable
|
||||
get_config().configure_logging()
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.models import RequestContext
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-dev"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "Development utilities for Hindsight"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# Extensions
|
||||
|
||||
Extensions allow you to customize and extend Hindsight behavior without modifying core code. They enable multi-tenancy, custom authentication, additional HTTP endpoints, and operation hooks.
|
||||
|
||||
---
|
||||
|
||||
## Available Extensions
|
||||
|
||||
### TenantExtension
|
||||
|
||||
Handles multi-tenancy and API key authentication. Validates incoming requests and determines which PostgreSQL schema to use for database operations, enabling tenant isolation at the database level.
|
||||
|
||||
**Built-in: ApiKeyTenantExtension**
|
||||
|
||||
A simple implementation that validates API keys against an environment variable and uses the `public` schema for all authenticated requests.
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
```
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant (e.g., JWT-based auth with per-tenant schemas), implement a custom `TenantExtension`.
|
||||
|
||||
---
|
||||
|
||||
### HttpExtension
|
||||
|
||||
Adds custom HTTP endpoints under the `/ext/` path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine.
|
||||
|
||||
**No built-in implementation** - implement your own to add custom endpoints.
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OperationValidatorExtension
|
||||
|
||||
Hooks into retain/recall/reflect operations for validation and monitoring. Use cases include:
|
||||
- Rate limiting and quota enforcement
|
||||
- Permission checks and content filtering
|
||||
- Audit logging and usage tracking
|
||||
- Custom metrics collection
|
||||
|
||||
**No built-in implementation** - implement your own based on your requirements.
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Writing Custom Extensions
|
||||
|
||||
### Extension Basics
|
||||
|
||||
Extensions are Python classes loaded via environment variables:
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_<TYPE>_EXTENSION=mypackage.module:MyExtensionClass
|
||||
```
|
||||
|
||||
Configuration is passed via prefixed environment variables:
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_<TYPE>_SOME_CONFIG=value
|
||||
# Extension receives: {"some_config": "value"}
|
||||
```
|
||||
|
||||
All extensions support lifecycle hooks:
|
||||
- `on_startup()` - Called when the application starts
|
||||
- `on_shutdown()` - Called when the application shuts down
|
||||
|
||||
Extensions have access to an `ExtensionContext` that provides:
|
||||
- `run_migration(schema)` - Run database migrations for a schema
|
||||
- `get_memory_engine()` - Get the MemoryEngine interface
|
||||
|
||||
### Example: Custom TenantExtension with JWT
|
||||
|
||||
```python
|
||||
import jwt
|
||||
from hindsight_api.extensions import TenantExtension, TenantContext, AuthenticationError
|
||||
|
||||
class JwtTenantExtension(TenantExtension):
|
||||
def __init__(self, config: dict[str, str]):
|
||||
super().__init__(config)
|
||||
self.jwt_secret = config.get("jwt_secret")
|
||||
if not self.jwt_secret:
|
||||
raise ValueError("HINDSIGHT_API_TENANT_JWT_SECRET is required")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
token = context.api_key
|
||||
if not token:
|
||||
raise AuthenticationError("Bearer token required")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
tenant_id = payload.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise AuthenticationError("Missing tenant_id in token")
|
||||
return TenantContext(schema_name=f"tenant_{tenant_id}")
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AuthenticationError(str(e))
|
||||
```
|
||||
|
||||
### Example: Custom HttpExtension
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from hindsight_api.extensions import HttpExtension
|
||||
|
||||
class MyHttpExtension(HttpExtension):
|
||||
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/hello")
|
||||
async def hello():
|
||||
return {"message": "Hello from extension!"}
|
||||
|
||||
@router.post("/custom/{bank_id}/action")
|
||||
async def custom_action(bank_id: str):
|
||||
# Access memory engine for database operations
|
||||
pool = await memory._get_pool()
|
||||
# ... custom logic
|
||||
return {"status": "ok"}
|
||||
|
||||
return router
|
||||
```
|
||||
|
||||
Routes are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
|
||||
|
||||
### Example: Custom OperationValidatorExtension
|
||||
|
||||
```python
|
||||
from hindsight_api.extensions import (
|
||||
OperationValidatorExtension,
|
||||
ValidationResult,
|
||||
RetainContext,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RetainResult,
|
||||
)
|
||||
|
||||
class MyValidator(OperationValidatorExtension):
|
||||
# Pre-operation validation (required)
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
# Implement your validation logic
|
||||
return ValidationResult.accept()
|
||||
# Or reject: return ValidationResult.reject("Reason")
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
# Post-operation hooks (optional)
|
||||
async def on_retain_complete(self, result: RetainResult) -> None:
|
||||
# Log usage, update metrics, send notifications, etc.
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying Custom Extensions
|
||||
|
||||
### With Docker
|
||||
|
||||
Mount your extension package as a volume and set the environment variable:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
hindsight-api:
|
||||
image: vectorize/hindsight-api:latest
|
||||
volumes:
|
||||
- ./my_extensions:/app/my_extensions
|
||||
environment:
|
||||
- HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
|
||||
- HINDSIGHT_API_TENANT_JWT_SECRET=${JWT_SECRET}
|
||||
- PYTHONPATH=/app
|
||||
```
|
||||
|
||||
Or build a custom image with your extensions:
|
||||
|
||||
```dockerfile
|
||||
FROM vectorize/hindsight-api:latest
|
||||
COPY my_extensions /app/my_extensions
|
||||
ENV PYTHONPATH=/app
|
||||
```
|
||||
|
||||
### Bare Metal
|
||||
|
||||
Install your extension package in the same Python environment as Hindsight:
|
||||
|
||||
```bash
|
||||
# Install Hindsight
|
||||
pip install hindsight-api
|
||||
|
||||
# Install your extension package
|
||||
pip install ./my-extensions
|
||||
# or
|
||||
pip install my-extensions-package
|
||||
|
||||
# Configure
|
||||
export HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
|
||||
export HINDSIGHT_API_TENANT_JWT_SECRET=your-secret
|
||||
|
||||
# Run
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing Extensions
|
||||
|
||||
Custom extensions that solve common use cases are welcome contributions to the Hindsight project. If you've built an extension for:
|
||||
|
||||
- Authentication providers (OAuth, SAML, API gateways)
|
||||
- Rate limiting or quota management
|
||||
- Audit logging integrations
|
||||
- Metrics exporters (Datadog, New Relic, etc.)
|
||||
- Custom HTTP endpoints for specific platforms
|
||||
|
||||
Consider contributing it to the `hindsight_api.extensions.builtin` package. Open an issue or pull request on [GitHub](https://github.com/vectorize-io/hindsight) to discuss your extension.
|
||||
@@ -111,6 +111,11 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'developer/configuration',
|
||||
label: 'Configuration',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/extensions',
|
||||
label: 'Extensions',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/models',
|
||||
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Install Hindsight Agent Skill
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://hindsight.vectorize.io/get-skill | bash
|
||||
#
|
||||
# Options:
|
||||
# --app <app> Target app: claude, opencode, codex
|
||||
#
|
||||
# Examples:
|
||||
# curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude
|
||||
# curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app opencode
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}▸ $1${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
echo ""
|
||||
# ANSI logo
|
||||
echo -e " \033[38;2;9;127;184m▄\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m▄\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m▄\033[0m\033[38;2;7;140;156m▄\033[0m "
|
||||
echo -e " \033[38;2;8;125;192m▄\033[0m \033[38;2;3;132;191m▀\033[0m\033[38;2;2;133;192m▄\033[0m \033[38;2;3;132;180m▄\033[0m\033[38;2;1;137;184m▄\033[0m\033[38;2;3;133;174m▄\033[0m \033[38;2;3;142;176m▄\033[0m\033[38;2;4;142;169m▀\033[0m \033[38;2;10;144;164m▄\033[0m "
|
||||
echo -e "\033[38;2;6;121;195m▀\033[0m\033[38;2;5;128;203m▀\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m▄\033[0m\033[38;2;2;126;196m▄\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m▄\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m▄\033[0m\033[38;2;1;141;196m▀\033[0m\033[38;2;1;135;183m▀\033[0m\033[38;2;1;148;198m▀\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m▄\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m▄\033[0m\033[38;2;3;138;173m▄\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m▄\033[0m\033[38;2;7;144;169m▀\033[0m\033[38;2;7;139;158m▀\033[0m"
|
||||
echo -e " \033[48;2;2;128;202m\033[38;2;2;124;201m▄\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m▄\033[0m\033[38;2;2;128;196m▄\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m▄\033[0m \033[38;2;1;135;186m▄\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m▄\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m▄\033[0m "
|
||||
echo -e " \033[48;2;8;118;200m\033[38;2;8;121;209m▄\033[0m\033[38;2;3;121;203m▀\033[0m \033[38;2;3;122;192m▀\033[0m\033[38;2;1;138;216m▀\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m▄\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m▄\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m▄\033[0m\033[38;2;1;140;196m▀\033[0m \033[38;2;4;134;175m▀\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m▄\033[0m "
|
||||
echo ""
|
||||
echo -e " ${BOLD}HINDSIGHT SKILL INSTALLER${NC}"
|
||||
echo -e " ${DIM}Give your AI agent persistent memory${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Embedded SKILL.md content
|
||||
SKILL_CONTENT='---
|
||||
name: hindsight
|
||||
description: Give your agent persistent memory that works like human memory. Store facts, preferences, and context that persist across sessions.
|
||||
---
|
||||
|
||||
# Hindsight Memory Skill
|
||||
|
||||
You have access to persistent memory via the `hindsight-embed` CLI. Use it to remember important information about the user and recall it when relevant.
|
||||
|
||||
## Setup (first time only)
|
||||
|
||||
Run: `uvx hindsight-embed configure`
|
||||
|
||||
This will configure your LLM provider and start a local daemon that manages your memory bank.
|
||||
|
||||
## Commands
|
||||
|
||||
The CLI uses a bank ID to organize memories. Use `default` for general memories or create project-specific banks.
|
||||
|
||||
### Store a memory
|
||||
|
||||
Use `memory retain` to store important facts, preferences, decisions, or context:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory retain default "User prefers dark mode for all UIs"
|
||||
uvx hindsight-embed memory retain default "Project uses Python 3.11 with FastAPI" --context work
|
||||
uvx hindsight-embed memory retain myproject "API uses JWT authentication"
|
||||
```
|
||||
|
||||
### Recall memories
|
||||
|
||||
Use `memory recall` to search for relevant memories before starting tasks:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory recall default "What are the user'"'"'s UI preferences?"
|
||||
uvx hindsight-embed memory recall default "What tech stack does this project use?"
|
||||
```
|
||||
|
||||
### Reflect on memories
|
||||
|
||||
Use `memory reflect` for contextual answers that synthesize multiple memories:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory reflect default "How should I set up the dev environment?"
|
||||
```
|
||||
|
||||
### Other commands
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed bank list # List all memory banks
|
||||
uvx hindsight-embed daemon status # Check daemon status
|
||||
uvx hindsight-embed --help # Full CLI help
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
### Store memories when you learn:
|
||||
- User preferences (coding style, tools, UI preferences)
|
||||
- Project context (tech stack, architecture decisions)
|
||||
- Personal information the user shares (name, role, company)
|
||||
- Important decisions or outcomes
|
||||
|
||||
### Recall memories when:
|
||||
- Starting a new task (get relevant context first)
|
||||
- Making decisions that should consider user preferences
|
||||
- Working on a project where past context would help
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Be specific**: Store "User prefers 2-space indentation" not "User has preferences"
|
||||
2. **Recall first**: Before starting tasks, recall relevant context
|
||||
3. **Use context tags**: Organize with `--context` (work, personal, preferences)
|
||||
4. **Use project banks**: Create separate banks for different projects
|
||||
'
|
||||
|
||||
# Get skills directory for app (bash 3.x compatible)
|
||||
get_skills_dir() {
|
||||
case "$1" in
|
||||
claude) echo "$HOME/.claude/skills" ;;
|
||||
opencode) echo "$HOME/.opencode/skills" ;;
|
||||
codex) echo "$HOME/.codex/skills" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Get app display name (bash 3.x compatible)
|
||||
get_app_name() {
|
||||
case "$1" in
|
||||
claude) echo "Claude Code" ;;
|
||||
opencode) echo "OpenCode" ;;
|
||||
codex) echo "Codex CLI" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
APP=""
|
||||
|
||||
show_usage() {
|
||||
echo "Usage: $0 [--app <app>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --app <app> Target app: claude, opencode, codex"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --app claude"
|
||||
echo " $0 --app opencode"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--app)
|
||||
APP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Show banner
|
||||
print_banner
|
||||
|
||||
# Validate app parameter
|
||||
if [ -z "$APP" ]; then
|
||||
echo -e "${DIM}Select your AI coding assistant:${NC}"
|
||||
echo ""
|
||||
echo " ${BOLD}1)${NC} Claude Code"
|
||||
echo " ${BOLD}2)${NC} OpenCode"
|
||||
echo " ${BOLD}3)${NC} Codex CLI"
|
||||
echo ""
|
||||
read -p "Enter choice [1]: " app_choice
|
||||
app_choice=${app_choice:-1}
|
||||
|
||||
case $app_choice in
|
||||
1) APP="claude" ;;
|
||||
2) APP="opencode" ;;
|
||||
3) APP="codex" ;;
|
||||
*) APP="claude" ;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Get skills directory for selected app
|
||||
SKILLS_DIR=$(get_skills_dir "$APP")
|
||||
APP_NAME=$(get_app_name "$APP")
|
||||
if [ -z "$SKILLS_DIR" ]; then
|
||||
print_error "Unknown app '$APP'. Supported: claude, opencode, codex"
|
||||
fi
|
||||
|
||||
print_info "Installing for ${BOLD}$APP_NAME${NC}"
|
||||
|
||||
# Step 1: Check for Python/uvx
|
||||
print_step "Checking prerequisites"
|
||||
|
||||
if ! command -v python3 &> /dev/null && ! command -v uvx &> /dev/null; then
|
||||
print_error "Python 3 or uvx is required.\nInstall from https://python.org or https://docs.astral.sh/uv/"
|
||||
fi
|
||||
print_success "Python/uvx available"
|
||||
|
||||
# Step 2: Configure LLM provider using the CLI
|
||||
print_step "Configuring LLM provider"
|
||||
|
||||
# Install/run hindsight-embed configure
|
||||
if command -v uvx &> /dev/null; then
|
||||
uvx hindsight-embed configure
|
||||
else
|
||||
pip install -q hindsight-embed
|
||||
hindsight-embed configure
|
||||
fi
|
||||
|
||||
# Step 3: Install skill to app's skills directory
|
||||
print_step "Installing skill to $APP_NAME"
|
||||
|
||||
mkdir -p "$SKILLS_DIR/hindsight"
|
||||
|
||||
# Write embedded SKILL.md content
|
||||
echo "$SKILL_CONTENT" > "$SKILLS_DIR/hindsight/SKILL.md"
|
||||
print_success "Installed to $SKILLS_DIR/hindsight/"
|
||||
|
||||
# Done!
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} ✓ Installation Complete!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e " The Hindsight skill is now available in ${BOLD}$APP_NAME${NC}."
|
||||
echo ""
|
||||
echo -e " ${DIM}Test the CLI:${NC}"
|
||||
echo -e " ${CYAN}uvx hindsight-embed memory retain default \"Test memory\"${NC}"
|
||||
echo -e " ${CYAN}uvx hindsight-embed memory recall default \"test\"${NC}"
|
||||
echo ""
|
||||
echo -e " ${DIM}$APP_NAME will automatically use the skill when relevant.${NC}"
|
||||
echo ""
|
||||
echo -e " ${DIM}Documentation:${NC} ${BLUE}https://hindsight.vectorize.io${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,167 @@
|
||||
# hindsight-embed
|
||||
|
||||
Hindsight embedded CLI - local memory operations with automatic daemon management.
|
||||
|
||||
This package provides a simple CLI for storing and recalling memories using Hindsight's memory engine. It automatically manages a background daemon for fast operations - no manual server setup required.
|
||||
|
||||
## How It Works
|
||||
|
||||
`hindsight-embed` uses a background daemon architecture for optimal performance:
|
||||
|
||||
1. **First command**: Automatically starts a local daemon (first run downloads dependencies and loads ML models - can take 1-3 minutes)
|
||||
2. **Subsequent commands**: Near-instant responses (~1-2s) since daemon is already running
|
||||
3. **Auto-shutdown**: Daemon automatically exits after 5 minutes of inactivity
|
||||
|
||||
The daemon runs on `localhost:8889` and uses an embedded PostgreSQL database (pg0) - everything stays local on your machine.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-embed
|
||||
# or with uvx (no install needed)
|
||||
uvx hindsight-embed --help
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Interactive setup (recommended)
|
||||
hindsight-embed configure
|
||||
|
||||
# Or set your LLM API key manually
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# Store a memory (bank_id = "default")
|
||||
hindsight-embed memory retain default "User prefers dark mode"
|
||||
|
||||
# Recall memories
|
||||
hindsight-embed memory recall default "What are user preferences?"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### configure
|
||||
|
||||
Interactive setup wizard:
|
||||
|
||||
```bash
|
||||
hindsight-embed configure
|
||||
```
|
||||
|
||||
This will:
|
||||
- Let you choose an LLM provider (OpenAI, Groq, Google, Ollama)
|
||||
- Configure your API key
|
||||
- Set the model and memory bank ID
|
||||
- Start the daemon with your configuration
|
||||
|
||||
### memory retain
|
||||
|
||||
Store a memory:
|
||||
|
||||
```bash
|
||||
hindsight-embed memory retain default "User prefers dark mode"
|
||||
hindsight-embed memory retain default "Meeting on Monday" --context work
|
||||
hindsight-embed memory retain myproject "API uses JWT authentication"
|
||||
```
|
||||
|
||||
### memory recall
|
||||
|
||||
Search memories:
|
||||
|
||||
```bash
|
||||
hindsight-embed memory recall default "user preferences"
|
||||
hindsight-embed memory recall default "upcoming events"
|
||||
```
|
||||
|
||||
Use `-o json` for JSON output:
|
||||
```bash
|
||||
hindsight-embed memory recall default "user preferences" -o json
|
||||
```
|
||||
|
||||
### memory reflect
|
||||
|
||||
Get contextual answers that synthesize multiple memories:
|
||||
|
||||
```bash
|
||||
hindsight-embed memory reflect default "How should I set up the dev environment?"
|
||||
```
|
||||
|
||||
### bank list
|
||||
|
||||
List all memory banks:
|
||||
|
||||
```bash
|
||||
hindsight-embed bank list
|
||||
```
|
||||
|
||||
### daemon
|
||||
|
||||
Manage the background daemon:
|
||||
|
||||
```bash
|
||||
hindsight-embed daemon status # Check if daemon is running
|
||||
hindsight-embed daemon start # Start the daemon
|
||||
hindsight-embed daemon stop # Stop the daemon
|
||||
hindsight-embed daemon logs # View last 50 lines of logs
|
||||
hindsight-embed daemon logs -f # Follow logs in real-time
|
||||
hindsight-embed daemon logs -n 100 # View last 100 lines
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
Run `hindsight-embed configure` for a guided setup that saves to `~/.hindsight/embed`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_EMBED_LLM_API_KEY` | LLM API key (or use `OPENAI_API_KEY`) | Required |
|
||||
| `HINDSIGHT_EMBED_LLM_PROVIDER` | LLM provider (`openai`, `groq`, `google`, `ollama`) | `openai` |
|
||||
| `HINDSIGHT_EMBED_LLM_MODEL` | LLM model | `gpt-4o-mini` |
|
||||
| `HINDSIGHT_EMBED_BANK_ID` | Memory bank ID | `default` |
|
||||
|
||||
### Files
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `~/.hindsight/embed` | Configuration file |
|
||||
| `~/.hindsight/config.env` | Alternative config file location |
|
||||
| `~/.hindsight/daemon.log` | Daemon logs |
|
||||
| `~/.hindsight/daemon.lock` | Daemon lock file (PID) |
|
||||
|
||||
## Use with AI Coding Assistants
|
||||
|
||||
This CLI is designed to work with AI coding assistants like Claude Code, Cursor, and Windsurf. Install the Hindsight skill:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
|
||||
```
|
||||
|
||||
This will configure the LLM provider and install the skill to your assistant's skills directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Daemon won't start:**
|
||||
```bash
|
||||
# Check logs for errors
|
||||
hindsight-embed daemon logs
|
||||
|
||||
# Stop any stuck daemon and restart
|
||||
hindsight-embed daemon stop
|
||||
hindsight-embed daemon start
|
||||
```
|
||||
|
||||
**Slow first command:**
|
||||
This is expected - the first command needs to download dependencies, start the daemon, and load ML models. First run can take 1-3 minutes depending on network speed. Subsequent commands will be fast (~1-2s).
|
||||
|
||||
**Change configuration:**
|
||||
```bash
|
||||
# Re-run configure (automatically restarts daemon)
|
||||
hindsight-embed configure
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Hindsight embedded CLI - local memory operations without a server."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
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_EMBED_LLM_API_KEY: Required. API key for LLM provider.
|
||||
HINDSIGHT_EMBED_LLM_PROVIDER: Optional. LLM provider (default: "openai").
|
||||
HINDSIGHT_EMBED_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini").
|
||||
HINDSIGHT_EMBED_BANK_ID: Optional. Memory bank ID (default: "default").
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path.home() / ".hindsight"
|
||||
CONFIG_FILE = CONFIG_DIR / "embed"
|
||||
CONFIG_FILE_ALT = CONFIG_DIR / "config.env" # Alternative config file location
|
||||
|
||||
|
||||
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 file if it exists."""
|
||||
# Check both config file locations
|
||||
config_files = [CONFIG_FILE, CONFIG_FILE_ALT]
|
||||
for config_path in config_files:
|
||||
if config_path.exists():
|
||||
with open(config_path) 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
|
||||
|
||||
|
||||
def get_config():
|
||||
"""Get configuration from environment variables."""
|
||||
load_config_file()
|
||||
return {
|
||||
"llm_api_key": os.environ.get("HINDSIGHT_EMBED_LLM_API_KEY")
|
||||
or os.environ.get("HINDSIGHT_API_LLM_API_KEY")
|
||||
or os.environ.get("OPENAI_API_KEY"),
|
||||
"llm_provider": os.environ.get("HINDSIGHT_EMBED_LLM_PROVIDER")
|
||||
or os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "openai"),
|
||||
"llm_model": os.environ.get("HINDSIGHT_EMBED_LLM_MODEL")
|
||||
or os.environ.get("HINDSIGHT_API_LLM_MODEL", "gpt-4o-mini"),
|
||||
"bank_id": os.environ.get("HINDSIGHT_EMBED_BANK_ID", "default"),
|
||||
}
|
||||
|
||||
|
||||
def do_configure(args):
|
||||
"""Interactive configuration setup with beautiful TUI."""
|
||||
import questionary
|
||||
from questionary import Style
|
||||
|
||||
# If stdin is not a terminal (e.g., running via curl | bash),
|
||||
# reopen stdin from /dev/tty for interactive prompts
|
||||
if not sys.stdin.isatty():
|
||||
try:
|
||||
sys.stdin = open('/dev/tty', 'r')
|
||||
except OSError:
|
||||
print("Error: Cannot run interactive configuration without a terminal.", file=sys.stderr)
|
||||
print("Run directly: uvx hindsight-embed configure", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Custom style for the prompts
|
||||
custom_style = Style([
|
||||
('qmark', 'fg:cyan bold'),
|
||||
('question', 'fg:white bold'),
|
||||
('answer', 'fg:cyan'),
|
||||
('pointer', 'fg:cyan bold'),
|
||||
('highlighted', 'fg:cyan bold'),
|
||||
('selected', 'fg:green'),
|
||||
('text', 'fg:white'),
|
||||
])
|
||||
|
||||
print()
|
||||
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
|
||||
if CONFIG_FILE.exists():
|
||||
if not questionary.confirm(
|
||||
"Existing configuration found. Reconfigure?",
|
||||
default=False,
|
||||
style=custom_style,
|
||||
).ask():
|
||||
print("\n\033[32m✓\033[0m Keeping existing configuration.")
|
||||
return 0
|
||||
print()
|
||||
|
||||
# Provider selection with descriptions
|
||||
providers = [
|
||||
questionary.Choice("OpenAI (recommended)", value=("openai", "o3-mini", "OpenAI")),
|
||||
questionary.Choice("Groq (fast & free tier)", value=("groq", "openai/gpt-oss-20b", "Groq")),
|
||||
questionary.Choice("Google Gemini", value=("google", "gemini-2.0-flash", "Google")),
|
||||
questionary.Choice("Ollama (local, no API key)", value=("ollama", "llama3.2", None)),
|
||||
]
|
||||
|
||||
result = questionary.select(
|
||||
"Select your LLM provider:",
|
||||
choices=providers,
|
||||
style=custom_style,
|
||||
).ask()
|
||||
|
||||
if result is None: # User cancelled
|
||||
print("\n\033[33m⚠\033[0m Configuration cancelled.")
|
||||
return 1
|
||||
|
||||
provider, default_model, key_name = result
|
||||
|
||||
# API key
|
||||
api_key = ""
|
||||
if key_name:
|
||||
env_keys = {
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
"Groq": "GROQ_API_KEY",
|
||||
"Google": "GOOGLE_API_KEY",
|
||||
}
|
||||
env_key = env_keys.get(key_name, "")
|
||||
existing = os.environ.get(env_key, "")
|
||||
|
||||
if existing:
|
||||
masked = existing[:8] + "..." + existing[-4:] if len(existing) > 12 else "***"
|
||||
if questionary.confirm(
|
||||
f"Found {key_name} key in ${env_key} ({masked}). Use it?",
|
||||
default=True,
|
||||
style=custom_style,
|
||||
).ask():
|
||||
api_key = existing
|
||||
|
||||
if not api_key:
|
||||
api_key = questionary.password(
|
||||
f"Enter your {key_name} API key:",
|
||||
style=custom_style,
|
||||
).ask()
|
||||
|
||||
if not api_key:
|
||||
print("\n\033[31m✗\033[0m API key is required.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Model selection
|
||||
model = questionary.text(
|
||||
"Model name:",
|
||||
default=default_model,
|
||||
style=custom_style,
|
||||
).ask()
|
||||
|
||||
if model is None:
|
||||
return 1
|
||||
|
||||
# Bank ID
|
||||
bank_id = questionary.text(
|
||||
"Memory bank ID:",
|
||||
default="default",
|
||||
style=custom_style,
|
||||
).ask()
|
||||
|
||||
if bank_id is None:
|
||||
return 1
|
||||
|
||||
# Save configuration
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
f.write("# Hindsight Embed Configuration\n")
|
||||
f.write(f"# Generated by hindsight-embed configure\n\n")
|
||||
f.write(f"HINDSIGHT_EMBED_LLM_PROVIDER={provider}\n")
|
||||
f.write(f"HINDSIGHT_EMBED_LLM_MODEL={model}\n")
|
||||
f.write(f"HINDSIGHT_EMBED_BANK_ID={bank_id}\n")
|
||||
if api_key:
|
||||
f.write(f"HINDSIGHT_EMBED_LLM_API_KEY={api_key}\n")
|
||||
|
||||
CONFIG_FILE.chmod(0o600)
|
||||
|
||||
# Stop existing daemon if running (it needs to pick up new config)
|
||||
from . import daemon_client
|
||||
|
||||
if daemon_client._is_daemon_running():
|
||||
print("\n \033[2mRestarting daemon with new configuration...\033[0m")
|
||||
daemon_client.stop_daemon()
|
||||
|
||||
# Start daemon with new config
|
||||
new_config = {
|
||||
"llm_api_key": api_key,
|
||||
"llm_provider": provider,
|
||||
"llm_model": model,
|
||||
"bank_id": bank_id,
|
||||
}
|
||||
if daemon_client.ensure_daemon_running(new_config):
|
||||
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 "Test memory"\033[0m')
|
||||
print(' \033[36mhindsight-embed recall "test"\033[0m')
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def do_daemon(args, config: dict, logger):
|
||||
"""Handle daemon subcommands."""
|
||||
from pathlib import Path
|
||||
from . import daemon_client
|
||||
|
||||
daemon_log_path = Path.home() / ".hindsight" / "daemon.log"
|
||||
|
||||
if args.daemon_command == "start":
|
||||
if daemon_client._is_daemon_running():
|
||||
print("Daemon is already running")
|
||||
return 0
|
||||
|
||||
print("Starting daemon...")
|
||||
if daemon_client.ensure_daemon_running(config):
|
||||
print("Daemon started successfully")
|
||||
print(f" Port: {daemon_client.DAEMON_PORT}")
|
||||
print(f" Logs: {daemon_log_path}")
|
||||
return 0
|
||||
else:
|
||||
print("Failed to start daemon", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.daemon_command == "stop":
|
||||
if not daemon_client._is_daemon_running():
|
||||
print("Daemon is not running")
|
||||
return 0
|
||||
|
||||
print("Stopping daemon...")
|
||||
if daemon_client.stop_daemon():
|
||||
print("Daemon stopped")
|
||||
return 0
|
||||
else:
|
||||
print("Failed to stop daemon", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.daemon_command == "status":
|
||||
if daemon_client._is_daemon_running():
|
||||
# Get PID from lockfile
|
||||
lockfile = Path.home() / ".hindsight" / "daemon.lock"
|
||||
pid = "unknown"
|
||||
if lockfile.exists():
|
||||
try:
|
||||
pid = lockfile.read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"Daemon is running (PID: {pid})")
|
||||
print(f" URL: http://127.0.0.1:{daemon_client.DAEMON_PORT}")
|
||||
print(f" Logs: {daemon_log_path}")
|
||||
return 0
|
||||
else:
|
||||
print("Daemon is not running")
|
||||
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) 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 main():
|
||||
"""Main entry point."""
|
||||
# Check for built-in commands first (before argparse)
|
||||
# This allows us to forward unknown commands to hindsight-cli
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1]
|
||||
|
||||
# Handle configure
|
||||
if command == "configure":
|
||||
logger = setup_logging(False)
|
||||
exit_code = do_configure(None)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle daemon subcommands
|
||||
if command == "daemon":
|
||||
# Parse daemon subcommand
|
||||
parser = argparse.ArgumentParser(prog="hindsight-embed daemon")
|
||||
subparsers = parser.add_subparsers(dest="daemon_command")
|
||||
subparsers.add_parser("start", help="Start the daemon")
|
||||
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(sys.argv[2:])
|
||||
logger = setup_logging(False)
|
||||
config = get_config()
|
||||
exit_code = do_daemon(args, config, logger)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# Handle --help / -h
|
||||
if command in ("--help", "-h"):
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Forward all other commands to hindsight-cli
|
||||
config = get_config()
|
||||
|
||||
# Check for LLM API key
|
||||
if not config["llm_api_key"]:
|
||||
print("Error: LLM API key is required.", file=sys.stderr)
|
||||
print("Run 'hindsight-embed configure' to set up.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from . import daemon_client
|
||||
|
||||
# Forward to hindsight-cli (handles daemon startup and CLI installation)
|
||||
exit_code = daemon_client.run_cli(sys.argv[1:], config)
|
||||
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 <command> [options]
|
||||
|
||||
Built-in commands:
|
||||
configure Interactive configuration setup
|
||||
daemon start Start the background daemon
|
||||
daemon stop Stop the daemon
|
||||
daemon status Check daemon status
|
||||
daemon logs [-f] [-n] View daemon 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
|
||||
|
||||
Examples:
|
||||
hindsight-embed configure
|
||||
hindsight-embed memory retain default "User prefers dark mode"
|
||||
hindsight-embed memory recall default "user preferences"
|
||||
hindsight-embed daemon status
|
||||
hindsight-embed daemon logs -f
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-embed"
|
||||
version = "0.1.0"
|
||||
description = "Hindsight embedded CLI - local memory operations without a server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"questionary>=2.0.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hindsight-embed = "hindsight_embed.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_embed"]
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Smoke test for hindsight-embed CLI with daemon mode
|
||||
# Tests retain and recall operations via the background daemon
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_DIR="$(cd "$SCRIPT_DIR/../hindsight-api" && pwd)"
|
||||
|
||||
echo "=== Hindsight Embed Smoke Test (Daemon Mode) ==="
|
||||
|
||||
# Check required environment (load from config if not set)
|
||||
if [ -f ~/.hindsight/config.env ]; then
|
||||
source ~/.hindsight/config.env
|
||||
fi
|
||||
|
||||
if [ -z "$HINDSIGHT_EMBED_LLM_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "Error: HINDSIGHT_EMBED_LLM_API_KEY or OPENAI_API_KEY is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use a unique bank ID for this test run
|
||||
BANK_ID="test-$$-$(date +%s)"
|
||||
echo "Using bank ID: $BANK_ID"
|
||||
echo "Script dir: $SCRIPT_DIR"
|
||||
echo "API dir: $API_DIR"
|
||||
|
||||
# Debug: Check if hindsight CLI is available
|
||||
echo ""
|
||||
echo "Checking hindsight CLI availability..."
|
||||
if command -v hindsight &> /dev/null; then
|
||||
echo " hindsight CLI found at: $(which hindsight)"
|
||||
else
|
||||
echo " hindsight CLI not in PATH"
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
echo " Found at ~/.local/bin/hindsight"
|
||||
else
|
||||
echo " Not found at ~/.local/bin/hindsight - attempting installation..."
|
||||
# Try to install the CLI
|
||||
if curl -fsSL https://hindsight.vectorize.io/get-cli | bash; then
|
||||
echo " CLI installation completed"
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
echo " CLI now available at ~/.local/bin/hindsight"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
echo " WARNING: CLI still not found after installation"
|
||||
ls -la ~/.local/bin/ 2>/dev/null || echo " ~/.local/bin does not exist"
|
||||
fi
|
||||
else
|
||||
echo " WARNING: CLI installation failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show environment info for debugging
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " HINDSIGHT_EMBED_LLM_PROVIDER: ${HINDSIGHT_EMBED_LLM_PROVIDER:-not set}"
|
||||
echo " HINDSIGHT_EMBED_LLM_MODEL: ${HINDSIGHT_EMBED_LLM_MODEL:-not set}"
|
||||
echo " HINDSIGHT_EMBED_LLM_API_KEY: ${HINDSIGHT_EMBED_LLM_API_KEY:+set (hidden)}"
|
||||
echo " PATH includes ~/.local/bin: $(echo $PATH | grep -q "$HOME/.local/bin" && echo yes || echo no)"
|
||||
|
||||
# Final check that CLI is available before proceeding
|
||||
echo ""
|
||||
echo "Final CLI check before tests..."
|
||||
CLI_PATH=""
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
CLI_PATH="$HOME/.local/bin/hindsight"
|
||||
elif command -v hindsight &> /dev/null; then
|
||||
CLI_PATH="$(which hindsight)"
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_PATH" ]; then
|
||||
echo " CLI found at: $CLI_PATH"
|
||||
echo " CLI version: $($CLI_PATH --version 2>&1 || echo 'unknown')"
|
||||
else
|
||||
echo " ERROR: hindsight CLI not found. Tests cannot proceed."
|
||||
echo " The hindsight-embed package forwards commands to the hindsight CLI."
|
||||
echo " Please ensure the CLI is installed or check the get-cli installer output above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop any existing daemon
|
||||
echo ""
|
||||
echo "Stopping any existing daemon..."
|
||||
uv run --project "$SCRIPT_DIR" hindsight-embed daemon stop 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Test 1: Retain (this should start the daemon)
|
||||
echo ""
|
||||
echo "Test 1: Retaining a memory (first call - daemon will start)..."
|
||||
START_TIME=$(python3 -c "import time; print(time.time())")
|
||||
set +e # Temporarily disable exit on error to capture output
|
||||
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory retain "$BANK_ID" "The user's favorite color is blue" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
END_TIME=$(python3 -c "import time; print(time.time())")
|
||||
DURATION=$(python3 -c "print(f'{$END_TIME - $START_TIME:.2f}')")
|
||||
echo "$OUTPUT"
|
||||
echo "Duration: ${DURATION}s"
|
||||
echo "Exit code: $EXIT_CODE"
|
||||
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "FAIL: Command exited with code $EXIT_CODE"
|
||||
echo ""
|
||||
echo "Checking daemon logs..."
|
||||
if [ -f ~/.hindsight/daemon.log ]; then
|
||||
echo "=== daemon.log ==="
|
||||
tail -50 ~/.hindsight/daemon.log
|
||||
else
|
||||
echo "No daemon.log found"
|
||||
fi
|
||||
if [ -f ~/.hindsight/daemon.stderr ]; then
|
||||
echo ""
|
||||
echo "=== daemon.stderr ==="
|
||||
cat ~/.hindsight/daemon.stderr
|
||||
else
|
||||
echo "No daemon.stderr found"
|
||||
fi
|
||||
echo ""
|
||||
echo "Checking for hindsight-api..."
|
||||
which hindsight-api 2>/dev/null || echo "hindsight-api not in PATH"
|
||||
which uvx 2>/dev/null || echo "uvx not in PATH"
|
||||
which uv 2>/dev/null || echo "uv not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$OUTPUT" | grep -qi "retained"; then
|
||||
echo "FAIL: Expected 'retained' in output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Memory retained successfully"
|
||||
|
||||
# Test 2: Recall (daemon already running - should be faster)
|
||||
echo ""
|
||||
echo "Test 2: Recalling memories (daemon already running)..."
|
||||
START_TIME=$(python3 -c "import time; print(time.time())")
|
||||
set +e
|
||||
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory recall "$BANK_ID" "What is the user's favorite color?" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
END_TIME=$(python3 -c "import time; print(time.time())")
|
||||
DURATION=$(python3 -c "print(f'{$END_TIME - $START_TIME:.2f}')")
|
||||
echo "$OUTPUT"
|
||||
echo "Duration: ${DURATION}s"
|
||||
echo "Exit code: $EXIT_CODE"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "FAIL: Command exited with code $EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$OUTPUT" | grep -qi "blue"; then
|
||||
echo "FAIL: Expected 'blue' in recall output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Memory recalled successfully"
|
||||
|
||||
# Test 3: Retain with context (daemon should still be running)
|
||||
echo ""
|
||||
echo "Test 3: Retaining memory with context..."
|
||||
START_TIME=$(python3 -c "import time; print(time.time())")
|
||||
set +e
|
||||
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory retain "$BANK_ID" "User prefers Python over JavaScript" --context work 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
END_TIME=$(python3 -c "import time; print(time.time())")
|
||||
DURATION=$(python3 -c "print(f'{$END_TIME - $START_TIME:.2f}')")
|
||||
echo "$OUTPUT"
|
||||
echo "Duration: ${DURATION}s"
|
||||
echo "Exit code: $EXIT_CODE"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "FAIL: Command exited with code $EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$OUTPUT" | grep -qi "retained"; then
|
||||
echo "FAIL: Expected 'retained' in output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Memory with context retained successfully"
|
||||
|
||||
# Test 4: Recall with JSON output
|
||||
echo ""
|
||||
echo "Test 4: Recalling with JSON output..."
|
||||
START_TIME=$(python3 -c "import time; print(time.time())")
|
||||
set +e
|
||||
JSON_OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory recall "$BANK_ID" "programming preferences" -o json 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
END_TIME=$(python3 -c "import time; print(time.time())")
|
||||
DURATION=$(python3 -c "print(f'{$END_TIME - $START_TIME:.2f}')")
|
||||
echo "$JSON_OUTPUT"
|
||||
echo "Duration: ${DURATION}s"
|
||||
echo "Exit code: $EXIT_CODE"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "FAIL: Command exited with code $EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$JSON_OUTPUT" | grep -qi "python"; then
|
||||
echo "FAIL: Expected 'Python' in recall output"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$JSON_OUTPUT" | python3 -c "import sys, json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
echo "FAIL: Expected valid JSON output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Memory recalled with JSON format successfully"
|
||||
|
||||
# Test 5: Check daemon is running
|
||||
echo ""
|
||||
echo "Test 5: Verifying daemon is running..."
|
||||
if curl -s http://127.0.0.1:8889/health | grep -q "healthy"; then
|
||||
echo "PASS: Daemon is running and healthy"
|
||||
else
|
||||
echo "FAIL: Daemon is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 6: Daemon status command
|
||||
echo ""
|
||||
echo "Test 6: Testing daemon status command..."
|
||||
STATUS_OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed daemon status 2>&1)
|
||||
echo "$STATUS_OUTPUT"
|
||||
if ! echo "$STATUS_OUTPUT" | grep -qi "running"; then
|
||||
echo "FAIL: Expected 'running' in daemon status output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Daemon status command works"
|
||||
|
||||
# Cleanup: Stop daemon
|
||||
echo ""
|
||||
echo "Stopping daemon..."
|
||||
uv run --project "$SCRIPT_DIR" hindsight-embed daemon stop 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== All tests passed! ==="
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hindsight-litellm"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "Universal LLM memory integration via LiteLLM - works with 100+ providers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.uv.workspace]
|
||||
members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-openai", "hindsight-langmem", "hindsight-clients/python"]
|
||||
members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-openai", "hindsight-langmem", "hindsight-clients/python", "hindsight-embed"]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
|
||||
Reference in New Issue
Block a user