* feat: add @vectorize-io/hindsight-embed daemon lifecycle package
Create a new top-level `hindsight-embed-npm/` package that owns the daemon
lifecycle for the Python `hindsight-embed` CLI: spawning via `uvx`, writing
the profile, waiting for `/health`, and shutting down. Nothing more.
Deliberately does not ship an HTTP client — `@vectorize-io/hindsight-client`
already covers retain / recall / reflect / createBank against the Hindsight
API, and the two packages compose: once `manager.start()` returns, consumers
talk to the daemon via `new HindsightClient({ baseUrl: manager.getBaseUrl() })`.
`HindsightEmbedManagerOptions.env` forwards an arbitrary `Record<string,
string>` to both the daemon process and the profile config via `--env K=V`,
and `extraProfileCreateArgs` / `extraDaemonStartArgs` escape hatches cover
any new CLI flag without waiting for a wrapper release.
Refactor `hindsight-integrations/openclaw` to consume both packages:
`HindsightEmbedManager` for daemon lifecycle in local mode, `HindsightClient`
for all HTTP memory operations. Drop the bespoke subprocess/HTTP client that
used to live in openclaw. The retain queue stays local to openclaw (it's a
client-side reliability workaround with a single consumer today — will move
to the client package or server-side when a second consumer needs it).
Wire the new package into the main release pipeline (versioned alongside
the other core packages, published from `v*` tags) and add a CI build job.
* docs: add Embedded Node.js SDK page for @vectorize-io/hindsight-embed
* refactor: rename hindsight-embed-npm to hindsight-all, restructure docs sidebar
The Node package previously named @vectorize-io/hindsight-embed was
semantically misnamed: hindsight-embed (Python) is a CLI tool, while what
this Node package actually provides is the Node equivalent of hindsight-all
— a programmatic lifecycle manager for a local Hindsight daemon. Rename to
match.
Package rename
- hindsight-embed-npm/ → hindsight-all-npm/ (git mv, history preserved)
- @vectorize-io/hindsight-embed → @vectorize-io/hindsight-all
- class HindsightEmbedManager → HindsightServer (matches Python hindsight-all)
- HindsightEmbedManagerOptions → HindsightServerOptions
- src/manager.ts → src/server.ts, src/manager.test.ts → src/server.test.ts
- openclaw (index.ts, backfill.ts, tests) and the claude-code Python port
updated to reference the new names
Docs restructure
- Split sdks/python.md: now client-only content. New sdks/hindsight-all.md
covers the programmatic hindsight-all Python package (HindsightServer and
HindsightEmbedded).
- Rename sdks/embed-npm.md → sdks/hindsight-all-npm.md with HindsightServer
examples.
- New "Installation" sidebar section, placed after Hosting, containing
Docker / Kubernetes / Bare Metal (anchor links into developer/installation)
plus Programmatic API (Python), Programmatic API (Node.js), and Daemon CLI.
- Add si-docker, si-kubernetes, si-nodedotjs, lu-hard-drive to the sidebar
ICON_MAP.
Docs dev-server fix
- docusaurus.config.ts: drop the flaky NODE_ENV sniff for including the
"Next" version. Use INCLUDE_CURRENT_VERSION exclusively. NODE_ENV was
unreliable across hot-reload paths and caused the Next version to
disappear intermittently when editing files.
- scripts/dev/start-docs.sh: export INCLUDE_CURRENT_VERSION=true so local
dev always shows Next; production builds leave it unset.
Lockfile cleanup
- package-lock.json and hindsight-integrations/openclaw/package-lock.json
had extraneous hindsight-embed-npm blocks left over from the rename.
Removed manually and verified with npm install.
* ci: fix openclaw jobs by pre-building workspace deps; regenerate docs-skill
The build-openclaw-integration and test-openclaw-integration jobs failed
with "Failed to resolve entry for package @vectorize-io/hindsight-all"
because openclaw depends on two monorepo workspaces via `file:` deps
(@vectorize-io/hindsight-client and @vectorize-io/hindsight-all) whose
`dist/` directories are gitignored and never built before openclaw's npm ci.
Both jobs now install the root workspace and build the two deps first,
mirroring the release-control-plane pattern.
Also regenerate skills/hindsight-docs/references/* via
./scripts/generate-docs-skill.sh:
- new skill pages for sdks/hindsight-all{.md,-npm.md}
- updated skill pages for sdks/embed.md and sdks/python.md to match
the new H1s and split content
- incidental refreshes to changelog/index.md, developer/models.md,
openapi.json, and uv.lock that verify-generated-files picked up
* ci: build openclaw before running tests so symlink test can realpath dist
333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Hindsight-embed daemon lifecycle management.
|
|
|
|
Port of: HindsightServer in @vectorize-io/hindsight-all, adapted for Python
|
|
subprocess calls from ephemeral hook processes.
|
|
|
|
Manages three connection modes (same as Openclaw):
|
|
1. External API — user provides hindsightApiUrl (skip daemon entirely)
|
|
2. Existing local server — user already has hindsight running
|
|
3. Auto-managed daemon — plugin starts/stops hindsight-embed
|
|
|
|
In Claude Code's ephemeral model, daemon state is tracked via files in
|
|
$CLAUDE_PLUGIN_DATA/state/. The daemon itself is a background OS process
|
|
managed by hindsight-embed's built-in daemon command.
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from .llm import detect_llm_config, get_llm_env_vars
|
|
from .state import read_state, write_state
|
|
|
|
DAEMON_STATE_FILE = "daemon.json"
|
|
PROFILE_NAME = "claude-code"
|
|
|
|
|
|
def _get_embed_command(config: dict) -> list:
|
|
"""Get the command to run hindsight-embed.
|
|
|
|
Port of: getEmbedCommand() in @vectorize-io/hindsight-all
|
|
"""
|
|
embed_path = config.get("embedPackagePath")
|
|
if embed_path:
|
|
return ["uv", "run", "--directory", embed_path, "hindsight-embed"]
|
|
|
|
version = config.get("embedVersion", "latest")
|
|
package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest"
|
|
return ["uvx", package]
|
|
|
|
|
|
def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess:
|
|
"""Run a hindsight-embed command and return the result."""
|
|
cmd = _get_embed_command(config) + args
|
|
run_env = dict(os.environ)
|
|
if env:
|
|
run_env.update(env)
|
|
return subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=run_env,
|
|
)
|
|
|
|
|
|
def _is_embed_available(config: dict) -> bool:
|
|
"""Quick check if hindsight-embed is available on PATH.
|
|
|
|
Avoids the slow uvx download path when the tool isn't installed.
|
|
"""
|
|
import shutil
|
|
|
|
embed_path = config.get("embedPackagePath")
|
|
if embed_path:
|
|
return os.path.isdir(embed_path)
|
|
# Check for uvx or hindsight-embed on PATH
|
|
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
|
|
|
|
|
|
def _check_health(base_url: str, timeout: int = 2) -> bool:
|
|
"""Quick health check against a Hindsight server."""
|
|
try:
|
|
url = f"{base_url.rstrip('/')}/health"
|
|
req = urllib.request.Request(url, method="GET")
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.status == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str:
|
|
"""Determine the API URL, optionally starting daemon if needed.
|
|
|
|
Returns the API URL to use for recall/retain, handling all three modes.
|
|
|
|
Connection mode priority:
|
|
1. External API (hindsightApiUrl configured)
|
|
2. Existing local server (check port health)
|
|
3. Auto-managed daemon (only if allow_daemon_start=True)
|
|
|
|
The allow_daemon_start flag prevents the recall hook (10s timeout) from
|
|
blocking on a 30s daemon startup. Only the retain hook (async, 15s) should
|
|
attempt daemon start.
|
|
"""
|
|
# Mode 1: External API
|
|
external_url = config.get("hindsightApiUrl")
|
|
if external_url:
|
|
if debug_fn:
|
|
debug_fn(f"Using external API: {external_url}")
|
|
return external_url
|
|
|
|
# Mode 2 & 3: Local server
|
|
port = config.get("apiPort", 9077)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
# Check if something is already running on this port
|
|
if _check_health(base_url):
|
|
if debug_fn:
|
|
debug_fn(f"Existing server healthy on port {port}")
|
|
return base_url
|
|
|
|
# Mode 3: Auto-start daemon (only when allowed)
|
|
if not allow_daemon_start:
|
|
raise RuntimeError(
|
|
f"No Hindsight server on port {port}. Set hindsightApiUrl for external "
|
|
f"API, start hindsight-embed manually, or wait for the retain hook to "
|
|
f"auto-start the daemon."
|
|
)
|
|
|
|
if debug_fn:
|
|
debug_fn(f"No server on port {port}, attempting daemon start")
|
|
|
|
try:
|
|
_ensure_daemon_running(config, port, debug_fn)
|
|
except Exception as e:
|
|
if debug_fn:
|
|
debug_fn(f"Daemon start failed: {e}")
|
|
raise RuntimeError(
|
|
"No Hindsight server available. Set hindsightApiUrl for external API, "
|
|
"or ensure hindsight-embed is installed for local daemon mode."
|
|
) from e
|
|
|
|
return base_url
|
|
|
|
|
|
def _ensure_daemon_running(config: dict, port: int, debug_fn=None):
|
|
"""Start the hindsight-embed daemon if not already running.
|
|
|
|
Port of: HindsightServer.start() in @vectorize-io/hindsight-all
|
|
"""
|
|
# Fast-fail if hindsight-embed toolchain is not available
|
|
if not _is_embed_available(config):
|
|
raise RuntimeError(
|
|
"hindsight-embed not found (uvx not on PATH). "
|
|
"Install with: pip install hindsight-embed, or set hindsightApiUrl."
|
|
)
|
|
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
|
|
# Detect LLM config (needed for daemon's fact extraction)
|
|
try:
|
|
llm_config = detect_llm_config(config)
|
|
except RuntimeError as e:
|
|
raise RuntimeError(f"Cannot start daemon: {e}") from e
|
|
|
|
llm_env = get_llm_env_vars(llm_config)
|
|
|
|
# Build daemon environment
|
|
daemon_env = dict(llm_env)
|
|
idle_timeout = config.get("daemonIdleTimeout", 300)
|
|
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
|
|
|
|
# On macOS, force CPU for embeddings/reranker (mirrors Openclaw)
|
|
if platform.system() == "Darwin":
|
|
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
|
|
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
|
|
|
|
# Step 1: Configure profile
|
|
if debug_fn:
|
|
debug_fn(f'Configuring "{PROFILE_NAME}" profile...')
|
|
|
|
profile_args = [
|
|
"profile",
|
|
"create",
|
|
PROFILE_NAME,
|
|
"--merge",
|
|
"--port",
|
|
str(port),
|
|
]
|
|
for env_name, env_val in daemon_env.items():
|
|
if env_val:
|
|
profile_args.extend(["--env", f"{env_name}={env_val}"])
|
|
|
|
try:
|
|
result = _run_embed(config, profile_args, daemon_env, timeout=10)
|
|
if result.returncode != 0:
|
|
if debug_fn:
|
|
debug_fn(f"Profile create stderr: {result.stderr.strip()}")
|
|
raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}")
|
|
if debug_fn:
|
|
debug_fn("Profile configured")
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError("Profile create timed out")
|
|
except FileNotFoundError:
|
|
raise RuntimeError(
|
|
"hindsight-embed not found. Install with: pip install hindsight-embed "
|
|
"or set hindsightApiUrl for external API mode."
|
|
)
|
|
|
|
# Step 2: Start daemon
|
|
if debug_fn:
|
|
debug_fn("Starting daemon...")
|
|
|
|
try:
|
|
result = _run_embed(
|
|
config,
|
|
["daemon", "--profile", PROFILE_NAME, "start"],
|
|
daemon_env,
|
|
timeout=30,
|
|
)
|
|
if debug_fn:
|
|
debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}")
|
|
if result.returncode != 0 and "already running" not in result.stderr.lower():
|
|
raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}")
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError("Daemon start timed out")
|
|
|
|
# Step 3: Wait for ready (poll health endpoint)
|
|
if debug_fn:
|
|
debug_fn("Waiting for daemon to be ready...")
|
|
|
|
for attempt in range(30):
|
|
if _check_health(base_url):
|
|
if debug_fn:
|
|
debug_fn(f"Daemon ready after {attempt + 1} attempts")
|
|
# Track that we started this daemon
|
|
write_state(
|
|
DAEMON_STATE_FILE,
|
|
{
|
|
"port": port,
|
|
"started_by_plugin": True,
|
|
"started_at": time.time(),
|
|
"pid": os.getpid(),
|
|
},
|
|
)
|
|
return
|
|
time.sleep(1)
|
|
|
|
raise RuntimeError("Daemon failed to become ready within 30 seconds")
|
|
|
|
|
|
def prestart_daemon_background(config: dict, debug_fn=None):
|
|
"""Fire off daemon startup in the background — non-blocking.
|
|
|
|
Called from SessionStart hook to warm up the daemon before the first
|
|
recall or retain hook fires. Returns immediately; the daemon starts
|
|
asynchronously as a detached OS process.
|
|
"""
|
|
if config.get("hindsightApiUrl"):
|
|
return # External API mode — no local daemon needed
|
|
|
|
port = config.get("apiPort", 9077)
|
|
if _check_health(f"http://127.0.0.1:{port}"):
|
|
if debug_fn:
|
|
debug_fn(f"Daemon already running on port {port}, skipping pre-start")
|
|
return
|
|
|
|
if not _is_embed_available(config):
|
|
if debug_fn:
|
|
debug_fn("hindsight-embed not available, skipping pre-start")
|
|
return
|
|
|
|
try:
|
|
llm_config = detect_llm_config(config)
|
|
except RuntimeError as e:
|
|
if debug_fn:
|
|
debug_fn(f"No LLM configured, skipping daemon pre-start: {e}")
|
|
return
|
|
|
|
llm_env = get_llm_env_vars(llm_config)
|
|
daemon_env = dict(os.environ)
|
|
daemon_env.update(llm_env)
|
|
idle_timeout = config.get("daemonIdleTimeout", 300)
|
|
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
|
|
if platform.system() == "Darwin":
|
|
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
|
|
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
|
|
|
|
embed_cmd = _get_embed_command(config)
|
|
|
|
profile_args = ["profile", "create", PROFILE_NAME, "--merge", "--port", str(port)]
|
|
for env_name, env_val in llm_env.items():
|
|
if env_val:
|
|
profile_args.extend(["--env", f"{env_name}={env_val}"])
|
|
|
|
import shlex
|
|
profile_str = shlex.join(embed_cmd + profile_args)
|
|
daemon_str = shlex.join(embed_cmd + ["daemon", "--profile", PROFILE_NAME, "start"])
|
|
|
|
import subprocess as _sp
|
|
_sp.Popen(
|
|
f"{profile_str} && {daemon_str}",
|
|
shell=True,
|
|
env=daemon_env,
|
|
stdout=_sp.DEVNULL,
|
|
stderr=_sp.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
if debug_fn:
|
|
debug_fn(f"Daemon pre-start initiated in background (port {port})")
|
|
|
|
|
|
def stop_daemon(config: dict, debug_fn=None):
|
|
"""Stop the daemon if it was started by this plugin.
|
|
|
|
Called during cleanup. Only stops if we started it (tracked in state).
|
|
"""
|
|
state = read_state(DAEMON_STATE_FILE)
|
|
if not state or not state.get("started_by_plugin"):
|
|
if debug_fn:
|
|
debug_fn("Daemon not started by plugin, skipping stop")
|
|
return
|
|
|
|
if debug_fn:
|
|
debug_fn("Stopping daemon...")
|
|
|
|
try:
|
|
result = _run_embed(
|
|
config,
|
|
["daemon", "--profile", PROFILE_NAME, "stop"],
|
|
timeout=10,
|
|
)
|
|
if debug_fn:
|
|
debug_fn(f"Daemon stop: {result.stdout.strip()}")
|
|
except Exception as e:
|
|
if debug_fn:
|
|
debug_fn(f"Daemon stop error: {e}")
|
|
|
|
# Clear state
|
|
write_state(DAEMON_STATE_FILE, {})
|