fleet-memory/hindsight-integrations/claude-code/scripts/lib/llm.py
Fabio Scarsi f4390bdc2e
feat: Add Claude Code integration plugin (#651)
* feat: Add Claude Code integration plugin

Complete port of hindsight-openclaw (v0.4.19) adapted to Claude Code's
hook-based plugin architecture. Pure Python stdlib, no external dependencies.

- Auto-recall via UserPromptSubmit hook (additionalContext injection)
- Auto-retain via async Stop hook (chunked retention with sliding window)
- Daemon management (auto-start/stop hindsight-embed via uvx)
- Dynamic bank IDs with per-agent/project/channel/user granularity
- All 34 configuration options with env var overrides
- File-based state persistence with fcntl locking
- Graceful degradation on all error paths

Works with Claude Code Channels (Telegram, Discord, Slack) and
interactive sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Set correct chunked retention defaults (10/2, not 1/0)

retainEveryNTurns=10 and retainOverlapTurns=2 are the production-tested
values — every 10 turns, retain a 12-turn sliding window. The previous
defaults (1/0) would retain every single turn with no overlap, defeating
the chunked retention design that prevents API bombardment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Align recallBudget and daemonIdleTimeout with Openclaw defaults

recallBudget: "low" → "mid" (Openclaw default)
daemonIdleTimeout: 300 → 0 (Openclaw default, never auto-stop)

As an official Hindsight integration, defaults should match Openclaw.
Users can optimize locally via settings.json or env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:06:54 +01:00

145 lines
5.8 KiB
Python

"""LLM provider detection for Hindsight's fact extraction.
Port of: detectLLMConfig() in index.js
When running hindsight-embed locally (daemon mode), it needs an LLM to
extract facts from retained conversations. This module detects the LLM
config using the same priority chain as Openclaw:
1. HINDSIGHT_API_LLM_* environment variables (highest priority)
2. Plugin config (llmProvider, llmModel, llmApiKeyEnv)
3. Auto-detect from standard provider env vars
4. External API mode (server-side LLM, no local config needed)
"""
import os
# Provider detection table — same order as Openclaw
PROVIDER_DETECTION = [
{"name": "openai", "key_env": "OPENAI_API_KEY", "default_model": "gpt-4o-mini"},
{"name": "anthropic", "key_env": "ANTHROPIC_API_KEY", "default_model": "claude-3-5-haiku-20241022"},
{"name": "gemini", "key_env": "GEMINI_API_KEY", "default_model": "gemini-2.5-flash"},
{"name": "groq", "key_env": "GROQ_API_KEY", "default_model": "openai/gpt-oss-20b"},
{"name": "ollama", "key_env": "", "default_model": "llama3.2"},
{"name": "openai-codex", "key_env": "", "default_model": "gpt-5.2-codex"},
{"name": "claude-code", "key_env": "", "default_model": "claude-sonnet-4-5-20250929"},
]
# Providers that don't require an API key
NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"}
def _find_provider(name):
"""Find a provider entry by name."""
for p in PROVIDER_DETECTION:
if p["name"] == name:
return p
return None
def detect_llm_config(config: dict) -> dict:
"""Detect LLM configuration.
Returns dict with: provider, api_key, model, base_url, source.
Returns None values for external API mode (server handles LLM).
Raises RuntimeError if no configuration found and not in external API mode.
"""
override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER")
override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY")
override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL")
# Priority 1: HINDSIGHT_API_LLM_PROVIDER env var
if override_provider:
if not override_key and override_provider not in NO_KEY_REQUIRED:
raise RuntimeError(
f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.'
)
pinfo = _find_provider(override_provider)
return {
"provider": override_provider,
"api_key": override_key or "",
"model": override_model or (pinfo["default_model"] if pinfo else None),
"base_url": override_base_url,
"source": "HINDSIGHT_API_LLM_PROVIDER override",
}
# Priority 2: Plugin config llmProvider/llmModel
cfg_provider = config.get("llmProvider")
if cfg_provider:
pinfo = _find_provider(cfg_provider)
api_key = ""
key_env_name = config.get("llmApiKeyEnv")
if key_env_name:
api_key = os.environ.get(key_env_name, "")
elif pinfo and pinfo["key_env"]:
api_key = os.environ.get(pinfo["key_env"], "")
if not api_key and cfg_provider not in NO_KEY_REQUIRED:
key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown")
raise RuntimeError(
f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}'
)
return {
"provider": cfg_provider,
"api_key": api_key,
"model": config.get("llmModel") or override_model or (pinfo["default_model"] if pinfo else None),
"base_url": override_base_url,
"source": "plugin config",
}
# Priority 3: Auto-detect from standard provider env vars
for pinfo in PROVIDER_DETECTION:
if pinfo["name"] in NO_KEY_REQUIRED:
continue # Must be explicitly requested
if not pinfo["key_env"]:
continue
api_key = os.environ.get(pinfo["key_env"], "")
if api_key:
return {
"provider": pinfo["name"],
"api_key": api_key,
"model": override_model or pinfo["default_model"],
"base_url": override_base_url,
"source": f"auto-detected from {pinfo['key_env']}",
}
# Priority 4: External API mode — server handles LLM
if config.get("hindsightApiUrl"):
return {
"provider": None,
"api_key": None,
"model": None,
"base_url": None,
"source": "external-api-mode-no-llm",
}
raise RuntimeError(
"No LLM configuration found for Hindsight.\n\n"
"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\n"
"Option 2: Override with Hindsight-specific env vars:\n"
" export HINDSIGHT_API_LLM_PROVIDER=openai\n"
" export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n"
"Option 3: Use an external Hindsight API (server-side LLM):\n"
" Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var"
)
def get_llm_env_vars(llm_config: dict) -> dict:
"""Build environment variables for hindsight-embed daemon from LLM config.
These are passed to the daemon subprocess so it knows which LLM to use
for fact extraction.
"""
env = {}
if llm_config.get("provider"):
env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"]
if llm_config.get("api_key"):
env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"]
if llm_config.get("model"):
env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"]
if llm_config.get("base_url"):
env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"]
return env