Compare commits

..
11 Commits
Author SHA1 Message Date
Nicolò Boschi bdef0d80f4 refactor deamin 2025-12-22 22:00:54 +01:00
Nicolò Boschi 7db1eb1b3a refactor deamin 2025-12-22 21:50:19 +01:00
Nicolò Boschi c96ae58c28 refactor deamin 2025-12-22 21:43:35 +01:00
Nicolò Boschi d349376df7 refactor deamin 2025-12-22 21:30:54 +01:00
Nicolò Boschi b403b94795 feat: refactor hindsight-embed architecture 2025-12-22 18:20:00 +01:00
Nicolò Boschi fe5d3a999b feat: refactor hindsight-embed architecture 2025-12-22 18:19:55 +01:00
Nicolò Boschi 6168a77846 Release v0.1.12
- Update version to 0.1.12 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-22 16:44:05 +01:00
Nicolò Boschi da44a5e839 feat: add hindsight-embed and native agentic skill (#64) 2025-12-22 16:42:11 +01:00
Nicolò Boschi 32bca12c6f fix: ollama structured support (#63)
* fix: ollama structured support

* fix: ollama structured support

* fix: ollama structured support
2025-12-22 16:35:24 +01:00
Nicolò Boschi 26850a0156 doc: add documentation for extensions (#62)
* add doc for extensions

* add doc for extensions
2025-12-22 11:58:05 +01:00
Nicolò Boschi 2a0c490c9e feat: extensions (#54) 2025-12-22 11:05:23 +01:00
34 changed files with 3466 additions and 800 deletions
+12
View File
@@ -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
+42
View File
@@ -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
+3 -1
View File
@@ -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 -2
View File
@@ -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
+4 -2
View File
@@ -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"
+11
View File
@@ -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:
+204
View File
@@ -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.
+95 -15
View File
@@ -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
+1 -1
View File
@@ -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"
+11 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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 = [
+226
View File
@@ -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.
+5
View File
@@ -111,6 +111,11 @@ const sidebars: SidebarsConfig = {
id: 'developer/configuration',
label: 'Configuration',
},
{
type: 'doc',
id: 'developer/extensions',
label: 'Extensions',
},
{
type: 'doc',
id: 'developer/models',
+266
View File
@@ -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 ""
+167
View File
@@ -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"
+417
View File
@@ -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
+20
View File
@@ -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"]
+236
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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 = []
Generated
+1192 -756
View File
File diff suppressed because it is too large Load Diff