Compare 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
13 changed files with 1210 additions and 282 deletions
+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
@@ -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,12 +229,23 @@ 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
@@ -594,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."""
@@ -602,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:
@@ -1641,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]:
"""
+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
@@ -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
+30 -8
View File
@@ -76,24 +76,45 @@ You have access to persistent memory via the `hindsight-embed` CLI. Use it to re
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 `retain` to store important facts, preferences, decisions, or context:
Use `memory retain` to store important facts, preferences, decisions, or context:
```bash
uvx hindsight-embed retain "User prefers dark mode for all UIs"
uvx hindsight-embed retain "Project uses Python 3.11 with FastAPI" --context work
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 `recall` to search for relevant memories before starting tasks:
Use `memory recall` to search for relevant memories before starting tasks:
```bash
uvx hindsight-embed recall "What are the user'"'"'s UI preferences?"
uvx hindsight-embed recall "What tech stack does this project use?"
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
@@ -114,6 +135,7 @@ uvx hindsight-embed recall "What tech stack does this project use?"
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)
@@ -235,8 +257,8 @@ 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 retain \"Test memory\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed recall \"test\"${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 ""
+113 -16
View File
@@ -1,8 +1,18 @@
# hindsight-embed
Hindsight embedded CLI - local memory operations without a server.
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 with an embedded PostgreSQL database (pg0). No external server or database setup required.
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
@@ -15,49 +25,115 @@ uvx hindsight-embed --help
## Quick Start
```bash
# Set your LLM API key
# Interactive setup (recommended)
hindsight-embed configure
# Or set your LLM API key manually
export OPENAI_API_KEY=sk-...
# Store a memory
hindsight-embed retain "User prefers dark mode"
# Store a memory (bank_id = "default")
hindsight-embed memory retain default "User prefers dark mode"
# Recall memories
hindsight-embed recall "What are user preferences?"
hindsight-embed memory recall default "What are user preferences?"
```
## Commands
### retain
### 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 retain "User prefers dark mode"
hindsight-embed retain "Meeting on Monday" --context work
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"
```
### recall
### memory recall
Search memories:
```bash
hindsight-embed recall "user preferences"
hindsight-embed recall "upcoming events" --budget high
hindsight-embed recall "project details" -v # verbose output
hindsight-embed memory recall default "user preferences"
hindsight-embed memory recall default "upcoming events"
```
## Environment Variables
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`, `anthropic`, `google`, `ollama`) | `openai` |
| `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, OpenCode, and Codex CLI. Install the Hindsight skill:
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
@@ -65,6 +141,27 @@ 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
+193 -199
View File
@@ -1,24 +1,23 @@
"""
Hindsight Embedded CLI.
A simple CLI for local memory operations using embedded PostgreSQL (pg0).
No external server required - runs everything locally.
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").
HINDSIGHT_EMBED_LOG_LEVEL: Optional. Log level (default: "warning").
"""
import argparse
import asyncio
import logging
import os
import sys
@@ -26,11 +25,12 @@ 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", "warning").lower()
level_str = os.environ.get("HINDSIGHT_EMBED_LOG_LEVEL", "info").lower()
if verbose:
level_str = "debug"
@@ -40,7 +40,7 @@ def setup_logging(verbose: bool = False):
"warning": logging.WARNING,
"error": logging.ERROR,
}
level = level_map.get(level_str, logging.WARNING)
level = level_map.get(level_str, logging.INFO)
logging.basicConfig(
level=level,
@@ -52,17 +52,20 @@ def setup_logging(verbose: bool = False):
def load_config_file():
"""Load configuration from file if it exists."""
if CONFIG_FILE.exists():
with open(CONFIG_FILE) 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
# 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():
@@ -85,6 +88,16 @@ def do_configure(args):
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'),
@@ -197,6 +210,25 @@ def do_configure(args):
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")
@@ -212,211 +244,173 @@ def do_configure(args):
return 0
async def _create_engine(config: dict, logger):
"""Create and initialize the memory engine."""
logger.debug("Setting up environment variables...")
def do_daemon(args, config: dict, logger):
"""Handle daemon subcommands."""
from pathlib import Path
from . import daemon_client
# Set hindsight-api environment variables from our config
if config["llm_api_key"]:
os.environ["HINDSIGHT_API_LLM_API_KEY"] = config["llm_api_key"]
if config["llm_provider"]:
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = config["llm_provider"]
if config["llm_model"]:
os.environ["HINDSIGHT_API_LLM_MODEL"] = config["llm_model"]
daemon_log_path = Path.home() / ".hindsight" / "daemon.log"
logger.debug("Importing MemoryEngine...")
if args.daemon_command == "start":
if daemon_client._is_daemon_running():
print("Daemon is already running")
return 0
# Import after setting env vars
from hindsight_api import MemoryEngine
from hindsight_api.engine.task_backend import SyncTaskBackend
# Use pg0 embedded database
db_name = f"hindsight-embed-{config['bank_id']}"
logger.debug(f"Creating MemoryEngine with pg0://{db_name}")
# Use SyncTaskBackend to avoid background workers that prevent clean exit
memory = MemoryEngine(
db_url=f"pg0://{db_name}",
task_backend=SyncTaskBackend(),
)
logger.debug("Initializing engine...")
await memory.initialize()
logger.debug("Engine initialized")
return memory
async def do_retain(args, config: dict, logger):
"""Execute retain command."""
from hindsight_api.models import RequestContext
logger.info(f"Retaining memory: {args.content[:50]}...")
memory = await _create_engine(config, logger)
try:
logger.debug("Calling retain_batch_async...")
await memory.retain_batch_async(
bank_id=config["bank_id"],
contents=[{
"content": args.content,
"context": args.context or "general",
}],
request_context=RequestContext(),
)
msg = f"Stored memory: {args.content[:50]}..." if len(args.content) > 50 else f"Stored memory: {args.content}"
print(msg, flush=True)
return 0
except Exception as e:
logger.error(f"Retain failed: {e}", exc_info=True)
print(f"Error: {e}", file=sys.stderr)
return 1
async def do_recall(args, config: dict, logger):
"""Execute recall command."""
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
logger.info(f"Recalling with query: {args.query}")
memory = await _create_engine(config, logger)
try:
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(args.budget.lower(), Budget.LOW)
logger.debug(f"Calling recall_async with budget={budget_enum}...")
result = await memory.recall_async(
bank_id=config["bank_id"],
query=args.query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=budget_enum,
max_tokens=args.max_tokens,
request_context=RequestContext(),
)
logger.debug(f"Recall returned {len(result.results)} results")
if result.results:
print("Memories found:", flush=True)
print("-" * 40, flush=True)
for fact in result.results:
print(f"- {fact.text}", flush=True)
if args.verbose and fact.occurred_start:
print(f" (Date: {fact.occurred_start})", flush=True)
print("-" * 40, flush=True)
print(f"Total: {len(result.results)} memories", flush=True)
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("No relevant memories found.", flush=True)
print("Failed to start daemon", file=sys.stderr)
return 1
return 0
except Exception as e:
logger.error(f"Recall failed: {e}", exc_info=True)
print(f"Error: {e}", file=sys.stderr)
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."""
parser = argparse.ArgumentParser(
description="Hindsight Embedded CLI - local memory operations without a server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
hindsight-embed configure # Interactive setup
hindsight-embed retain "User prefers dark mode"
hindsight-embed retain "Meeting on Monday" -c work
hindsight-embed recall "user preferences"
hindsight-embed recall "meetings" --budget high
"""
)
# 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]
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose/debug logging"
)
# Handle configure
if command == "configure":
logger = setup_logging(False)
exit_code = do_configure(None)
sys.exit(exit_code)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# 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)
# Configure command
subparsers.add_parser("configure", help="Interactive configuration setup")
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)
# Retain command
retain_parser = subparsers.add_parser("retain", help="Store a memory")
retain_parser.add_argument("content", help="The memory content to store")
retain_parser.add_argument(
"--context", "-c",
help="Category for the memory (e.g., 'preferences', 'work')",
default="general"
)
# Handle --help / -h
if command in ("--help", "-h"):
print_help()
sys.exit(0)
# Recall command
recall_parser = subparsers.add_parser("recall", help="Search memories")
recall_parser.add_argument("query", help="Search query")
recall_parser.add_argument(
"--budget", "-b",
choices=["low", "mid", "high"],
default="low",
help="Search budget level (default: low)"
)
recall_parser.add_argument(
"--max-tokens", "-m",
type=int,
default=4096,
help="Maximum tokens in results (default: 4096)"
)
recall_parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show additional details"
)
# Forward all other commands to hindsight-cli
config = get_config()
args = parser.parse_args()
# 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)
# Setup logging
verbose = getattr(args, 'verbose', False)
logger = setup_logging(verbose)
from . import daemon_client
if not args.command:
parser.print_help()
sys.exit(1)
# Handle configure separately (no config needed)
if args.command == "configure":
exit_code = do_configure(args)
# Forward to hindsight-cli (handles daemon startup and CLI installation)
exit_code = daemon_client.run_cli(sys.argv[1:], config)
sys.exit(exit_code)
config = get_config()
# No command - show help
print_help()
sys.exit(1)
# 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)
# Run the appropriate command
exit_code = 1
try:
if args.command == "retain":
exit_code = asyncio.run(do_retain(args, config, logger))
elif args.command == "recall":
exit_code = asyncio.run(do_recall(args, config, logger))
else:
parser.print_help()
exit_code = 1
except KeyboardInterrupt:
logger.debug("Interrupted")
exit_code = 130
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
print(f"Error: {e}", file=sys.stderr)
exit_code = 1
def print_help():
"""Print help message."""
print("""Hindsight Embedded CLI - local memory operations with automatic daemon management.
sys.exit(exit_code)
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__":
@@ -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
+1 -4
View File
@@ -9,8 +9,8 @@ description = "Hindsight embedded CLI - local memory operations without a server
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api>=0.1.11",
"questionary>=2.0.0",
"httpx>=0.27.0",
]
[project.scripts]
@@ -18,6 +18,3 @@ hindsight-embed = "hindsight_embed.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_embed"]
[tool.uv.sources]
hindsight-api = { workspace = true }
+194 -26
View File
@@ -1,68 +1,236 @@
#!/bin/bash
#
# Simple smoke test for hindsight-embed CLI
# Tests retain and recall operations with embedded PostgreSQL
# 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 ==="
echo "=== Hindsight Embed Smoke Test (Daemon Mode) ==="
# Check required environment
if [ -z "$HINDSIGHT_EMBED_LLM_API_KEY" ]; then
echo "Error: HINDSIGHT_EMBED_LLM_API_KEY is required"
# 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
export HINDSIGHT_EMBED_BANK_ID="test-$$-$(date +%s)"
echo "Using bank ID: $HINDSIGHT_EMBED_BANK_ID"
BANK_ID="test-$$-$(date +%s)"
echo "Using bank ID: $BANK_ID"
echo "Script dir: $SCRIPT_DIR"
echo "API dir: $API_DIR"
# Test 1: Retain a memory
# Debug: Check if hindsight CLI is available
echo ""
echo "Test 1: Retaining a memory..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed retain "The user's favorite color is blue" 2>&1)
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"
if ! echo "$OUTPUT" | grep -q "Stored memory"; then
echo "FAIL: Expected 'Stored memory' in 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 the memory
# Test 2: Recall (daemon already running - should be faster)
echo ""
echo "Test 2: Recalling memories..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed recall "What is the user's favorite color?" 2>&1)
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
# Test 3: Retain with context (daemon should still be running)
echo ""
echo "Test 3: Retaining memory with context..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed retain "User prefers Python over JavaScript" --context work 2>&1)
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"
if ! echo "$OUTPUT" | grep -q "Stored memory"; then
echo "FAIL: Expected 'Stored memory' in 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 budget
# Test 4: Recall with JSON output
echo ""
echo "Test 4: Recalling with budget..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed recall "programming preferences" --budget mid 2>&1)
echo "$OUTPUT"
if ! echo "$OUTPUT" | grep -qi "python"; then
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
echo "PASS: Memory recalled with budget successfully"
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! ==="
Generated
+6 -6
View File
@@ -1292,7 +1292,7 @@ wheels = [
[[package]]
name = "hindsight-all"
version = "0.1.11"
version = "0.1.12"
source = { editable = "hindsight" }
dependencies = [
{ name = "hindsight-api" },
@@ -1316,7 +1316,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-api"
version = "0.1.11"
version = "0.1.12"
source = { editable = "hindsight-api" }
dependencies = [
{ name = "alembic" },
@@ -1422,7 +1422,7 @@ dev = [
[[package]]
name = "hindsight-client"
version = "0.1.11"
version = "0.1.12"
source = { editable = "hindsight-clients/python" }
dependencies = [
{ name = "aiohttp" },
@@ -1456,7 +1456,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-dev"
version = "0.1.11"
version = "0.1.12"
source = { editable = "hindsight-dev" }
dependencies = [
{ name = "hindsight-api" },
@@ -1494,13 +1494,13 @@ name = "hindsight-embed"
version = "0.1.0"
source = { editable = "hindsight-embed" }
dependencies = [
{ name = "hindsight-api" },
{ name = "httpx" },
{ name = "questionary" },
]
[package.metadata]
requires-dist = [
{ name = "hindsight-api", editable = "hindsight-api" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "questionary", specifier = ">=2.0.0" },
]