Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,22 @@ my-app/

# ROFL config (user-generated)
rofl.yaml

# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
.venv/
venv/
ENV/
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
.coverage.*
build/
*.whl

477 changes: 477 additions & 0 deletions docs/specs/2026-05-22-contexto-hermes-plugin-design.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions packages/contexto-py/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

## 0.1.0 — 2026-05-23

Initial release. Implements the v5 design (`docs/specs/2026-05-22-contexto-hermes-plugin-design.md`).

- Remote backend only: `POST /v1/webhooks/events`, `POST /v1/mindmap/search` against `api.getcontexto.com`.
- Standard `ContextEngine` ABC implementation.
- Compaction in `compress()`: ingest drop slice → search → inject as synthetic user/assistant pair → token-invariant guard.
- `contexto_search` engine tool.
- Env-var-only config (`CONTEXTO_*`), with bounds validation: out-of-range/NaN values fall back to defaults with a `WARNING`.
- Defensive token coercion in `update_from_response` (string/garbled provider usage counts never raise).
- Installer discovers Hermes' `plugins/context_engine` even when it is a PEP-420 namespace package.
- `python -m contexto_hermes.install` installer.

Compatible with `api.getcontexto.com` schema version pinned in `__compatible_contexto_api__`.
18 changes: 18 additions & 0 deletions packages/contexto-py/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.PHONY: install test fixtures install-local clean

install:
pip install -e .[test]

test:
python -m pytest -q

fixtures:
cd tests/fixtures && node generate_episode_fixture.mjs

install-local:
python -m contexto_hermes.install

