* 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
163 lines
5.7 KiB
Python
163 lines
5.7 KiB
Python
"""Hindsight REST API client.
|
|
|
|
Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the
|
|
Openclaw HindsightClient (client.js), adapted for Python stdlib.
|
|
"""
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Optional
|
|
|
|
DEFAULT_TIMEOUT = 15 # seconds
|
|
HEALTH_CHECK_RETRIES = 3
|
|
HEALTH_CHECK_DELAY = 2 # seconds
|
|
|
|
|
|
def _validate_api_url(url: str) -> str:
|
|
"""Validate and normalize the API URL. Reject non-HTTP schemes."""
|
|
parsed = urllib.parse.urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
|
|
if not parsed.hostname:
|
|
raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
|
|
return url.rstrip("/")
|
|
|
|
|
|
class HindsightClient:
|
|
"""HTTP client for the Hindsight API."""
|
|
|
|
def __init__(self, api_url: str, api_token: Optional[str] = None):
|
|
self.api_url = _validate_api_url(api_url)
|
|
self.api_token = api_token
|
|
|
|
def _headers(self) -> dict:
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.api_token:
|
|
headers["Authorization"] = f"Bearer {self.api_token}"
|
|
return headers
|
|
|
|
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
url = f"{self.api_url}{path}"
|
|
data = json.dumps(body).encode() if body else None
|
|
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body_text = ""
|
|
try:
|
|
body_text = e.read().decode()
|
|
except Exception:
|
|
pass
|
|
raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
|
|
|
|
def health_check(self, timeout: int = 5) -> bool:
|
|
"""Check if the Hindsight server is reachable.
|
|
|
|
Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
|
|
with 2s delay between attempts.
|
|
"""
|
|
import time
|
|
|
|
for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
|
|
try:
|
|
url = f"{self.api_url}/health"
|
|
req = urllib.request.Request(url, headers=self._headers(), method="GET")
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
if resp.status == 200:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
if attempt < HEALTH_CHECK_RETRIES:
|
|
time.sleep(HEALTH_CHECK_DELAY)
|
|
return False
|
|
|
|
def recall(
|
|
self,
|
|
bank_id: str,
|
|
query: str,
|
|
max_tokens: int = 1024,
|
|
budget: str = "mid",
|
|
types: Optional[list] = None,
|
|
timeout: int = 10,
|
|
) -> dict:
|
|
"""Recall memories from a bank.
|
|
|
|
Returns the raw API response dict with 'results' list.
|
|
"""
|
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
|
|
body = {
|
|
"query": query,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
if budget:
|
|
body["budget"] = budget
|
|
if types:
|
|
body["types"] = types
|
|
return self._request("POST", path, body, timeout=timeout)
|
|
|
|
def retain(
|
|
self,
|
|
bank_id: str,
|
|
content: str,
|
|
document_id: str = "conversation",
|
|
context: Optional[str] = None,
|
|
metadata: Optional[dict] = None,
|
|
tags: Optional[list] = None,
|
|
timeout: int = 15,
|
|
) -> dict:
|
|
"""Retain content into a bank's memory.
|
|
|
|
Posts with async=true so the server processes in the background.
|
|
The context field helps Hindsight cluster memories by provenance
|
|
(e.g. "claude-code" vs manual retains).
|
|
"""
|
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
|
|
item = {
|
|
"content": content,
|
|
"document_id": document_id,
|
|
"metadata": metadata or {},
|
|
}
|
|
if context:
|
|
item["context"] = context
|
|
if tags:
|
|
item["tags"] = tags
|
|
body = {
|
|
"items": [item],
|
|
"async": True,
|
|
}
|
|
return self._request("POST", path, body, timeout=timeout)
|
|
|
|
def reflect(
|
|
self,
|
|
bank_id: str,
|
|
query: str,
|
|
budget: str = "mid",
|
|
max_tokens: int = 1024,
|
|
timeout: int = 30,
|
|
) -> dict:
|
|
"""Reflect on memories and return a synthesized answer.
|
|
|
|
Runs an agentic loop that retrieves facts, mental models, and experiences,
|
|
then uses the LLM to formulate a coherent response. Slower than recall
|
|
but produces a synthesized prose answer rather than raw facts.
|
|
"""
|
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/reflect"
|
|
body = {"query": query, "budget": budget, "max_tokens": max_tokens}
|
|
return self._request("POST", path, body, timeout=timeout)
|
|
|
|
def set_bank_mission(
|
|
self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
|
|
) -> dict:
|
|
"""Set the mission/persona for a bank.
|
|
|
|
Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
|
|
The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19.
|
|
"""
|
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
|
|
updates = {"reflect_mission": mission}
|
|
if retain_mission:
|
|
updates["retain_mission"] = retain_mission
|
|
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
|