Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8500702d67 | ||
|
|
dd8f1aa1df | ||
|
|
e307ce69dd | ||
|
|
ce88fe91cd | ||
|
|
22fd82c05a | ||
|
|
dc1a051f42 | ||
|
|
18d732512c | ||
|
|
3ef4485cfb | ||
|
|
ddf2bd3e8d | ||
|
|
266bcebf00 | ||
|
|
04c2bfe2f5 |
@@ -749,9 +749,9 @@ jobs:
|
||||
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
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Daemon mode support for Hindsight API.
|
||||
|
||||
Provides idle timeout and lockfile management for running as a background daemon.
|
||||
Provides idle timeout for running as a background daemon.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -17,8 +16,9 @@ logger = logging.getLogger(__name__)
|
||||
# Default daemon configuration
|
||||
DEFAULT_DAEMON_PORT = 8888
|
||||
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"
|
||||
|
||||
# Allow override via environment variable for profile-specific logs
|
||||
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
|
||||
|
||||
|
||||
class IdleTimeoutMiddleware:
|
||||
@@ -58,97 +58,27 @@ class IdleTimeoutMiddleware:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
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)
|
||||
# First fork - detach from parent
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"fork #1 failed: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Create new session
|
||||
# Decouple from parent environment
|
||||
os.chdir("/")
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Second fork to prevent zombie processes
|
||||
# Second fork - prevent zombie
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
@@ -181,27 +111,3 @@ def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
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
|
||||
|
||||
@@ -27,7 +27,6 @@ from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
DaemonLock,
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
@@ -131,12 +130,6 @@ def main():
|
||||
default=DEFAULT_IDLE_TIMEOUT,
|
||||
help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lockfile",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Custom lockfile path for daemon mode (default: ~/.hindsight/daemon.lock)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -147,30 +140,10 @@ def main():
|
||||
args.port = DEFAULT_DAEMON_PORT
|
||||
args.host = "127.0.0.1" # Only bind to localhost for security
|
||||
|
||||
# Check if another daemon is already running
|
||||
# Use custom lockfile if provided (for profile support)
|
||||
from pathlib import Path
|
||||
|
||||
lockfile_path = Path(args.lockfile) if args.lockfile else None
|
||||
daemon_lock = DaemonLock(lockfile_path) if lockfile_path else 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
|
||||
# No lockfile needed - port binding prevents duplicate daemons
|
||||
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()
|
||||
|
||||
@@ -26,6 +26,12 @@ export GEMINI_API_KEY="your-key"
|
||||
|
||||
# Option D: Groq (uses openai/gpt-oss-20b for memory extraction)
|
||||
export GROQ_API_KEY="your-key"
|
||||
|
||||
# Option E: Claude Code (uses claude-sonnet-4-20250514, no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=claude-code
|
||||
|
||||
# Option F: OpenAI Codex (uses o3-mini, no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
|
||||
```
|
||||
|
||||
**Step 2: Install the plugin**
|
||||
@@ -41,7 +47,7 @@ openclaw gateway
|
||||
```
|
||||
|
||||
The plugin will automatically:
|
||||
- Start a local Hindsight daemon (port 8888)
|
||||
- Start a local Hindsight daemon (port 9077)
|
||||
- Capture conversations after each turn
|
||||
- Inject relevant memories before agent responses
|
||||
|
||||
@@ -68,6 +74,7 @@ Optional settings in `~/.openclaw/openclaw.json`:
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiPort": 9077,
|
||||
"daemonIdleTimeout": 0,
|
||||
"embedVersion": "latest"
|
||||
}
|
||||
@@ -78,6 +85,7 @@ Optional settings in `~/.openclaw/openclaw.json`:
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `apiPort` - Port for the openclaw profile daemon (default: `9077`)
|
||||
- `daemonIdleTimeout` - Seconds before daemon shuts down from inactivity (default: `0` = never)
|
||||
- `embedVersion` - hindsight-embed version (default: `"latest"`)
|
||||
- `bankMission` - Custom context for the memory bank (optional)
|
||||
@@ -86,12 +94,14 @@ Optional settings in `~/.openclaw/openclaw.json`:
|
||||
|
||||
The plugin auto-detects your LLM provider from these environment variables:
|
||||
|
||||
| Provider | Env Var | Default Model |
|
||||
|----------|---------|---------------|
|
||||
| OpenAI | `OPENAI_API_KEY` | `gpt-4o-mini` |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-20241022` |
|
||||
| Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` |
|
||||
| Groq | `GROQ_API_KEY` | `openai/gpt-oss-20b` |
|
||||
| Provider | Env Var | Default Model | Notes |
|
||||
|----------|---------|---------------|-------|
|
||||
| OpenAI | `OPENAI_API_KEY` | `gpt-4o-mini` | |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-20241022` | |
|
||||
| Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` | |
|
||||
| Groq | `GROQ_API_KEY` | `openai/gpt-oss-20b` | |
|
||||
| Claude Code | `HINDSIGHT_API_LLM_PROVIDER=claude-code` | `claude-sonnet-4-20250514` | No API key needed |
|
||||
| OpenAI Codex | `HINDSIGHT_API_LLM_PROVIDER=openai-codex` | `o3-mini` | No API key needed |
|
||||
|
||||
**Override with explicit config:**
|
||||
|
||||
@@ -133,32 +143,32 @@ Useful for shared memory across multiple OpenClaw instances or production deploy
|
||||
View the daemon config that was written by the plugin:
|
||||
|
||||
```bash
|
||||
cat ~/.hindsight/embed
|
||||
cat ~/.hindsight/profiles/openclaw.env
|
||||
```
|
||||
|
||||
This shows the LLM provider, model, and other settings the daemon is using.
|
||||
This shows the LLM provider, model, port, and other settings the daemon is using.
|
||||
|
||||
### Check Daemon Status
|
||||
|
||||
```bash
|
||||
# Check if daemon is running
|
||||
uvx hindsight-embed@latest daemon status
|
||||
uvx hindsight-embed@latest -p openclaw daemon status
|
||||
|
||||
# View daemon logs
|
||||
tail -f ~/.hindsight/daemon.log
|
||||
tail -f ~/.hindsight/profiles/openclaw.log
|
||||
```
|
||||
|
||||
### Query Memories
|
||||
|
||||
```bash
|
||||
# Search memories
|
||||
uvx hindsight-embed@latest memory recall openclaw "user preferences"
|
||||
uvx hindsight-embed@latest -p openclaw memory recall openclaw "user preferences"
|
||||
|
||||
# View recent memories
|
||||
uvx hindsight-embed@latest memory list openclaw --limit 10
|
||||
uvx hindsight-embed@latest -p openclaw memory list openclaw --limit 10
|
||||
|
||||
# Open web UI
|
||||
uvx hindsight-embed@latest ui
|
||||
# Open web UI (uses openclaw profile's daemon)
|
||||
uvx hindsight-embed@latest -p openclaw ui
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -176,27 +186,40 @@ openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
### Daemon not starting
|
||||
|
||||
```bash
|
||||
# Check daemon status
|
||||
uvx hindsight-embed@latest daemon status
|
||||
# Check daemon status (note: -p openclaw uses the openclaw profile)
|
||||
uvx hindsight-embed@latest -p openclaw daemon status
|
||||
|
||||
# View logs for errors
|
||||
tail -f ~/.hindsight/daemon.log
|
||||
tail -f ~/.hindsight/profiles/openclaw.log
|
||||
|
||||
# Check configuration
|
||||
cat ~/.hindsight/embed
|
||||
cat ~/.hindsight/profiles/openclaw.env
|
||||
|
||||
# List all profiles
|
||||
uvx hindsight-embed@latest profile list
|
||||
```
|
||||
|
||||
### No API key error
|
||||
|
||||
Make sure you've set one of the provider API keys:
|
||||
Make sure you've set one of the provider API keys (or use a provider that doesn't require one):
|
||||
|
||||
```bash
|
||||
# Option 1: OpenAI
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
# or
|
||||
|
||||
# Option 2: Anthropic
|
||||
export ANTHROPIC_API_KEY="your-key"
|
||||
|
||||
# Option 3: Claude Code (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=claude-code
|
||||
|
||||
# Option 4: OpenAI Codex (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
|
||||
|
||||
# Verify it's set
|
||||
echo $OPENAI_API_KEY
|
||||
# or
|
||||
echo $HINDSIGHT_API_LLM_PROVIDER
|
||||
```
|
||||
|
||||
### Verify it's working
|
||||
@@ -208,6 +231,8 @@ tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
|
||||
|
||||
# Should see on startup:
|
||||
# [Hindsight] ✓ Using provider: openai, model: gpt-4o-mini
|
||||
# or
|
||||
# [Hindsight] ✓ Using provider: claude-code, model: claude-sonnet-4-20250514
|
||||
|
||||
# Should see after conversations:
|
||||
# [Hindsight] Retained X messages for session ...
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,31 +153,29 @@ def _start_daemon(config: dict, profile: str | None = None) -> bool:
|
||||
# Get idle timeout from environment or use default
|
||||
idle_timeout = int(os.getenv("HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT", str(DEFAULT_DAEMON_IDLE_TIMEOUT)))
|
||||
|
||||
# Pass profile-specific port and lockfile
|
||||
# Use profile-specific log file
|
||||
daemon_log = paths.log
|
||||
daemon_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Tell hindsight-api daemon where to write its logs
|
||||
env["HINDSIGHT_API_DAEMON_LOG"] = str(daemon_log)
|
||||
|
||||
# Pass profile-specific port (no lockfile - we use port-based discovery)
|
||||
cmd = _find_hindsight_api_command() + [
|
||||
"--daemon",
|
||||
"--idle-timeout",
|
||||
str(idle_timeout),
|
||||
"--port",
|
||||
str(paths.port),
|
||||
"--lockfile",
|
||||
str(paths.lock),
|
||||
]
|
||||
|
||||
# Use profile-specific log file
|
||||
daemon_log = paths.log
|
||||
daemon_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Starting daemon with command: {' '.join(cmd)}", file=sys.stderr)
|
||||
print(f" Log file: {daemon_log}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
# Start daemon with shell redirection (works across forks)
|
||||
# The >> appends to log file, 2>&1 redirects stderr to stdout
|
||||
shell_cmd = f"{' '.join(cmd)} >> {shlex.quote(str(daemon_log))} 2>&1"
|
||||
# Start daemon directly (hindsight-api handles its own log redirection via HINDSIGHT_API_DAEMON_LOG)
|
||||
subprocess.Popen(
|
||||
shell_cmd,
|
||||
shell=True,
|
||||
cmd,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
@@ -261,19 +259,36 @@ def stop_daemon(profile: str | None = None) -> bool:
|
||||
Returns:
|
||||
True if daemon stopped successfully.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
if profile is None:
|
||||
profile = resolve_active_profile()
|
||||
|
||||
# Get profile-specific lockfile
|
||||
# Check if daemon is actually running via health check
|
||||
if not _is_daemon_running(profile):
|
||||
logger.debug(f"Daemon not running for profile '{profile or 'default'}'")
|
||||
return True
|
||||
|
||||
# Get profile-specific port
|
||||
pm = ProfileManager()
|
||||
paths = pm.resolve_profile_paths(profile)
|
||||
lockfile = paths.lock
|
||||
port = paths.port
|
||||
|
||||
# Find PID by port using lsof (works on macOS/Linux, handles stale lockfiles)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pid = int(result.stdout.strip().split()[0])
|
||||
logger.debug(f"Found daemon PID {pid} on port {port}")
|
||||
|
||||
# Send SIGTERM
|
||||
os.kill(pid, 15)
|
||||
|
||||
# Try to kill by PID from lockfile
|
||||
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)
|
||||
@@ -281,8 +296,10 @@ def stop_daemon(profile: str | None = None) -> bool:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
break # Process exited
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"Could not find PID for port {port}")
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError, FileNotFoundError) as e:
|
||||
logger.warning(f"Could not find/kill daemon by port: {e}")
|
||||
|
||||
# Wait for health check to fail (daemon fully stopped)
|
||||
for _ in range(30): # Wait up to 3 seconds
|
||||
|
||||
@@ -140,33 +140,50 @@ class ProfileManager:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def create_profile(self, name: str, config: dict[str, str]):
|
||||
def create_profile(self, name: str, port_or_config: int | dict[str, str], config: dict[str, str] | None = None):
|
||||
"""Create or update a profile.
|
||||
|
||||
Args:
|
||||
name: Profile name.
|
||||
config: Configuration dict (KEY=VALUE pairs).
|
||||
port_or_config: Port number (int) or configuration dict. For backward compatibility,
|
||||
if this is a dict, it's treated as config and port is auto-allocated.
|
||||
config: Configuration dict (KEY=VALUE pairs). Only used if port_or_config is an int.
|
||||
|
||||
Raises:
|
||||
ValueError: If profile name is invalid.
|
||||
ValueError: If profile name is invalid or port is invalid.
|
||||
"""
|
||||
# Handle backward compatibility - allow (name, config) or (name, port, config)
|
||||
if isinstance(port_or_config, dict):
|
||||
# Called with (name, config) - auto-allocate port
|
||||
port = None
|
||||
config = port_or_config
|
||||
else:
|
||||
# Called with (name, port, config)
|
||||
port = port_or_config
|
||||
if config is None:
|
||||
raise ValueError("Config must be provided when port is specified")
|
||||
|
||||
if not name:
|
||||
raise ValueError("Profile name cannot be empty")
|
||||
|
||||
if not name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError(f"Invalid profile name '{name}'. Use alphanumeric chars, hyphens, and underscores.")
|
||||
|
||||
if port is not None and (port < 1024 or port > 65535):
|
||||
raise ValueError(f"Invalid port {port}. Must be between 1024-65535.")
|
||||
|
||||
# Ensure profile directory exists
|
||||
self._ensure_directories()
|
||||
|
||||
# Load metadata to check if profile already exists
|
||||
metadata = self._load_metadata()
|
||||
|
||||
# Allocate port for this profile (preserve existing port if updating)
|
||||
if name in metadata.profiles and "port" in metadata.profiles[name]:
|
||||
port = metadata.profiles[name]["port"]
|
||||
else:
|
||||
port = self._allocate_port(name)
|
||||
# Determine port: use provided port, preserve existing, or allocate new
|
||||
if port is None:
|
||||
if name in metadata.profiles and "port" in metadata.profiles[name]:
|
||||
port = metadata.profiles[name]["port"]
|
||||
else:
|
||||
port = self._allocate_port(name)
|
||||
|
||||
# Write config file
|
||||
config_path = PROFILES_DIR / f"{name}.env"
|
||||
|
||||
+8
-53
@@ -2,6 +2,7 @@
|
||||
#
|
||||
# Smoke test for hindsight-embed CLI with daemon mode
|
||||
# Tests retain and recall operations via the background daemon
|
||||
# Verifies daemon lifecycle, memory retention, and recall functionality
|
||||
#
|
||||
|
||||
set -e
|
||||
@@ -16,8 +17,8 @@ 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"
|
||||
if [ -z "$HINDSIGHT_API_LLM_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "Error: HINDSIGHT_API_LLM_API_KEY or OPENAI_API_KEY is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -27,60 +28,14 @@ echo "Using bank ID: $BANK_ID"
|
||||
echo "Script dir: $SCRIPT_DIR"
|
||||
echo "API dir: $API_DIR"
|
||||
|
||||
# Debug: Check if hindsight CLI is available
|
||||
echo ""
|
||||
echo "Checking hindsight CLI availability..."
|
||||
if command -v hindsight &> /dev/null; then
|
||||
echo " hindsight CLI found at: $(which hindsight)"
|
||||
else
|
||||
echo " hindsight CLI not in PATH"
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
echo " Found at ~/.local/bin/hindsight"
|
||||
else
|
||||
echo " Not found at ~/.local/bin/hindsight - attempting installation..."
|
||||
# Try to install the CLI
|
||||
if curl -fsSL https://hindsight.vectorize.io/get-cli | bash; then
|
||||
echo " CLI installation completed"
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
echo " CLI now available at ~/.local/bin/hindsight"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
echo " WARNING: CLI still not found after installation"
|
||||
ls -la ~/.local/bin/ 2>/dev/null || echo " ~/.local/bin does not exist"
|
||||
fi
|
||||
else
|
||||
echo " WARNING: CLI installation failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show environment info for debugging
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " HINDSIGHT_EMBED_LLM_PROVIDER: ${HINDSIGHT_EMBED_LLM_PROVIDER:-not set}"
|
||||
echo " HINDSIGHT_EMBED_LLM_MODEL: ${HINDSIGHT_EMBED_LLM_MODEL:-not set}"
|
||||
echo " HINDSIGHT_EMBED_LLM_API_KEY: ${HINDSIGHT_EMBED_LLM_API_KEY:+set (hidden)}"
|
||||
echo " PATH includes ~/.local/bin: $(echo $PATH | grep -q "$HOME/.local/bin" && echo yes || echo no)"
|
||||
|
||||
# Final check that CLI is available before proceeding
|
||||
echo ""
|
||||
echo "Final CLI check before tests..."
|
||||
CLI_PATH=""
|
||||
if [ -f ~/.local/bin/hindsight ]; then
|
||||
CLI_PATH="$HOME/.local/bin/hindsight"
|
||||
elif command -v hindsight &> /dev/null; then
|
||||
CLI_PATH="$(which hindsight)"
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_PATH" ]; then
|
||||
echo " CLI found at: $CLI_PATH"
|
||||
echo " CLI version: $($CLI_PATH --version 2>&1 || echo 'unknown')"
|
||||
else
|
||||
echo " ERROR: hindsight CLI not found. Tests cannot proceed."
|
||||
echo " The hindsight-embed package forwards commands to the hindsight CLI."
|
||||
echo " Please ensure the CLI is installed or check the get-cli installer output above."
|
||||
exit 1
|
||||
fi
|
||||
echo " HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-not set}"
|
||||
echo " HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-not set}"
|
||||
echo " HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:+set (hidden)}"
|
||||
echo " Python: $(python3 --version 2>&1)"
|
||||
echo " uv: $(uv --version 2>&1)"
|
||||
|
||||
# Stop any existing daemon
|
||||
echo ""
|
||||
|
||||
@@ -5,9 +5,15 @@ Biomimetic long-term memory for [OpenClaw](https://openclaw.ai) using [Hindsight
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure your LLM provider
|
||||
# 1. Configure your LLM provider for memory extraction
|
||||
# Option A: OpenAI
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
openclaw config set 'agents.defaults.models."openai/gpt-4o-mini"' '{}'
|
||||
|
||||
# Option B: Claude Code (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=claude-code
|
||||
|
||||
# Option C: OpenAI Codex (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
|
||||
|
||||
# 2. Install and enable the plugin
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
@@ -24,6 +30,40 @@ For full documentation, configuration options, troubleshooting, and development
|
||||
|
||||
**[OpenClaw Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/openclaw)**
|
||||
|
||||
## Development
|
||||
|
||||
To test local changes to the Hindsight package before publishing:
|
||||
|
||||
1. Add `embedPackagePath` to your plugin config in `~/.openclaw/openclaw.json`:
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"embedPackagePath": "/path/to/hindsight-wt3/hindsight-embed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. The plugin will use `uv run --directory <path> hindsight-embed` instead of `uvx hindsight-embed@latest`
|
||||
|
||||
3. To use a specific profile for testing:
|
||||
```bash
|
||||
# Check daemon status
|
||||
uvx hindsight-embed@latest -p openclaw daemon status
|
||||
|
||||
# View logs
|
||||
tail -f ~/.hindsight/profiles/openclaw.log
|
||||
|
||||
# List profiles
|
||||
uvx hindsight-embed@latest profile list
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Hindsight Documentation](https://vectorize.io/hindsight)
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
},
|
||||
"llmProvider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider for Hindsight memory (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama'). Takes priority over auto-detection but not over HINDSIGHT_API_LLM_PROVIDER env var.",
|
||||
"enum": ["openai", "anthropic", "gemini", "groq", "ollama"]
|
||||
"description": "LLM provider for Hindsight memory (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama', 'openai-codex', 'claude-code'). Takes priority over auto-detection but not over HINDSIGHT_API_LLM_PROVIDER env var.",
|
||||
"enum": ["openai", "anthropic", "gemini", "groq", "ollama", "openai-codex", "claude-code"]
|
||||
},
|
||||
"llmModel": {
|
||||
"type": "string",
|
||||
@@ -37,6 +37,15 @@
|
||||
"llmApiKeyEnv": {
|
||||
"type": "string",
|
||||
"description": "Name of the env var holding the API key (e.g. 'MY_CUSTOM_KEY'). If not set, uses the standard env var for the chosen provider."
|
||||
},
|
||||
"embedPackagePath": {
|
||||
"type": "string",
|
||||
"description": "Local path to hindsight package for development (e.g. '/path/to/hindsight'). When set, uses 'uv run --directory <path>' instead of 'uvx hindsight-embed@latest'."
|
||||
},
|
||||
"apiPort": {
|
||||
"type": "number",
|
||||
"description": "Port for the openclaw profile daemon (default: 9077)",
|
||||
"default": 9077
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -69,6 +78,14 @@
|
||||
"llmApiKeyEnv": {
|
||||
"label": "API Key Env Var",
|
||||
"placeholder": "e.g. MY_CUSTOM_API_KEY (optional)"
|
||||
},
|
||||
"embedPackagePath": {
|
||||
"label": "Local Package Path (Dev)",
|
||||
"placeholder": "/path/to/hindsight (for local development)"
|
||||
},
|
||||
"apiPort": {
|
||||
"label": "API Port",
|
||||
"placeholder": "9077 (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,28 @@ export class HindsightClient {
|
||||
private llmApiKey: string;
|
||||
private llmModel?: string;
|
||||
private embedVersion: string;
|
||||
private embedPackagePath?: string;
|
||||
|
||||
constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion: string = 'latest') {
|
||||
constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion: string = 'latest', embedPackagePath?: string) {
|
||||
this.llmProvider = llmProvider;
|
||||
this.llmApiKey = llmApiKey;
|
||||
this.llmModel = llmModel;
|
||||
this.embedVersion = embedVersion || 'latest';
|
||||
this.embedPackagePath = embedPackagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command prefix to run hindsight-embed (either local or from PyPI)
|
||||
*/
|
||||
private getEmbedCommandPrefix(): string {
|
||||
if (this.embedPackagePath) {
|
||||
// Local package: uv run --directory <path> hindsight-embed
|
||||
return `uv run --directory ${this.embedPackagePath} hindsight-embed`;
|
||||
} else {
|
||||
// PyPI package: uvx hindsight-embed@version
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
return `uvx ${embedPackage}`;
|
||||
}
|
||||
}
|
||||
|
||||
setBankId(bankId: string): void {
|
||||
@@ -34,11 +50,11 @@ export class HindsightClient {
|
||||
}
|
||||
|
||||
const escapedMission = mission.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} bank mission ${this.bankId} '${escapedMission}'`;
|
||||
const embedCmd = this.getEmbedCommandPrefix();
|
||||
const cmd = `${embedCmd} --profile openclaw bank mission ${this.bankId} '${escapedMission}'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
const { stdout } = await execAsync(cmd);
|
||||
console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`);
|
||||
} catch (error) {
|
||||
// Don't fail if mission set fails - bank might not exist yet, will be created on first retain
|
||||
@@ -46,29 +62,15 @@ export class HindsightClient {
|
||||
}
|
||||
}
|
||||
|
||||
private getEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
HINDSIGHT_EMBED_LLM_PROVIDER: this.llmProvider,
|
||||
HINDSIGHT_EMBED_LLM_API_KEY: this.llmApiKey,
|
||||
};
|
||||
|
||||
if (this.llmModel) {
|
||||
env.HINDSIGHT_EMBED_LLM_MODEL = this.llmModel;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async retain(request: RetainRequest): Promise<RetainResponse> {
|
||||
const content = request.content.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const docId = request.document_id || 'conversation';
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} memory retain ${this.bankId} '${content}' --doc-id '${docId}' --async`;
|
||||
const embedCmd = this.getEmbedCommandPrefix();
|
||||
const cmd = `${embedCmd} --profile openclaw memory retain ${this.bankId} '${content}' --doc-id '${docId}' --async`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
const { stdout } = await execAsync(cmd);
|
||||
console.log(`[Hindsight] Retained (async): ${stdout.trim()}`);
|
||||
|
||||
// Return a simple response
|
||||
@@ -86,11 +88,11 @@ export class HindsightClient {
|
||||
const query = request.query.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const maxTokens = request.max_tokens || 1024;
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
|
||||
const embedCmd = this.getEmbedCommandPrefix();
|
||||
const cmd = `${embedCmd} --profile openclaw memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
const { stdout } = await execAsync(cmd);
|
||||
|
||||
// Parse JSON output - returns { entities: {...}, results: [...] }
|
||||
const response = JSON.parse(stdout);
|
||||
|
||||
@@ -15,6 +15,7 @@ export class HindsightEmbedManager {
|
||||
private llmBaseUrl?: string;
|
||||
private daemonIdleTimeout: number;
|
||||
private embedVersion: string;
|
||||
private embedPackagePath?: string;
|
||||
|
||||
constructor(
|
||||
port: number,
|
||||
@@ -23,10 +24,12 @@ export class HindsightEmbedManager {
|
||||
llmModel?: string,
|
||||
llmBaseUrl?: string,
|
||||
daemonIdleTimeout: number = 0, // Default: never timeout
|
||||
embedVersion: string = 'latest' // Default: latest
|
||||
embedVersion: string = 'latest', // Default: latest
|
||||
embedPackagePath?: string // Local path to hindsight package
|
||||
) {
|
||||
this.port = 8888; // hindsight-embed daemon uses same port as API
|
||||
this.baseUrl = `http://127.0.0.1:8888`;
|
||||
// Use the configured port (default: 9077 from config)
|
||||
this.port = port;
|
||||
this.baseUrl = `http://127.0.0.1:${port}`;
|
||||
this.embedDir = join(homedir(), '.openclaw', 'hindsight-embed');
|
||||
this.llmProvider = llmProvider;
|
||||
this.llmApiKey = llmApiKey;
|
||||
@@ -34,21 +37,36 @@ export class HindsightEmbedManager {
|
||||
this.llmBaseUrl = llmBaseUrl;
|
||||
this.daemonIdleTimeout = daemonIdleTimeout;
|
||||
this.embedVersion = embedVersion || 'latest';
|
||||
this.embedPackagePath = embedPackagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command to run hindsight-embed (either local or from PyPI)
|
||||
*/
|
||||
private getEmbedCommand(): string[] {
|
||||
if (this.embedPackagePath) {
|
||||
// Local package: uv run --directory <path> hindsight-embed
|
||||
return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed'];
|
||||
} else {
|
||||
// PyPI package: uvx hindsight-embed@version
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
return ['uvx', embedPackage];
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
console.log(`[Hindsight] Starting hindsight-embed daemon...`);
|
||||
|
||||
// Build environment variables
|
||||
// Build environment variables using standard HINDSIGHT_API_LLM_* variables
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HINDSIGHT_EMBED_LLM_PROVIDER: this.llmProvider,
|
||||
HINDSIGHT_EMBED_LLM_API_KEY: this.llmApiKey,
|
||||
HINDSIGHT_API_LLM_PROVIDER: this.llmProvider,
|
||||
HINDSIGHT_API_LLM_API_KEY: this.llmApiKey,
|
||||
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: this.daemonIdleTimeout.toString(),
|
||||
};
|
||||
|
||||
if (this.llmModel) {
|
||||
env['HINDSIGHT_EMBED_LLM_MODEL'] = this.llmModel;
|
||||
env['HINDSIGHT_API_LLM_MODEL'] = this.llmModel;
|
||||
}
|
||||
|
||||
// Pass through base URL for OpenAI-compatible providers (OpenRouter, etc.)
|
||||
@@ -62,16 +80,16 @@ export class HindsightEmbedManager {
|
||||
env['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
|
||||
}
|
||||
|
||||
// Write env vars to ~/.hindsight/config.env for daemon persistence
|
||||
await this.writeConfigEnv(env);
|
||||
// Configure "openclaw" profile using hindsight-embed configure (non-interactive)
|
||||
console.log('[Hindsight] Configuring "openclaw" profile...');
|
||||
await this.configureProfile(env);
|
||||
|
||||
// Start hindsight-embed daemon (it manages itself)
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
// Start hindsight-embed daemon with openclaw profile
|
||||
const embedCmd = this.getEmbedCommand();
|
||||
const startDaemon = spawn(
|
||||
'uvx',
|
||||
[embedPackage, 'daemon', 'start'],
|
||||
embedCmd[0],
|
||||
[...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'start'],
|
||||
{
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
@@ -114,8 +132,8 @@ export class HindsightEmbedManager {
|
||||
async stop(): Promise<void> {
|
||||
console.log('[Hindsight] Stopping hindsight-embed daemon...');
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const stopDaemon = spawn('uvx', [embedPackage, 'daemon', 'stop'], {
|
||||
const embedCmd = this.getEmbedCommand();
|
||||
const stopDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'stop'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
@@ -172,75 +190,60 @@ export class HindsightEmbedManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async writeConfigEnv(env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const hindsightDir = join(homedir(), '.hindsight');
|
||||
const embedConfigPath = join(hindsightDir, 'embed');
|
||||
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
|
||||
// Build profile create command args with --merge, --port and --env flags
|
||||
// Use --merge to allow updating existing profile
|
||||
const createArgs = ['profile', 'create', 'openclaw', '--merge', '--port', this.port.toString()];
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(hindsightDir, { recursive: true });
|
||||
|
||||
// Read existing config to preserve extra settings
|
||||
let existingContent = '';
|
||||
let extraSettings: string[] = [];
|
||||
try {
|
||||
existingContent = await fs.readFile(embedConfigPath, 'utf-8');
|
||||
// Extract non-LLM settings (like FORCE_CPU flags)
|
||||
const lines = existingContent.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('HINDSIGHT_EMBED_LLM_') &&
|
||||
!trimmed.startsWith('HINDSIGHT_API_LLM_') &&
|
||||
!trimmed.startsWith('HINDSIGHT_EMBED_BANK_ID') &&
|
||||
!trimmed.startsWith('HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT')) {
|
||||
extraSettings.push(line);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet, that's fine
|
||||
}
|
||||
|
||||
// Build config file with header
|
||||
const configLines: string[] = [
|
||||
'# Hindsight Embed Configuration',
|
||||
'# Generated by OpenClaw Hindsight plugin',
|
||||
'',
|
||||
// Add all environment variables as --env flags
|
||||
const envVars = [
|
||||
'HINDSIGHT_API_LLM_PROVIDER',
|
||||
'HINDSIGHT_API_LLM_MODEL',
|
||||
'HINDSIGHT_API_LLM_API_KEY',
|
||||
'HINDSIGHT_API_LLM_BASE_URL',
|
||||
'HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT',
|
||||
'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
|
||||
'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
|
||||
];
|
||||
|
||||
// Add LLM config
|
||||
if (env.HINDSIGHT_EMBED_LLM_PROVIDER) {
|
||||
configLines.push(`HINDSIGHT_EMBED_LLM_PROVIDER=${env.HINDSIGHT_EMBED_LLM_PROVIDER}`);
|
||||
}
|
||||
if (env.HINDSIGHT_EMBED_LLM_MODEL) {
|
||||
configLines.push(`HINDSIGHT_EMBED_LLM_MODEL=${env.HINDSIGHT_EMBED_LLM_MODEL}`);
|
||||
}
|
||||
if (env.HINDSIGHT_EMBED_LLM_API_KEY) {
|
||||
configLines.push(`HINDSIGHT_EMBED_LLM_API_KEY=${env.HINDSIGHT_EMBED_LLM_API_KEY}`);
|
||||
}
|
||||
if (env.HINDSIGHT_API_LLM_BASE_URL) {
|
||||
configLines.push(`HINDSIGHT_API_LLM_BASE_URL=${env.HINDSIGHT_API_LLM_BASE_URL}`);
|
||||
}
|
||||
if (env.HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT) {
|
||||
configLines.push(`HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT=${env.HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT}`);
|
||||
for (const envVar of envVars) {
|
||||
if (env[envVar]) {
|
||||
createArgs.push('--env', `${envVar}=${env[envVar]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add platform-specific config (macOS FORCE_CPU flags)
|
||||
if (env.HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU) {
|
||||
configLines.push(`HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=${env.HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU}`);
|
||||
}
|
||||
if (env.HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU) {
|
||||
configLines.push(`HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=${env.HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU}`);
|
||||
}
|
||||
// Run profile create command (non-interactive, overwrites if exists)
|
||||
const embedCmd = this.getEmbedCommand();
|
||||
const create = spawn(embedCmd[0], [...embedCmd.slice(1), ...createArgs], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Add extra settings if they exist
|
||||
if (extraSettings.length > 0) {
|
||||
configLines.push('');
|
||||
configLines.push('# Additional settings');
|
||||
configLines.push(...extraSettings);
|
||||
}
|
||||
let output = '';
|
||||
create.stdout?.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
console.log(`[Hindsight] ${text.trim()}`);
|
||||
});
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(embedConfigPath, configLines.join('\n') + '\n', 'utf-8');
|
||||
console.log(`[Hindsight] Wrote config to ${embedConfigPath}`);
|
||||
create.stderr?.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
console.error(`[Hindsight] ${text.trim()}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
create.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('[Hindsight] Profile "openclaw" configured successfully');
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Profile create failed with code ${code}: ${output}`));
|
||||
}
|
||||
});
|
||||
|
||||
create.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const PROVIDER_DETECTION = [
|
||||
{ name: 'gemini', keyEnv: 'GEMINI_API_KEY', defaultModel: 'gemini-2.5-flash' },
|
||||
{ name: 'groq', keyEnv: 'GROQ_API_KEY', defaultModel: 'openai/gpt-oss-20b' },
|
||||
{ name: 'ollama', keyEnv: '', defaultModel: 'llama3.2' },
|
||||
{ name: 'openai-codex', keyEnv: '', defaultModel: 'gpt-5.2-codex' },
|
||||
{ name: 'claude-code', keyEnv: '', defaultModel: 'claude-sonnet-4-5-20250929' },
|
||||
];
|
||||
|
||||
function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
@@ -52,7 +54,9 @@ function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
|
||||
// Priority 1: If provider is explicitly set via env var, use that
|
||||
if (overrideProvider) {
|
||||
if (!overrideKey && overrideProvider !== 'ollama') {
|
||||
// Providers that don't require an API key (use OAuth or local models)
|
||||
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
|
||||
if (!overrideKey && !noKeyRequired.includes(overrideProvider)) {
|
||||
throw new Error(
|
||||
`HINDSIGHT_API_LLM_PROVIDER is set to "${overrideProvider}" but HINDSIGHT_API_LLM_API_KEY is not set.\n` +
|
||||
`Please set: export HINDSIGHT_API_LLM_API_KEY=your-api-key`
|
||||
@@ -81,7 +85,9 @@ function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
apiKey = process.env[providerInfo.keyEnv] || '';
|
||||
}
|
||||
|
||||
if (!apiKey && pluginConfig.llmProvider !== 'ollama') {
|
||||
// Providers that don't require an API key (use OAuth or local models)
|
||||
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
|
||||
if (!apiKey && !noKeyRequired.includes(pluginConfig.llmProvider)) {
|
||||
const keySource = pluginConfig.llmApiKeyEnv || providerInfo?.keyEnv || 'unknown';
|
||||
throw new Error(
|
||||
`Plugin config llmProvider is set to "${pluginConfig.llmProvider}" but no API key found.\n` +
|
||||
@@ -103,8 +109,9 @@ function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
for (const providerInfo of PROVIDER_DETECTION) {
|
||||
const apiKey = providerInfo.keyEnv ? process.env[providerInfo.keyEnv] : '';
|
||||
|
||||
// Skip ollama in auto-detection (must be explicitly requested)
|
||||
if (providerInfo.name === 'ollama') {
|
||||
// Skip providers that don't use API keys in auto-detection (must be explicitly requested)
|
||||
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
|
||||
if (noKeyRequired.includes(providerInfo.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -125,11 +132,14 @@ function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
`Option 1: Set a standard provider API key (auto-detect):\n` +
|
||||
` export OPENAI_API_KEY=sk-your-key # Uses gpt-4o-mini\n` +
|
||||
` export ANTHROPIC_API_KEY=your-key # Uses claude-3-5-haiku\n` +
|
||||
` export GEMINI_API_KEY=your-key # Uses gemini-2.0-flash-exp\n` +
|
||||
` export GROQ_API_KEY=your-key # Uses llama-3.3-70b-versatile\n\n` +
|
||||
`Option 2: Set llmProvider in openclaw.json plugin config:\n` +
|
||||
` export GEMINI_API_KEY=your-key # Uses gemini-2.5-flash\n` +
|
||||
` export GROQ_API_KEY=your-key # Uses openai/gpt-oss-20b\n\n` +
|
||||
`Option 2: Use Codex or Claude Code (no API key needed):\n` +
|
||||
` export HINDSIGHT_API_LLM_PROVIDER=openai-codex # Requires 'codex auth login'\n` +
|
||||
` export HINDSIGHT_API_LLM_PROVIDER=claude-code # Requires Claude Code CLI\n\n` +
|
||||
`Option 3: Set llmProvider in openclaw.json plugin config:\n` +
|
||||
` "llmProvider": "openai", "llmModel": "gpt-4o-mini"\n\n` +
|
||||
`Option 3: Override with Hindsight-specific env vars:\n` +
|
||||
`Option 4: Override with Hindsight-specific env vars:\n` +
|
||||
` export HINDSIGHT_API_LLM_PROVIDER=openai\n` +
|
||||
` export HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n` +
|
||||
` export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n` +
|
||||
@@ -147,6 +157,7 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
|
||||
embedPort: config.embedPort || 0,
|
||||
daemonIdleTimeout: config.daemonIdleTimeout !== undefined ? config.daemonIdleTimeout : 0,
|
||||
embedVersion: config.embedVersion || 'latest',
|
||||
embedPackagePath: config.embedPackagePath,
|
||||
llmProvider: config.llmProvider,
|
||||
llmModel: config.llmModel,
|
||||
llmApiKeyEnv: config.llmApiKeyEnv,
|
||||
@@ -178,9 +189,9 @@ export default function (api: MoltbotPluginAPI) {
|
||||
}
|
||||
console.log(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
|
||||
|
||||
// Determine port
|
||||
const port = pluginConfig.embedPort || Math.floor(Math.random() * 10000) + 10000;
|
||||
console.log(`[Hindsight] Port: ${port}`);
|
||||
// Get API port from config (default: 9077)
|
||||
const apiPort = pluginConfig.apiPort || 9077;
|
||||
console.log(`[Hindsight] API Port: ${apiPort}`);
|
||||
|
||||
// Initialize in background (non-blocking)
|
||||
console.log('[Hindsight] Starting initialization in background...');
|
||||
@@ -189,13 +200,14 @@ export default function (api: MoltbotPluginAPI) {
|
||||
// Initialize embed manager
|
||||
console.log('[Hindsight] Creating HindsightEmbedManager...');
|
||||
embedManager = new HindsightEmbedManager(
|
||||
port,
|
||||
apiPort,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.model,
|
||||
llmConfig.baseUrl,
|
||||
pluginConfig.daemonIdleTimeout,
|
||||
pluginConfig.embedVersion
|
||||
pluginConfig.embedVersion,
|
||||
pluginConfig.embedPackagePath
|
||||
);
|
||||
|
||||
// Start the embedded server
|
||||
@@ -204,7 +216,7 @@ export default function (api: MoltbotPluginAPI) {
|
||||
|
||||
// Initialize client
|
||||
console.log('[Hindsight] Creating HindsightClient...');
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion);
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
||||
|
||||
// Use openclaw bank
|
||||
console.log(`[Hindsight] Using bank: ${BANK_NAME}`);
|
||||
@@ -263,21 +275,22 @@ export default function (api: MoltbotPluginAPI) {
|
||||
console.log('[Hindsight] Reinitializing daemon...');
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
const llmConfig = detectLLMConfig(pluginConfig);
|
||||
const port = pluginConfig.embedPort || Math.floor(Math.random() * 10000) + 10000;
|
||||
const apiPort = pluginConfig.apiPort || 9077;
|
||||
|
||||
embedManager = new HindsightEmbedManager(
|
||||
port,
|
||||
apiPort,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.model,
|
||||
llmConfig.baseUrl,
|
||||
pluginConfig.daemonIdleTimeout,
|
||||
pluginConfig.embedVersion
|
||||
pluginConfig.embedVersion,
|
||||
pluginConfig.embedPackagePath
|
||||
);
|
||||
|
||||
await embedManager.start();
|
||||
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion);
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
||||
client.setBankId(BANK_NAME);
|
||||
|
||||
if (pluginConfig.bankMission) {
|
||||
|
||||
@@ -32,9 +32,11 @@ export interface PluginConfig {
|
||||
embedPort?: number;
|
||||
daemonIdleTimeout?: number; // Seconds before daemon shuts down (0 = never)
|
||||
embedVersion?: string; // hindsight-embed version (default: "latest")
|
||||
embedPackagePath?: string; // Local path to hindsight package (e.g. '/path/to/hindsight')
|
||||
llmProvider?: string; // LLM provider override (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama')
|
||||
llmModel?: string; // LLM model override (e.g. 'gpt-4o-mini', 'claude-3-5-haiku-20241022')
|
||||
llmApiKeyEnv?: string; // Env var name holding the API key (e.g. 'MY_CUSTOM_KEY')
|
||||
apiPort?: number; // Port for openclaw profile daemon (default: 9077)
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
|
||||
Reference in New Issue
Block a user