clean:
rm -rf build dist *.egg-info src/*.egg-info
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type d -name .pytest_cache -exec rm -rf {} +
60 changes: 60 additions & 0 deletions packages/contexto-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# contexto-hermes

[Contexto](https://getcontexto.com) as a context engine plugin for [hermes-agent](https://hermes-agent.nousresearch.com).

Mirrors the remote mode of `@ekai/contexto` (OpenClaw plugin) — ingestion of compacted episodes and mindmap retrieval against `api.getcontexto.com`.

## Install

```bash
pip install contexto-hermes
python -m contexto_hermes.install # symlink into hermes-agent's plugin tree
export CONTEXTO_API_KEY=ckai_xxx
```

Then in `~/.hermes/config.yaml`:

```yaml
context:
engine: contexto
```

## Configuration

All config is via env vars. Only `CONTEXTO_API_KEY` is required.

| Env var | Default | Meaning |
|---|---|---|
| `CONTEXTO_API_KEY` | — | API key from getcontexto.com (required) |
| `CONTEXTO_ENABLED` | `true` | When `false`, ingestion still happens but retrieval injection is disabled |
| `CONTEXTO_MAX_CONTEXT_CHARS` | `2000` | Cap on retrieved-context-block size in chars (must be ≥ 1) |
| `CONTEXTO_MIN_SCORE` | `0.45` | Minimum similarity score for retrieved items (0.0–1.0) |
| `CONTEXTO_MAX_RESULTS` | `7` | Items fetched per automatic recall at compaction time |
| `CONTEXTO_SEARCH_TIMEOUT` | `10` | HTTP timeout (seconds) for search calls |
| `CONTEXTO_INGEST_TIMEOUT` | `30` | HTTP timeout (seconds) for ingest calls |

Invalid, out-of-range, or NaN values fall back to the default with a `WARNING`; they never block registration.

`CONTEXTO_MAX_RESULTS` sets recall breadth at compaction time. The `contexto_search` tool takes its own `max_results` (default `5`) for on-demand recall.

## Status

Health is observable via the engine's `get_status()`:

```python
{
"auth_state": "ok" | "degraded" | "auth_error",
"last_api_error": str | None,
"consecutive_ingest_failures": int, # fail-closed compactions in a row
"last_ingest_failure": str | None, # reason for the last fail-closed ingest
...
}
```

On ingest failure, `compress()` fails closed — original messages kept, retrieval skipped, compaction count unchanged — so unpersisted history is never dropped. The counters above surface a sustained outage (e.g. a rate-limit window).

Hermes' `/status` command surfaces only token-level fields directly; `auth_state` transitions are logged at INFO so they appear in hermes-agent logs.

## License

MIT
39 changes: 39 additions & 0 deletions packages/contexto-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "contexto-hermes"
version = "0.1.0"
description = "Contexto context engine plugin for hermes-agent — full episodes + mindmap retrieval (remote)"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [{ name = "Ekai Labs" }]
keywords = ["contexto", "hermes-agent", "context-engine", "rag", "llm"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = ["httpx>=0.27"]

[project.optional-dependencies]
test = ["pytest>=8", "tiktoken>=0.7", "pyyaml>=6"]

[project.scripts]
contexto-hermes-install = "contexto_hermes.install:main"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
contexto_hermes = ["py.typed", "plugin.yaml"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
34 changes: 34 additions & 0 deletions packages/contexto-py/src/contexto_hermes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""contexto-hermes — Contexto context engine plugin for hermes-agent.

Plugin entry point. Hermes' `_EngineCollector` exec's this module and calls
`register(ctx)`; we wire a `ContextoEngine` instance into `ctx`.
"""

from __future__ import annotations

import logging
from typing import Any

from .engine import ContextoEngine

__all__ = ["ContextoEngine", "register", "__compatible_contexto_api__"]
__version__ = "0.1.0"

# Pinned `api.getcontexto.com` schema version compatible with this release.
# Bumped independently from `@ekai/contexto`'s semver.
__compatible_contexto_api__ = "2026-05"

logger = logging.getLogger("plugins.context_engine.contexto")


def register(ctx: Any) -> None:
"""Plugin registration. Called by hermes-agent's context-engine loader."""
engine = ContextoEngine.from_env()
if engine is None:
logger.error(
"Contexto plugin not registered: CONTEXTO_API_KEY is not set. "
"Hermes will fall back to the default 'compressor' engine. "
"Get a key at https://getcontexto.com and `export CONTEXTO_API_KEY=...`."
)
return
ctx.register_context_engine(engine)
6 changes: 6 additions & 0 deletions packages/contexto-py/src/contexto_hermes/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""`python -m contexto_hermes` → installer."""

from .install import main

if __name__ == "__main__":
raise SystemExit(main())
196 changes: 196 additions & 0 deletions packages/contexto-py/src/contexto_hermes/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""RemoteBackend — sync httpx client for api.getcontexto.com.

Mirrors TS RemoteBackend semantics (URLs, headers, body shapes) and adds
Python-side concerns the TS version doesn't have: configurable timeouts and
429 suppression with Retry-After.

Per-request client construction (`with httpx.Client(...) as c`). No
module-level shared state. Never raises — every failure path goes through
the `on_error` callback.

The engine observes errors via `on_error` and successes via `on_success`;
suppression state lives ONLY in the backend.
"""

from __future__ import annotations

import logging
import time
from typing import Any, Callable

import httpx

from .types import ApiError, ContextoConfig, SearchResult, WebhookPayload

logger = logging.getLogger("plugins.context_engine.contexto")

API_BASE = "https://api.getcontexto.com"
INGEST_PATH = "/v1/webhooks/events"
SEARCH_PATH = "/v1/mindmap/search"


def _categorize_status(status: int) -> str:
if status in (401, 403):
return "auth"
if status == 422:
return "schema"
if status == 429:
return "ratelimit"
return "server"


def _parse_retry_after(raw: str | None) -> float:
if not raw:
return 60.0 # conservative default
raw = raw.strip()
try:
return max(0.0, float(raw))
except (TypeError, ValueError):
return 60.0 # HTTP-date form is rare; fall back to 60s


class RemoteBackend:
"""Sync HTTP backend talking to api.getcontexto.com."""

def __init__(
self,
config: ContextoConfig,
on_error: Callable[[ApiError], None],
on_success: Callable[[], None],
transport: httpx.BaseTransport | None = None,
) -> None:
self._config = config
self._on_error = on_error
self._on_success = on_success
self._transport = transport
self._headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {config.api_key}",
}
self._rate_limit_reset_at: float | None = None

# --- public surface ---

def ingest(self, payloads: list[WebhookPayload]) -> bool:
if not payloads:
return True
if self._suppressed():
return False
try:
with self._client(self._config.ingest_timeout) as client:
response = client.post(
API_BASE + INGEST_PATH,
headers=self._headers,
json=payloads,
)
except httpx.HTTPError as exc:
self._handle_network_error(exc)
return False
except Exception as exc:
self._handle_unexpected_error(exc)
return False

return self._handle_response(response, op="ingest")

def search(
self,
query: str,
max_results: int,
filter: dict[str, Any] | None,
min_score: float,
) -> SearchResult | None:
if self._suppressed():
return None
body: dict[str, Any] = {
"query": query,
"maxResults": max_results,
"filter": filter,
"minScore": min_score,
}
try:
with self._client(self._config.search_timeout) as client:
response = client.post(
API_BASE + SEARCH_PATH,
headers=self._headers,
json=body,
)
except httpx.HTTPError as exc:
self._handle_network_error(exc)
return None
except Exception as exc:
self._handle_unexpected_error(exc)
return None

if not self._handle_response(response, op="search"):
return None

try:
data = response.json()
except ValueError:
logger.error("Search response was not valid JSON")
return None
if not isinstance(data, dict):
logger.error("Search response JSON was not an object")
return None
items = data.get("items", [])
paths = data.get("paths", [])
return SearchResult(
items=items if isinstance(items, list) else [],
paths=paths if isinstance(paths, list) else [],
)

# --- internals ---

def _client(self, timeout: float) -> httpx.Client:
kwargs: dict[str, Any] = {"timeout": timeout}
if self._transport is not None:
kwargs["transport"] = self._transport
return httpx.Client(**kwargs)

def _suppressed(self) -> bool:
if self._rate_limit_reset_at is None:
return False
if time.time() >= self._rate_limit_reset_at:
self._rate_limit_reset_at = None
return False
return True

def _handle_response(self, response: httpx.Response, *, op: str) -> bool:
if response.is_success:
self._on_success()
return True

category = _categorize_status(response.status_code)
retry_after: float | None = None
if category == "ratelimit":
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
self._rate_limit_reset_at = time.time() + retry_after
logger.warning(
"[contexto] %s rate-limited (HTTP 429); suppressing for %.1fs",
op, retry_after,
)
else:
body_preview = ""
try:
body_preview = response.text[:200]
except Exception:
pass
logger.error(
"[contexto] %s HTTP %d: %s",
op, response.status_code, body_preview,
)

self._on_error(ApiError(
category=category,
message=f"HTTP {response.status_code}",
retry_after=retry_after,
))
return False

def _handle_network_error(self, exc: httpx.HTTPError) -> None:
logger.error("[contexto] network error: %s", exc)
self._on_error(ApiError(category="network", message=str(exc) or type(exc).__name__))

def _handle_unexpected_error(self, exc: BaseException) -> None:
logger.error("[contexto] unexpected error: %s", exc, exc_info=True)
self._on_error(ApiError(category="network", message=str(exc) or type(exc).__name__))
Loading
Loading