fleet-memory/hindsight-integrations/codex/scripts/lib/client.py
Nicolò Boschi ecf16ea1e6
fix(codex): cleanup dead code, add release lifecycle and docs (#753)
* fix(codex): cleanup dead code and add to release lifecycle

- Remove orphaned reflect() method from client.py (leftover from dropped auto-mode)
- Remove dead retainToolCalls config default (never wired through)
- Add codex to release-integration.sh valid integrations
- Add settings.json version fallback to release script
- Add codex CI test job in test.yml
- Add codex to integrations.json registry

* docs(codex): add changelog page and link from integration docs

* feat(codex): add hosted installer script (get-codex)

Add self-contained installer at hindsight.vectorize.io/get-codex that
downloads scripts from GitHub, configures hooks, and supports local/cloud
mode selection — no git clone required.

Update docs and README to use the one-liner install:
  curl -fsSL https://hindsight.vectorize.io/get-codex | bash

* chore(codex): remove install.sh in favor of hosted get-codex

* fix(docs): use /next/ prefix for codex changelog link

* fix(docs): use GitHub link for codex changelog back-link
2026-03-30 11:34:43 +02:00

145 lines
5 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 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)