fleet-memory/hindsight-integrations/codex/scripts/lib/llm.py
Ben 0b17a67c70
feat: add Hindsight memory integration for OpenAI Codex CLI (#730)
* feat(codex): add Hindsight memory integration for OpenAI Codex CLI

Hooks-based integration that gives Codex CLI long-term memory via Hindsight.
Three hooks keep memory in sync: SessionStart (daemon pre-warm), UserPromptSubmit
(recall + context injection), Stop (retain conversation to memory).

Key differences from the Claude Code integration:
- Codex transcript format: JSONL with {msg: {type, message}} (user_message/agent_message)
- No CODEX_PLUGIN_ROOT env var — install.sh writes hooks.json with absolute paths
- State stored in ~/.hindsight/codex/state/ (not CLAUDE_PLUGIN_DATA)
- No async: true in hooks (not supported by Codex)
- No SessionEnd event
- hooks.json written to ~/.codex/hooks.json with codex_hooks = true in config.toml

* fix(codex): fix transcript parser for actual Codex disk format

Codex stores sessions as rollout-*.jsonl with response_item entries:
  User:      {type:response_item, payload:{type:message, role:user, content:[{type:input_text, text:...}]}}
  Assistant: {type:response_item, payload:{type:message, role:assistant, phase:final_answer, content:[{type:output_text, text:...}]}}

Previous parser expected an undocumented {msg:{type:user_message}} format from the Rust protocol spec
that does not match the actual on-disk storage format.

* feat(codex): add reflect mode to UserPromptSubmit hook

Add recallMode config option (default: 'recall') that switches the
UserPromptSubmit hook between:
- 'recall': existing behavior, fast raw facts list
- 'reflect': agentic synthesis loop, returns coherent prose answer

Also adds reflect() method to HindsightClient and HINDSIGHT_RECALL_MODE
env var override. Reflect uses a 25s timeout (vs 10s for recall).

* feat(codex): auto mode for recall/reflect selection

Add recallMode: 'auto' (new default) that picks the operation per-query:
- Synthesis patterns (what do you know, what's my, summarize, etc.) → reflect
- All other prompts → recall (fast, raw facts, better for code tasks)

* feat(codex): add automated test suite and finalize recall-only mode

* docs(codex): add docs page and sidebar entry for Codex CLI integration
2026-03-30 10:51:53 +02:00

146 lines
5.5 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"},
{"name": "anthropic", "key_env": "ANTHROPIC_API_KEY"},
{"name": "gemini", "key_env": "GEMINI_API_KEY"},
{"name": "groq", "key_env": "GROQ_API_KEY"},
{"name": "ollama", "key_env": ""},
{"name": "openai-codex", "key_env": ""},
{"name": "claude-code", "key_env": ""},
]
# 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,
"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,
"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,
"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\n"
" export ANTHROPIC_API_KEY=your-key\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\n\n"
"The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model"
)
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