* feat(claude-code): full-session retain mode with document upsert and configurable tags
Switch default retain behavior from per-turn chunks to full-session upsert.
Each session is now retained as a single document (document_id = session_id)
that gets updated on every Stop event, instead of creating fragmented
documents with timestamp-suffixed IDs.
New config options:
- retainMode: "full-session" (default) or "chunked" (legacy)
- retainTags: list with template variable support ({session_id}, {bank_id}, {timestamp})
- retainMetadata: extra metadata dict merged with built-in fields, supports templates
* fix(claude-code): respect retainEveryNTurns in full-session mode
The turn-count gating was only applied in chunked mode, meaning
full-session mode would re-ingest the entire transcript on every
single Stop event. Now retainEveryNTurns gates both modes.
Also fix test isolation: resolve ~/.hindsight/claude-code.json at
call time (not module load) so HOME override in tests works correctly.
* fix(claude-code): fix config tests after USER_CONFIG_PATH removal
Update tests to use HOME env var override instead of monkeypatching
the removed USER_CONFIG_PATH constant. Add autouse fixture to
TestLoadConfig to isolate all config tests from real user config
and HINDSIGHT_* env vars.
145 lines
5 KiB
Python
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)
|