diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cad87e6..2f1f5655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,10 @@ jobs: working-directory: ./hindsight-integrations/litellm run: uv build --out-dir dist + - name: Build hindsight-embed + working-directory: ./hindsight-embed + run: uv build --out-dir dist + # Publish in order (client and api first, then hindsight-all which depends on them) - name: Publish hindsight-client to PyPI uses: pypa/gh-action-pypi-publish@release/v1 @@ -67,6 +71,12 @@ jobs: packages-dir: ./hindsight-integrations/litellm/dist skip-existing: true + - name: Publish hindsight-embed to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ./hindsight-embed/dist + skip-existing: true + # Upload artifacts for GitHub release - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -77,6 +87,7 @@ jobs: hindsight-api/dist/* hindsight/dist/* hindsight-integrations/litellm/dist/* + hindsight-embed/dist/* retention-days: 1 release-typescript-client: @@ -416,6 +427,7 @@ jobs: cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true cp artifacts/python-packages/hindsight/dist/* release-assets/ || true cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true + cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true # TypeScript client cp artifacts/typescript-client/*.tgz release-assets/ || true # Control Plane diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 545f377c..8068c845 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,8 @@ jobs: path: hindsight-api - name: hindsight-client path: hindsight-clients/python + - name: hindsight-embed + path: hindsight-embed steps: - uses: actions/checkout@v4 @@ -563,6 +565,46 @@ jobs: working-directory: ./hindsight-integrations/litellm run: uv run pytest tests -v + test-embed: + runs-on: ubuntu-latest + env: + HINDSIGHT_EMBED_LLM_PROVIDER: groq + HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} + HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b + # Prefer CPU-only PyTorch in CI + UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + prune-cache: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install dependencies + working-directory: ./hindsight-embed + run: uv sync --index-strategy unsafe-best-match + + - name: Cache HuggingFace models + uses: actions/cache@v4 + with: + path: ~/.cache/huggingface + key: ${{ runner.os }}-huggingface-embed-${{ hashFiles('hindsight-embed/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-huggingface-embed- + ${{ runner.os }}-huggingface- + + - name: Run smoke test + working-directory: ./hindsight-embed + run: ./test.sh + test-doc-examples: runs-on: ubuntu-latest needs: build-rust-cli diff --git a/AGENTS.md b/AGENTS.md index b4c7a9e8..5038b812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This document captures architectural decisions and coding conventions for the Hi hindsight/ # Python package for embedded usage hindsight-api/ # FastAPI server (core memory engine) hindsight-cli/ # Rust CLI client +hindsight-embed/ # Embedded CLI (no server needed) hindsight-control-plane/ # Next.js admin UI hindsight-docs/ # Docusaurus documentation site hindsight-dev/ # Development tools and benchmarks @@ -148,4 +149,5 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved # Branding ## Colors -- Primary: gradient from #0074d9 to #009296 +- Primary: gradient from #0074d9 to #009296 + diff --git a/hindsight-api/hindsight_api/engine/task_backend.py b/hindsight-api/hindsight_api/engine/task_backend.py index be7c9c69..d34139f7 100644 --- a/hindsight-api/hindsight_api/engine/task_backend.py +++ b/hindsight-api/hindsight_api/engine/task_backend.py @@ -89,6 +89,38 @@ class TaskBackend(ABC): traceback.print_exc() +class SyncTaskBackend(TaskBackend): + """ + Synchronous task backend that executes tasks immediately. + + This is useful for embedded/CLI usage where we don't want background + workers that prevent clean exit. Tasks are executed inline rather than + being queued. + """ + + async def initialize(self): + """No-op for sync backend.""" + self._initialized = True + logger.debug("SyncTaskBackend initialized") + + async def submit_task(self, task_dict: dict[str, Any]): + """ + Execute the task immediately (synchronously). + + Args: + task_dict: Task dictionary to execute + """ + if not self._initialized: + await self.initialize() + + await self._execute_task(task_dict) + + async def shutdown(self): + """No-op for sync backend.""" + self._initialized = False + logger.debug("SyncTaskBackend shutdown") + + class AsyncIOQueueBackend(TaskBackend): """ Task backend implementation using asyncio queues. diff --git a/hindsight-docs/static/get-skill b/hindsight-docs/static/get-skill new file mode 100755 index 00000000..f9998820 --- /dev/null +++ b/hindsight-docs/static/get-skill @@ -0,0 +1,244 @@ +#!/bin/bash +# +# Install Hindsight Agent Skill +# +# Usage: +# curl -fsSL https://hindsight.vectorize.io/get-skill | bash +# +# Options: +# --app Target app: claude, opencode, codex +# +# Examples: +# curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude +# curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app opencode +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" + exit 1 +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_step() { + echo "" + echo -e "${BOLD}${CYAN}▸ $1${NC}" + echo "" +} + +print_banner() { + echo "" + # ANSI logo + echo -e " \033[38;2;9;127;184m▄\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m▄\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m▄\033[0m\033[38;2;7;140;156m▄\033[0m " + echo -e " \033[38;2;8;125;192m▄\033[0m \033[38;2;3;132;191m▀\033[0m\033[38;2;2;133;192m▄\033[0m \033[38;2;3;132;180m▄\033[0m\033[38;2;1;137;184m▄\033[0m\033[38;2;3;133;174m▄\033[0m \033[38;2;3;142;176m▄\033[0m\033[38;2;4;142;169m▀\033[0m \033[38;2;10;144;164m▄\033[0m " + echo -e "\033[38;2;6;121;195m▀\033[0m\033[38;2;5;128;203m▀\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m▄\033[0m\033[38;2;2;126;196m▄\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m▄\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m▄\033[0m\033[38;2;1;141;196m▀\033[0m\033[38;2;1;135;183m▀\033[0m\033[38;2;1;148;198m▀\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m▄\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m▄\033[0m\033[38;2;3;138;173m▄\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m▄\033[0m\033[38;2;7;144;169m▀\033[0m\033[38;2;7;139;158m▀\033[0m" + echo -e " \033[48;2;2;128;202m\033[38;2;2;124;201m▄\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m▄\033[0m\033[38;2;2;128;196m▄\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m▄\033[0m \033[38;2;1;135;186m▄\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m▄\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m▄\033[0m " + echo -e " \033[48;2;8;118;200m\033[38;2;8;121;209m▄\033[0m\033[38;2;3;121;203m▀\033[0m \033[38;2;3;122;192m▀\033[0m\033[38;2;1;138;216m▀\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m▄\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m▄\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m▄\033[0m\033[38;2;1;140;196m▀\033[0m \033[38;2;4;134;175m▀\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m▄\033[0m " + echo "" + echo -e " ${BOLD}HINDSIGHT SKILL INSTALLER${NC}" + echo -e " ${DIM}Give your AI agent persistent memory${NC}" + echo "" +} + +# Embedded SKILL.md content +SKILL_CONTENT='--- +name: hindsight +description: Give your agent persistent memory that works like human memory. Store facts, preferences, and context that persist across sessions. +--- + +# Hindsight Memory Skill + +You have access to persistent memory via the `hindsight-embed` CLI. Use it to remember important information about the user and recall it when relevant. + +## Setup (first time only) + +Run: `uvx hindsight-embed configure` + +## Commands + +### Store a memory + +Use `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 +``` + +### Recall memories + +Use `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?" +``` + +## When to Use + +### Store memories when you learn: +- User preferences (coding style, tools, UI preferences) +- Project context (tech stack, architecture decisions) +- Personal information the user shares (name, role, company) +- Important decisions or outcomes + +### Recall memories when: +- Starting a new task (get relevant context first) +- Making decisions that should consider user preferences +- Working on a project where past context would help + +## Best Practices + +1. **Be specific**: Store "User prefers 2-space indentation" not "User has preferences" +2. **Recall first**: Before starting tasks, recall relevant context +3. **Use context tags**: Organize with `--context` (work, personal, preferences) +' + +# Get skills directory for app (bash 3.x compatible) +get_skills_dir() { + case "$1" in + claude) echo "$HOME/.claude/skills" ;; + opencode) echo "$HOME/.opencode/skills" ;; + codex) echo "$HOME/.codex/skills" ;; + *) echo "" ;; + esac +} + +# Get app display name (bash 3.x compatible) +get_app_name() { + case "$1" in + claude) echo "Claude Code" ;; + opencode) echo "OpenCode" ;; + codex) echo "Codex CLI" ;; + *) echo "$1" ;; + esac +} + +# Parse arguments +APP="" + +show_usage() { + echo "Usage: $0 [--app ]" + echo "" + echo "Options:" + echo " --app Target app: claude, opencode, codex" + echo "" + echo "Examples:" + echo " $0 --app claude" + echo " $0 --app opencode" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app) + APP="$2" + shift 2 + ;; + --help|-h) + show_usage + ;; + *) + print_error "Unknown option: $1" + ;; + esac +done + +# Show banner +print_banner + +# Validate app parameter +if [ -z "$APP" ]; then + echo -e "${DIM}Select your AI coding assistant:${NC}" + echo "" + echo " ${BOLD}1)${NC} Claude Code" + echo " ${BOLD}2)${NC} OpenCode" + echo " ${BOLD}3)${NC} Codex CLI" + echo "" + read -p "Enter choice [1]: " app_choice + app_choice=${app_choice:-1} + + case $app_choice in + 1) APP="claude" ;; + 2) APP="opencode" ;; + 3) APP="codex" ;; + *) APP="claude" ;; + esac + echo "" +fi + +# Get skills directory for selected app +SKILLS_DIR=$(get_skills_dir "$APP") +APP_NAME=$(get_app_name "$APP") +if [ -z "$SKILLS_DIR" ]; then + print_error "Unknown app '$APP'. Supported: claude, opencode, codex" +fi + +print_info "Installing for ${BOLD}$APP_NAME${NC}" + +# Step 1: Check for Python/uvx +print_step "Checking prerequisites" + +if ! command -v python3 &> /dev/null && ! command -v uvx &> /dev/null; then + print_error "Python 3 or uvx is required.\nInstall from https://python.org or https://docs.astral.sh/uv/" +fi +print_success "Python/uvx available" + +# Step 2: Configure LLM provider using the CLI +print_step "Configuring LLM provider" + +# Install/run hindsight-embed configure +if command -v uvx &> /dev/null; then + uvx hindsight-embed configure +else + pip install -q hindsight-embed + hindsight-embed configure +fi + +# Step 3: Install skill to app's skills directory +print_step "Installing skill to $APP_NAME" + +mkdir -p "$SKILLS_DIR/hindsight" + +# Write embedded SKILL.md content +echo "$SKILL_CONTENT" > "$SKILLS_DIR/hindsight/SKILL.md" +print_success "Installed to $SKILLS_DIR/hindsight/" + +# Done! +echo "" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN} ✓ Installation Complete!${NC}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +echo -e " The Hindsight skill is now available in ${BOLD}$APP_NAME${NC}." +echo "" +echo -e " ${DIM}Test the CLI:${NC}" +echo -e " ${CYAN}uvx hindsight-embed 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 "" +echo -e " ${DIM}Documentation:${NC} ${BLUE}https://hindsight.vectorize.io${NC}" +echo "" diff --git a/hindsight-embed/README.md b/hindsight-embed/README.md new file mode 100644 index 00000000..c360b2af --- /dev/null +++ b/hindsight-embed/README.md @@ -0,0 +1,70 @@ +# hindsight-embed + +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 with an embedded PostgreSQL database (pg0). No external server or database setup required. + +## Installation + +```bash +pip install hindsight-embed +# or with uvx (no install needed) +uvx hindsight-embed --help +``` + +## Quick Start + +```bash +# Set your LLM API key +export OPENAI_API_KEY=sk-... + +# Store a memory +hindsight-embed retain "User prefers dark mode" + +# Recall memories +hindsight-embed recall "What are user preferences?" +``` + +## Commands + +### retain + +Store a memory: + +```bash +hindsight-embed retain "User prefers dark mode" +hindsight-embed retain "Meeting on Monday" --context work +``` + +### recall + +Search memories: + +```bash +hindsight-embed recall "user preferences" +hindsight-embed recall "upcoming events" --budget high +hindsight-embed recall "project details" -v # verbose output +``` + +## 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_MODEL` | LLM model | `gpt-4o-mini` | +| `HINDSIGHT_EMBED_BANK_ID` | Memory bank ID | `default` | + +## 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: + +```bash +curl -fsSL https://hindsight.vectorize.io/get-skill | bash +``` + +This will configure the LLM provider and install the skill to your assistant's skills directory. + +## License + +Apache 2.0 diff --git a/hindsight-embed/hindsight_embed/__init__.py b/hindsight-embed/hindsight_embed/__init__.py new file mode 100644 index 00000000..12b0b93a --- /dev/null +++ b/hindsight-embed/hindsight_embed/__init__.py @@ -0,0 +1,3 @@ +"""Hindsight embedded CLI - local memory operations without a server.""" + +__version__ = "0.1.0" diff --git a/hindsight-embed/hindsight_embed/cli.py b/hindsight-embed/hindsight_embed/cli.py new file mode 100644 index 00000000..79cfb070 --- /dev/null +++ b/hindsight-embed/hindsight_embed/cli.py @@ -0,0 +1,423 @@ +""" +Hindsight Embedded CLI. + +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?" + +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 +from pathlib import Path + +CONFIG_DIR = Path.home() / ".hindsight" +CONFIG_FILE = CONFIG_DIR / "embed" + + +def setup_logging(verbose: bool = False): + """Configure logging.""" + level_str = os.environ.get("HINDSIGHT_EMBED_LOG_LEVEL", "warning").lower() + if verbose: + level_str = "debug" + + level_map = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + level = level_map.get(level_str, logging.WARNING) + + logging.basicConfig( + level=level, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + stream=sys.stderr, + ) + return logging.getLogger(__name__) + + +def load_config_file(): + """Load configuration from file if it exists.""" + 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(): + """Get configuration from environment variables.""" + load_config_file() + return { + "llm_api_key": os.environ.get("HINDSIGHT_EMBED_LLM_API_KEY") + or os.environ.get("HINDSIGHT_API_LLM_API_KEY") + or os.environ.get("OPENAI_API_KEY"), + "llm_provider": os.environ.get("HINDSIGHT_EMBED_LLM_PROVIDER") + or os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "openai"), + "llm_model": os.environ.get("HINDSIGHT_EMBED_LLM_MODEL") + or os.environ.get("HINDSIGHT_API_LLM_MODEL", "gpt-4o-mini"), + "bank_id": os.environ.get("HINDSIGHT_EMBED_BANK_ID", "default"), + } + + +def do_configure(args): + """Interactive configuration setup with beautiful TUI.""" + import questionary + from questionary import Style + + # Custom style for the prompts + custom_style = Style([ + ('qmark', 'fg:cyan bold'), + ('question', 'fg:white bold'), + ('answer', 'fg:cyan'), + ('pointer', 'fg:cyan bold'), + ('highlighted', 'fg:cyan bold'), + ('selected', 'fg:green'), + ('text', 'fg:white'), + ]) + + print() + print("\033[1m\033[36m ╭─────────────────────────────────────╮\033[0m") + print("\033[1m\033[36m │ Hindsight Embed Configuration │\033[0m") + print("\033[1m\033[36m ╰─────────────────────────────────────╯\033[0m") + print() + + # Check existing config + if CONFIG_FILE.exists(): + if not questionary.confirm( + "Existing configuration found. Reconfigure?", + default=False, + style=custom_style, + ).ask(): + print("\n\033[32m✓\033[0m Keeping existing configuration.") + return 0 + print() + + # Provider selection with descriptions + providers = [ + questionary.Choice("OpenAI (recommended)", value=("openai", "o3-mini", "OpenAI")), + questionary.Choice("Groq (fast & free tier)", value=("groq", "openai/gpt-oss-20b", "Groq")), + questionary.Choice("Google Gemini", value=("google", "gemini-2.0-flash", "Google")), + questionary.Choice("Ollama (local, no API key)", value=("ollama", "llama3.2", None)), + ] + + result = questionary.select( + "Select your LLM provider:", + choices=providers, + style=custom_style, + ).ask() + + if result is None: # User cancelled + print("\n\033[33m⚠\033[0m Configuration cancelled.") + return 1 + + provider, default_model, key_name = result + + # API key + api_key = "" + if key_name: + env_keys = { + "OpenAI": "OPENAI_API_KEY", + "Groq": "GROQ_API_KEY", + "Google": "GOOGLE_API_KEY", + } + env_key = env_keys.get(key_name, "") + existing = os.environ.get(env_key, "") + + if existing: + masked = existing[:8] + "..." + existing[-4:] if len(existing) > 12 else "***" + if questionary.confirm( + f"Found {key_name} key in ${env_key} ({masked}). Use it?", + default=True, + style=custom_style, + ).ask(): + api_key = existing + + if not api_key: + api_key = questionary.password( + f"Enter your {key_name} API key:", + style=custom_style, + ).ask() + + if not api_key: + print("\n\033[31m✗\033[0m API key is required.", file=sys.stderr) + return 1 + + # Model selection + model = questionary.text( + "Model name:", + default=default_model, + style=custom_style, + ).ask() + + if model is None: + return 1 + + # Bank ID + bank_id = questionary.text( + "Memory bank ID:", + default="default", + style=custom_style, + ).ask() + + if bank_id is None: + return 1 + + # Save configuration + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + with open(CONFIG_FILE, "w") as f: + f.write("# Hindsight Embed Configuration\n") + f.write(f"# Generated by hindsight-embed configure\n\n") + f.write(f"HINDSIGHT_EMBED_LLM_PROVIDER={provider}\n") + f.write(f"HINDSIGHT_EMBED_LLM_MODEL={model}\n") + f.write(f"HINDSIGHT_EMBED_BANK_ID={bank_id}\n") + if api_key: + f.write(f"HINDSIGHT_EMBED_LLM_API_KEY={api_key}\n") + + CONFIG_FILE.chmod(0o600) + + print() + print("\033[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") + print("\033[32m ✓ Configuration saved!\033[0m") + print("\033[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") + print() + print(f" \033[2mConfig:\033[0m {CONFIG_FILE}") + print() + print(" \033[2mTest with:\033[0m") + print(' \033[36mhindsight-embed retain "Test memory"\033[0m') + print(' \033[36mhindsight-embed recall "test"\033[0m') + print() + + return 0 + + +async def _create_engine(config: dict, logger): + """Create and initialize the memory engine.""" + logger.debug("Setting up environment variables...") + + # 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"] + + logger.debug("Importing MemoryEngine...") + + # 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("No relevant memories found.", flush=True) + + 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.""" + 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 + """ + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose/debug logging" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Configure command + subparsers.add_parser("configure", help="Interactive configuration setup") + + # 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" + ) + + # 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" + ) + + args = parser.parse_args() + + # Setup logging + verbose = getattr(args, 'verbose', False) + logger = setup_logging(verbose) + + 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) + + 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) + + # 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 + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/hindsight-embed/pyproject.toml b/hindsight-embed/pyproject.toml new file mode 100644 index 00000000..209fbc13 --- /dev/null +++ b/hindsight-embed/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hindsight-embed" +version = "0.1.0" +description = "Hindsight embedded CLI - local memory operations without a server" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "hindsight-api>=0.1.11", + "questionary>=2.0.0", +] + +[project.scripts] +hindsight-embed = "hindsight_embed.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["hindsight_embed"] + +[tool.uv.sources] +hindsight-api = { workspace = true } diff --git a/hindsight-embed/test.sh b/hindsight-embed/test.sh new file mode 100755 index 00000000..c767b129 --- /dev/null +++ b/hindsight-embed/test.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# +# 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)" + +echo "=== Hindsight Embed Smoke Test ===" + +# 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 +export HINDSIGHT_EMBED_BANK_ID="test-$$-$(date +%s)" +echo "Using bank ID: $HINDSIGHT_EMBED_BANK_ID" + +# Test 1: Retain a memory +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 "$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 the memory +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 "$OUTPUT" +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 +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) +echo "$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 budget +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 "FAIL: Expected 'Python' in recall output" + exit 1 +fi +echo "PASS: Memory recalled with budget successfully" + +echo "" +echo "=== All tests passed! ===" diff --git a/pyproject.toml b/pyproject.toml index 46a0406b..35e413e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.uv.workspace] -members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-openai", "hindsight-langmem", "hindsight-clients/python"] +members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-openai", "hindsight-langmem", "hindsight-clients/python", "hindsight-embed"] [tool.uv] dev-dependencies = [] diff --git a/uv.lock b/uv.lock index b06caba3..e92ca8ef 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "hindsight-api", "hindsight-client", "hindsight-dev", + "hindsight-embed", ] [[package]] @@ -1488,6 +1489,21 @@ dev = [ { name = "ty", specifier = ">=0.0.1" }, ] +[[package]] +name = "hindsight-embed" +version = "0.1.0" +source = { editable = "hindsight-embed" } +dependencies = [ + { name = "hindsight-api" }, + { name = "questionary" }, +] + +[package.metadata] +requires-dist = [ + { name = "hindsight-api", editable = "hindsight-api" }, + { name = "questionary", specifier = ">=2.0.0" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -2899,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" @@ -3604,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" @@ -4925,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"