Compare commits

..
1 Commits
Author SHA1 Message Date
Nicolò Boschi 6d4051d347 fix(ui): timestamp is not considered in retain 2025-12-22 22:24:08 +01:00
22 changed files with 405 additions and 1387 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.1.13
appVersion: "0.1.13"
version: 0.1.12
appVersion: "0.1.12"
keywords:
- ai
- memory
-11
View File
@@ -33,10 +33,6 @@ 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"
@@ -111,10 +107,6 @@ 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."""
@@ -141,9 +133,6 @@ 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
@@ -1,204 +0,0 @@
"""
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,8 +202,6 @@ 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.
@@ -229,23 +227,12 @@ 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
@@ -605,8 +592,6 @@ 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."""
@@ -615,26 +600,16 @@ class MemoryEngine(MemoryEngineInterface):
async def verify_llm():
"""Verify LLM connection is working."""
if not self._skip_llm_verification:
await self._llm_config.verify_connection()
await self._llm_config.verify_connection()
# Build list of initialization tasks
init_tasks = [
# Run pg0 and all model initializations in parallel
await asyncio.gather(
start_pg0(),
init_embeddings(),
init_cross_encoder(),
init_query_analyzer(),
]
# 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)
verify_llm(),
)
# Run database migrations if enabled
if self._run_migrations:
@@ -1664,9 +1639,6 @@ 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,23 +26,6 @@ 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]:
"""
+15 -95
View File
@@ -4,9 +4,6 @@ Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Run as background daemon:
hindsight-api --daemon
Stop with Ctrl+C.
"""
@@ -24,13 +21,9 @@ from . import MemoryEngine
from .api import create_app
from .banner import print_banner
from .config import HindsightConfig, get_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
DaemonLock,
IdleTimeoutMiddleware,
daemonize,
)
print()
print_banner()
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
@@ -113,52 +106,8 @@ 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:
@@ -179,12 +128,9 @@ 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()
if not args.daemon:
config.log_config()
config.log_config()
# Register cleanup handlers
atexit.register(_cleanup)
@@ -203,12 +149,6 @@ 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,
@@ -232,38 +172,18 @@ def main():
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not args.daemon:
from .banner import print_startup_info
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,
)
# 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()
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,
)
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.13"
version = "0.1.12"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.1.13"
version = "0.1.12"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
@@ -85,21 +85,9 @@ class Hindsight:
self.close()
def close(self):
"""Close the API client (sync version - use aclose() in async code)."""
"""Close the API client."""
if self._api_client:
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()
_run_async(self._api_client.close())
# Simplified methods for main operations
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hindsight-client"
version = "0.1.13"
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.13",
"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.13",
"version": "0.1.12",
"description": "Control plane for Hindsight - Semantic memory system",
"bin": {
"hindsight-control-plane": "./bin/cli.js"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-dev"
version = "0.1.13"
version = "0.1.12"
description = "Development utilities for Hindsight"
requires-python = ">=3.11"
dependencies = [
+23 -57
View File
@@ -76,45 +76,24 @@ 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 `memory retain` to store important facts, preferences, decisions, or context:
Use `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"
uvx hindsight-embed retain "User prefers dark mode for all UIs"
uvx hindsight-embed retain "Project uses Python 3.11 with FastAPI" --context work
```
### Recall memories
Use `memory recall` to search for relevant memories before starting tasks:
Use `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
uvx hindsight-embed recall "What are the user'"'"'s UI preferences?"
uvx hindsight-embed recall "What tech stack does this project use?"
```
## When to Use
@@ -135,7 +114,6 @@ uvx hindsight-embed --help # Full CLI help
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)
@@ -193,34 +171,22 @@ print_banner
# Validate app parameter
if [ -z "$APP" ]; then
# Check if we have a terminal for interactive prompts
if [ -t 0 ] || [ -e /dev/tty ]; 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 ""
# Use /dev/tty for input if stdin is piped
if [ -t 0 ]; then
read -p "Enter choice [1]: " app_choice
else
read -p "Enter choice [1]: " app_choice </dev/tty
fi
app_choice=${app_choice:-1}
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 ""
else
# Non-interactive (CI) - default to claude
APP="claude"
print_info "Non-interactive mode detected, defaulting to Claude Code"
fi
case $app_choice in
1) APP="claude" ;;
2) APP="opencode" ;;
3) APP="codex" ;;
*) APP="claude" ;;
esac
echo ""
fi
# Get skills directory for selected app
@@ -269,8 +235,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 memory retain default \"Test memory\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed memory recall default \"test\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed retain \"Test memory\"${NC}"
echo -e " ${CYAN}uvx hindsight-embed recall \"test\"${NC}"
echo ""
echo -e " ${DIM}$APP_NAME will automatically use the skill when relevant.${NC}"
echo ""
+16 -113
View File
@@ -1,18 +1,8 @@
# hindsight-embed
Hindsight embedded CLI - local memory operations with automatic daemon management.
Hindsight embedded CLI - local memory operations without a server.
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.
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.
## Installation
@@ -25,115 +15,49 @@ uvx hindsight-embed --help
## Quick Start
```bash
# Interactive setup (recommended)
hindsight-embed configure
# Or set your LLM API key manually
# Set your LLM API key
export OPENAI_API_KEY=sk-...
# Store a memory (bank_id = "default")
hindsight-embed memory retain default "User prefers dark mode"
# Store a memory
hindsight-embed retain "User prefers dark mode"
# Recall memories
hindsight-embed memory recall default "What are user preferences?"
hindsight-embed recall "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
### 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"
hindsight-embed retain "User prefers dark mode"
hindsight-embed retain "Meeting on Monday" --context work
```
### memory recall
### recall
Search memories:
```bash
hindsight-embed memory recall default "user preferences"
hindsight-embed memory recall default "upcoming events"
hindsight-embed recall "user preferences"
hindsight-embed recall "upcoming events" --budget high
hindsight-embed recall "project details" -v # verbose output
```
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
## 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_PROVIDER` | LLM provider (`openai`, `anthropic`, `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:
This CLI is designed to work with AI coding assistants like Claude Code, OpenCode, and Codex CLI. Install the Hindsight skill:
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
@@ -141,27 +65,6 @@ 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
+259 -333
View File
@@ -1,23 +1,24 @@
"""
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.
A simple CLI for local memory operations using embedded PostgreSQL (pg0).
No external server required - runs everything locally.
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
@@ -25,12 +26,11 @@ 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()
level_str = os.environ.get("HINDSIGHT_EMBED_LOG_LEVEL", "warning").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.INFO)
level = level_map.get(level_str, logging.WARNING)
logging.basicConfig(
level=level,
@@ -52,20 +52,17 @@ def setup_logging(verbose: bool = False):
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
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
def get_config():
@@ -83,141 +80,22 @@ def get_config():
}
# Provider defaults: (provider_id, default_model, env_key_name)
PROVIDER_DEFAULTS = {
"openai": ("openai", "o3-mini", "OPENAI_API_KEY"),
"groq": ("groq", "openai/gpt-oss-20b", "GROQ_API_KEY"),
"google": ("google", "gemini-2.0-flash", "GOOGLE_API_KEY"),
"ollama": ("ollama", "llama3.2", None),
}
def do_configure(args):
"""Interactive configuration setup."""
# If stdin is not a terminal (e.g., running via curl | bash),
# redirect stdin from /dev/tty for interactive prompts
original_stdin = None
if not sys.stdin.isatty():
try:
original_stdin = sys.stdin
sys.stdin = open('/dev/tty', 'r')
except OSError:
# No terminal available - try non-interactive mode with env vars
return _do_configure_from_env()
"""Interactive configuration setup with beautiful TUI."""
import questionary
from questionary import Style
try:
return _do_configure_interactive()
finally:
if original_stdin is not None:
sys.stdin.close()
sys.stdin = original_stdin
# 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'),
])
def _do_configure_from_env():
"""Non-interactive configuration from environment variables (for CI)."""
# Check for required environment variables
api_key = os.environ.get("HINDSIGHT_EMBED_LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
provider = os.environ.get("HINDSIGHT_EMBED_LLM_PROVIDER", "openai")
if provider not in PROVIDER_DEFAULTS:
print(f"Error: Unknown provider '{provider}'. Supported: {', '.join(PROVIDER_DEFAULTS.keys())}", file=sys.stderr)
return 1
_, default_model, env_key = PROVIDER_DEFAULTS[provider]
# Check for API key (required for non-ollama providers)
if not api_key and provider != "ollama":
print("Error: Cannot run interactive configuration without a terminal.", file=sys.stderr)
print("", file=sys.stderr)
print("For non-interactive (CI) mode, set environment variables:", file=sys.stderr)
print(f" HINDSIGHT_EMBED_LLM_API_KEY=<your-api-key>", file=sys.stderr)
print(f" HINDSIGHT_EMBED_LLM_PROVIDER={provider} # optional, default: openai", file=sys.stderr)
print(f" HINDSIGHT_EMBED_LLM_MODEL=<model> # optional, default: {default_model}", file=sys.stderr)
return 1
model = os.environ.get("HINDSIGHT_EMBED_LLM_MODEL", default_model)
bank_id = os.environ.get("HINDSIGHT_EMBED_BANK_ID", "default")
print()
print("\033[1m\033[36m Hindsight Embed - Non-interactive Configuration\033[0m")
print()
print(f" \033[2mProvider:\033[0m {provider}")
print(f" \033[2mModel:\033[0m {model}")
print(f" \033[2mBank ID:\033[0m {bank_id}")
# 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 (non-interactive)\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)
print()
print("\033[32m ✓ Configuration saved!\033[0m")
print()
return 0
def _prompt_choice(prompt: str, choices: list[tuple[str, str]], default: int = 1) -> str | None:
"""Simple choice prompt that works with /dev/tty."""
print(f"\033[1m{prompt}\033[0m")
print()
for i, (label, _) in enumerate(choices, 1):
print(f" \033[36m{i})\033[0m {label}")
print()
try:
response = input(f"Enter choice [{default}]: ").strip()
if not response:
return choices[default - 1][1]
idx = int(response)
if 1 <= idx <= len(choices):
return choices[idx - 1][1]
return choices[default - 1][1]
except (ValueError, EOFError, KeyboardInterrupt):
return None
def _prompt_text(prompt: str, default: str = "") -> str | None:
"""Simple text prompt."""
try:
suffix = f" [{default}]" if default else ""
response = input(f"\033[1m{prompt}\033[0m{suffix}: ").strip()
return response if response else default
except (EOFError, KeyboardInterrupt):
return None
def _prompt_password(prompt: str) -> str | None:
"""Simple password prompt."""
import getpass
try:
return getpass.getpass(f"\033[1m{prompt}\033[0m: ")
except (EOFError, KeyboardInterrupt):
return None
def _prompt_confirm(prompt: str, default: bool = True) -> bool | None:
"""Simple yes/no prompt."""
suffix = "[Y/n]" if default else "[y/N]"
try:
response = input(f"\033[1m{prompt}\033[0m {suffix}: ").strip().lower()
if not response:
return default
return response in ('y', 'yes')
except (EOFError, KeyboardInterrupt):
return None
def _do_configure_interactive():
"""Internal interactive configuration."""
print()
print("\033[1m\033[36m ╭─────────────────────────────────────╮\033[0m")
print("\033[1m\033[36m │ Hindsight Embed Configuration │\033[0m")
@@ -226,53 +104,82 @@ def _do_configure_interactive():
# Check existing config
if CONFIG_FILE.exists():
if not _prompt_confirm("Existing configuration found. Reconfigure?", default=False):
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
# Provider selection with descriptions
providers = [
("OpenAI (recommended)", "openai"),
("Groq (fast & free tier)", "groq"),
("Google Gemini", "google"),
("Ollama (local, no API key)", "ollama"),
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)),
]
provider = _prompt_choice("Select your LLM provider:", providers, default=1)
if provider is 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
_, default_model, env_key = PROVIDER_DEFAULTS[provider]
print()
provider, default_model, key_name = result
# API key
api_key = ""
if env_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 _prompt_confirm(f"Found API key in ${env_key} ({masked}). Use it?", default=True):
if questionary.confirm(
f"Found {key_name} key in ${env_key} ({masked}). Use it?",
default=True,
style=custom_style,
).ask():
api_key = existing
print()
if not api_key:
api_key = _prompt_password("Enter your 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
print()
# Model selection
model = _prompt_text("Model name", default=default_model)
model = questionary.text(
"Model name:",
default=default_model,
style=custom_style,
).ask()
if model is None:
return 1
print()
# Bank ID
bank_id = _prompt_text("Memory bank ID", default="default")
bank_id = questionary.text(
"Memory bank ID:",
default="default",
style=custom_style,
).ask()
if bank_id is None:
return 1
@@ -290,25 +197,6 @@ def _do_configure_interactive():
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")
@@ -324,173 +212,211 @@ def _do_configure_interactive():
return 0
def do_daemon(args, config: dict, logger):
"""Handle daemon subcommands."""
from pathlib import Path
from . import daemon_client
async def _create_engine(config: dict, logger):
"""Create and initialize the memory engine."""
logger.debug("Setting up environment variables...")
daemon_log_path = Path.home() / ".hindsight" / "daemon.log"
# 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"]
if args.daemon_command == "start":
if daemon_client._is_daemon_running():
print("Daemon is already running")
return 0
logger.debug("Importing MemoryEngine...")
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
# 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)
else:
print("Failed to start daemon", file=sys.stderr)
return 1
print("No relevant memories found.", flush=True)
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 0
except Exception as e:
logger.error(f"Recall failed: {e}", exc_info=True)
print(f"Error: {e}", 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]
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
"""
)
# Handle configure
if command == "configure":
logger = setup_logging(False)
exit_code = do_configure(None)
sys.exit(exit_code)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose/debug logging"
)
# 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)
subparsers = parser.add_subparsers(dest="command", help="Commands")
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)
# Configure command
subparsers.add_parser("configure", help="Interactive configuration setup")
# Handle --help / -h
if command in ("--help", "-h"):
print_help()
sys.exit(0)
# 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"
)
# Forward all other commands to hindsight-cli
config = get_config()
# 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"
)
# 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)
args = parser.parse_args()
from . import daemon_client
# Setup logging
verbose = getattr(args, 'verbose', False)
logger = setup_logging(verbose)
# Forward to hindsight-cli (handles daemon startup and CLI installation)
exit_code = daemon_client.run_cli(sys.argv[1:], config)
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)
sys.exit(exit_code)
# No command - show help
print_help()
sys.exit(1)
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)
def print_help():
"""Print help message."""
print("""Hindsight Embedded CLI - local memory operations with automatic daemon management.
# 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
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
""")
sys.exit(exit_code)
if __name__ == "__main__":
@@ -1,298 +0,0 @@
"""
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
+5 -1
View File
@@ -9,7 +9,8 @@ description = "Hindsight embedded CLI - local memory operations without a server
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27.0",
"hindsight-api>=0.1.11",
"questionary>=2.0.0",
]
[project.scripts]
@@ -17,3 +18,6 @@ hindsight-embed = "hindsight_embed.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_embed"]
[tool.uv.sources]
hindsight-api = { workspace = true }
+26 -194
View File
@@ -1,236 +1,68 @@
#!/bin/bash
#
# Smoke test for hindsight-embed CLI with daemon mode
# Tests retain and recall operations via the background daemon
# Simple smoke test for hindsight-embed CLI
# Tests retain and recall operations with embedded PostgreSQL
#
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) ==="
echo "=== Hindsight Embed Smoke Test ==="
# 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"
# Check required environment
if [ -z "$HINDSIGHT_EMBED_LLM_API_KEY" ]; then
echo "Error: HINDSIGHT_EMBED_LLM_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"
export HINDSIGHT_EMBED_BANK_ID="test-$$-$(date +%s)"
echo "Using bank ID: $HINDSIGHT_EMBED_BANK_ID"
# Debug: Check if hindsight CLI is available
# Test 1: Retain a memory
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 "Test 1: Retaining a memory..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed retain "The user's favorite color is blue" 2>&1)
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"
if ! echo "$OUTPUT" | grep -q "Stored memory"; then
echo "FAIL: Expected 'Stored memory' in output"
exit 1
fi
echo "PASS: Memory retained successfully"
# Test 2: Recall (daemon already running - should be faster)
# Test 2: Recall the memory
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 "Test 2: Recalling memories..."
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed recall "What is the user's favorite color?" 2>&1)
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)
# Test 3: Retain with context
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}')")
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed retain "User prefers Python over JavaScript" --context work 2>&1)
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"
if ! echo "$OUTPUT" | grep -q "Stored memory"; then
echo "FAIL: Expected 'Stored memory' in output"
exit 1
fi
echo "PASS: Memory with context retained successfully"
# Test 4: Recall with JSON output
# Test 4: Recall with budget
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 "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 "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 "PASS: Memory recalled with budget successfully"
echo ""
echo "=== All tests passed! ==="
@@ -1,6 +1,6 @@
[project]
name = "hindsight-litellm"
version = "0.1.13"
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.13"
version = "0.1.12"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
Generated
+43 -6
View File
@@ -1292,7 +1292,7 @@ wheels = [
[[package]]
name = "hindsight-all"
version = "0.1.13"
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.13"
version = "0.1.12"
source = { editable = "hindsight-api" }
dependencies = [
{ name = "alembic" },
@@ -1422,7 +1422,7 @@ dev = [
[[package]]
name = "hindsight-client"
version = "0.1.13"
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.13"
version = "0.1.12"
source = { editable = "hindsight-dev" }
dependencies = [
{ name = "hindsight-api" },
@@ -1494,11 +1494,15 @@ name = "hindsight-embed"
version = "0.1.0"
source = { editable = "hindsight-embed" }
dependencies = [
{ name = "httpx" },
{ name = "hindsight-api" },
{ name = "questionary" },
]
[package.metadata]
requires-dist = [{ name = "httpx", specifier = ">=0.27.0" }]
requires-dist = [
{ name = "hindsight-api", editable = "hindsight-api" },
{ name = "questionary", specifier = ">=2.0.0" },
]
[[package]]
name = "httpcore"
@@ -2911,6 +2915,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145 },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -3616,6 +3632,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753 },
]
[[package]]
name = "redis"
version = "7.1.0"
@@ -4937,6 +4965,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 },
]
[[package]]
name = "websockets"
version = "15.0.1"