* 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>
142 lines
4.9 KiB
Python
142 lines
4.9 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,
|
|
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
|
|
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)
|