diff --git a/README.md b/README.md
index 13bdd13..400fc6f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
Contexto
-Keep long-running OpenClaw agents reliable after the context window fills.
-A drop-in OpenClaw context engine that retrieves old constraints instead of losing them to summaries.
+Keep long-running OpenClaw and Hermes agents reliable after the context window fills.
+A drop-in context engine for OpenClaw and hermes-agent that retrieves old constraints instead of losing them to summaries.
Quick Start •
@@ -18,7 +18,7 @@
- OpenClaw works well until long sessions start compacting away the exact instruction that mattered.
+ OpenClaw and hermes-agent work well until long sessions start compacting away the exact instruction that mattered.
Contexto is the context engine built for that failure mode.
@@ -64,7 +64,7 @@ The instruction survives compaction.
## Why Contexto
-Contexto is a context engine for OpenClaw. It is built for the exact moment OpenClaw starts dropping or blurring the context your agent still needs:
+Contexto is a context engine plugin. It runs inside OpenClaw and hermes-agent today, and is built for the exact moment your agent starts dropping or blurring the context it still needs:
- early instructions get compacted away
- summaries turn into summaries of summaries
@@ -79,11 +79,13 @@ Contexto fixes that by storing full episodes and retrieving only the context tha
- Stores full episodes instead of collapsing everything into lossy summaries
- Separates topics with semantic clustering so retrieval stays clean
- Surfaces explainable paths such as `travel -> Japan -> visa docs`
-- Drops into OpenClaw as one plugin with one config key
+- Drops into OpenClaw or Hermes as one plugin with one config key
## Quick Start
-Built for OpenClaw today. Managed hosting is available, so you do not need to run retrieval infrastructure yourself.
+Built for OpenClaw and Hermes today. Managed hosting is available, so you do not need to run retrieval infrastructure yourself.
+
+### OpenClaw
```bash
openclaw plugins install @ekai/contexto
@@ -93,13 +95,36 @@ openclaw config set plugins.entries.contexto.config.apiKey YOUR_KEY
openclaw gateway restart
```
+### Hermes
+
+```bash
+pip install contexto-hermes
+contexto-hermes-install # symlinks the plugin into hermes-agent
+```
+
+Enable it in `~/.hermes/config.yaml`:
+
+```yaml
+context:
+ engine: contexto
+```
+
+Then set the key and run:
+
+```bash
+export CONTEXTO_API_KEY=YOUR_KEY
+hermes gateway run
+```
+
+For a fully local setup — embeddings + summarization against your own OpenAI or OpenRouter key, mindmap on disk — see [docs/contexto-hermes-quickstart.md](docs/contexto-hermes-quickstart.md).
+
Get an API key at [getcontexto.com](https://getcontexto.com/).
If your agent ever forgets a rule, preference, or prior decision after a long run, this is the switch to try first.
## Who Should Use This
-- OpenClaw users whose sessions run long enough to compact
+- OpenClaw or Hermes users whose sessions run long enough to compact
- Agents where forgotten constraints are costly
- Teams that want better reliability without prompt hacks
- Not for one-shot chats or very short sessions
@@ -171,7 +196,6 @@ interface ContextoBackend {
- [ ] Horizontal scaling with sub-agent context delegation
- [ ] Scoped context with access boundaries
- [ ] Knowledge from external documents
-- [ ] Local backend
- [ ] Context sharing across agents
## Community
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 9377723..d034064 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -4,6 +4,7 @@
* [Architecture Overview](architecture-overview.md)
* [Getting Started](getting-started.md)
* [Contexto Plugin](contexto.md)
+* [Contexto-Hermes Quickstart](contexto-hermes-quickstart.md)
* [Memory](memory-plugin.md)
* [Supported Providers and Models](providers-and-models.md)
* [OpenRouter Integration Quickstart](openrouter-quickstart.md)
diff --git a/docs/contexto-hermes-quickstart.md b/docs/contexto-hermes-quickstart.md
new file mode 100644
index 0000000..cbb4ca9
--- /dev/null
+++ b/docs/contexto-hermes-quickstart.md
@@ -0,0 +1,106 @@
+# Contexto-Hermes Quickstart
+
+Install Contexto as a context engine in [hermes-agent](https://hermes-agent.nousresearch.com) in a few minutes. Two backends to choose from — they install the same way and differ only in env vars.
+
+| | **Remote** *(default)* | **Local** |
+|---|---|---|
+| Storage | Contexto cloud | On-disk JSON (`$HERMES_HOME/data/contexto/mindmap.json`) |
+| Embeddings + summarization | Contexto-hosted | Your OpenAI **or** OpenRouter key |
+| Required key | `CONTEXTO_API_KEY` from [getcontexto.com](https://getcontexto.com) | `OPENAI_API_KEY` *or* `OPENROUTER_API_KEY` |
+| Extra deps | none beyond `httpx` | `numpy`, `scipy` (installed automatically) |
+| Best for | Zero local setup, managed retrieval | Air-gapped / BYOK / "no third-party context store" |
+
+## 0. Prereqs
+
+- Python 3.10 or newer.
+- `hermes-agent` installed and importable (i.e. `python -c "import plugins.context_engine"` resolves). If you don't have it yet, follow the hermes-agent install instructions first, then come back.
+
+## 1. Install the plugin (both backends)
+
+```bash
+pip install contexto-hermes
+contexto-hermes-install # symlinks the plugin into hermes-agent's tree
+```
+
+`contexto-hermes-install` finds hermes via the same import path Hermes uses. To point at a non-default checkout: `HERMES_AGENT_ROOT=/path/to/hermes-agent contexto-hermes-install`.
+
+Then enable the engine in `~/.hermes/config.yaml`:
+
+```yaml
+context:
+ engine: contexto
+```
+
+That's it for installation. The remaining step is one or two env vars to pick a backend.
+
+## 2a. Remote backend (default)
+
+```bash
+export CONTEXTO_API_KEY=ckai_xxx # from https://getcontexto.com
+hermes chat # or `hermes gateway run`
+```
+
+Nothing else to configure. `CONTEXTO_BACKEND` defaults to `remote`.
+
+## 2b. Local backend
+
+```bash
+export CONTEXTO_BACKEND=local
+export OPENROUTER_API_KEY=sk-or-xxx # or: export OPENAI_API_KEY=sk-xxx
+hermes chat # or `hermes gateway run`
+```
+
+Provider is auto-detected from the key you set. If both keys are exported, OpenRouter wins; pin explicitly with `CONTEXTO_LOCAL_PROVIDER=openai|openrouter`. The mindmap lands at `~/.hermes/data/contexto/mindmap.json` after the first `compress()`.
+
+## 3. Verify it's wired up
+
+After enough chat turns to trigger compaction:
+
+```bash
+# Hermes logs — confirm no registration error.
+# If anything is wrong you'll see one of these and Hermes falls back silently:
+# "Contexto plugin not registered: CONTEXTO_API_KEY is not set"
+# "Contexto plugin (local) not registered: local config invalid"
+grep -i "contexto plugin" ~/.hermes/logs/hermes.log
+
+# Local backend only — inspect the on-disk mindmap.
+jq '.version, .stats' ~/.hermes/data/contexto/mindmap.json
+```
+
+A healthy local backend prints `1` and `{"total_items": N, "total_clusters": M, ...}`.
+
+## Running inside Docker
+
+The hermes-agent base image needs two adjustments when using the local backend:
+
+1. **Plugin source.** The bundled `plugins/context_engine/contexto/` is an absolute symlink that `docker build` resolves to a host path. Bind-mount the source at runtime instead:
+ ```yaml
+ volumes:
+ - /path/to/contexto/packages/contexto-py/src/contexto_hermes:/opt/hermes/plugins/context_engine/contexto:ro
+ ```
+2. **Runtime deps.** Install `numpy` + `scipy` into the image's venv before launching:
+ ```yaml
+ command:
+ - sh
+ - -c
+ - |
+ uv pip install --python /opt/hermes/.venv/bin/python numpy scipy \
+ && exec hermes gateway run
+ ```
+
+A ready-to-use compose file is at [`packages/contexto-py/e2e/docker-compose.hermes-local.yml`](../packages/contexto-py/e2e/docker-compose.hermes-local.yml).
+
+The remote backend has no extra Docker requirements — just `CONTEXTO_API_KEY` in the environment.
+
+## Full config reference
+
+The most common knobs are above. For everything else — `CONTEXTO_MAX_RESULTS`, `CONTEXTO_MIN_SCORE`, `CONTEXTO_LOCAL_SIMILARITY_THRESHOLD`, model overrides, timeouts, status fields — see the package README at [`packages/contexto-py/README.md`](../packages/contexto-py/README.md).
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| `Contexto plugin not registered: CONTEXTO_API_KEY is not set` | Remote backend selected (default) but no API key in env. |
+| `Contexto plugin (local) not registered: local config invalid` | Local backend selected but neither `OPENAI_API_KEY` nor `OPENROUTER_API_KEY` is set, or `CONTEXTO_LOCAL_PROVIDER` is unknown. Check the line above this in the log for the specific reason. |
+| `ModuleNotFoundError: No module named 'numpy'` (Docker) | The image doesn't ship numpy/scipy — install them into the venv as shown above. |
+| `Could not locate hermes-agent's plugins/context_engine directory` from `contexto-hermes-install` | hermes-agent isn't installed in the same Python environment. Activate the venv first, or set `HERMES_AGENT_ROOT`. |
diff --git a/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md b/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md
new file mode 100644
index 0000000..4496f16
--- /dev/null
+++ b/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md
@@ -0,0 +1,356 @@
+# Contexto × Hermes Local Backend — Design
+
+**Status:** approved
+**Date:** 2026-05-23
+**Extends:** [`2026-05-22-contexto-hermes-plugin-design.md`](./2026-05-22-contexto-hermes-plugin-design.md)
+**Implements:** `LocalBackend` for `contexto-hermes`, mirroring the TS `LocalBackend` in `@ekai/contexto`'s `local/` module
+
+---
+
+## 1. Goal
+
+Make Contexto's local mode available to Hermes the same way it's available to OpenClaw: no Contexto-hosted API calls; embeddings and summarization call the user's chosen provider (OpenAI or OpenRouter); state lives on disk as a single JSON file. Behavior matches `@ekai/contexto`'s `LocalBackend` (`packages/contexto/src/local/backend.ts`) on the user-observable contract.
+
+The v1 plugin spec ([§2 Out of scope](./2026-05-22-contexto-hermes-plugin-design.md)) deferred this; this spec fills the gap.
+
+## 2. Scope
+
+### In scope
+
+- New `LocalBackend` class inside the existing `contexto-hermes` Python package. Sync. Same duck-typed contract as `RemoteBackend` (`ingest`, `search`, never-raises).
+- Selectable via `CONTEXTO_BACKEND=local`. Default stays `remote`.
+- Pure-Python. **No Node.js dependency.** `numpy` + `scipy` + `httpx` only.
+- AGNES hierarchical clustering via `scipy.cluster.hierarchy.linkage(method='average', metric='cosine')`. Defaults match TS `DEFAULT_CONFIG`: `similarity_threshold=0.65`, `max_depth=4`, `max_children=10`, `rebuild_interval=50`.
+- Beam-search retrieval over the cluster tree (`beam_width=3`).
+- Per-ingest LLM summarization (parity with TS `summarizeEpisode`), opt-out via env var.
+- On-disk JSON state, default `$HERMES_HOME/data/contexto/mindmap.json` (Hermes home dir; `~/.hermes` locally, `/opt/data` in container).
+- Local episode-text extractor that reads Hermes' flat `data.messages` payload and produces the same `Q:` / `A:` / `T:`-prefixed text TS produces from its `data.userMessage` / `assistantMessages` / `toolMessages` shape.
+
+### Out of scope (v1 of the local backend)
+
+- Byte-for-byte interop with the TS `mindmap.json` on-disk format.
+- Multi-writer concurrency.
+- Approximate-nearest-neighbor indexes (HNSW, IVF). AGNES is fine to ~10k items.
+- Local embedding models (sentence-transformers, BGE, …).
+- Gemini provider. TS supports three; Python v1 supports `openai` + `openrouter`.
+- LLM-rewritten cluster labels. Cluster labels use TS's lightweight keyword extraction.
+
+## 3. Repo layout
+
+Additive to the layout from [§3 of the v1 spec](./2026-05-22-contexto-hermes-plugin-design.md):
+
+```
+contexto/packages/contexto-py/
+├── src/contexto_hermes/
+│ ├── __init__.py # MODIFIED — backend-aware register()
+│ ├── engine.py # MODIFIED — backend selector + from_env_local()
+│ ├── types.py # MODIFIED — ContextoConfig.local_mode_defaults();
+│ │ # SearchResult.paths: list[dict] → list[list[str]]
+│ ├── plugin.yaml # MODIFIED — adds CONTEXTO_LOCAL_* env vars
+│ └── local/ # NEW
+│ ├── __init__.py
+│ ├── backend.py # LocalBackend orchestrator
+│ ├── extractor.py # episode text from data.messages → Q:/A:/T:
+│ ├── store.py # JSON load/save + corrupt-file quarantine
+│ ├── labeler.py # cluster labels (port of TS generateLabel)
+│ ├── clustering.py # scipy AGNES + incremental insert + rebuild policy
+│ ├── retrieval.py # beam search with similarity_threshold pruning
+│ ├── embedder.py # httpx → /embeddings, provider-specific defaults
+│ ├── summarizer.py # httpx → /chat/completions, provider-specific defaults
+│ └── mindmap_types.py # dataclasses
+├── tests/local/ # NEW
+├── tests/fixtures/local-backend/ # NEW
+└── pyproject.toml # MODIFIED — adds numpy, scipy
+```
+
+`httpx` is already a `RemoteBackend` dependency; only `numpy` and `scipy` are new.
+
+## 4. Backend selection & registration
+
+The v1 plugin only registers when `CONTEXTO_API_KEY` is set. The local backend doesn't need that key — it needs a provider key instead.
+
+| `CONTEXTO_BACKEND` | Required env vars |
+|---|---|
+| `remote` (default) | `CONTEXTO_API_KEY` |
+| `local` | one of `OPENAI_API_KEY` or `OPENROUTER_API_KEY` |
+
+`CONTEXTO_API_KEY` is not required in local mode; if present, it is ignored.
+
+Registration behavior:
+
+1. Read `CONTEXTO_BACKEND`. Invalid values are coerced to `remote` with a WARNING log.
+2. Attempt construction via the appropriate path: `ContextoEngine.from_env()` for remote, `ContextoEngine.from_env_local()` for local. Construction-time exceptions are caught and logged at ERROR; the plugin does not register.
+3. If the constructor returns `None`, log a backend-appropriate error pointing at the prior `from_env` log (which already explained the specific reason) and do not register.
+4. Otherwise register the engine on the plugin context.
+
+`ContextoEngine.from_env_local()` returns `None` when local credentials/config are unusable; otherwise it builds a `LocalBackendConfig` via `from_env` (rules in §7), constructs a `ContextoConfig` via `local_mode_defaults()`, and returns a `ContextoEngine` with the `LocalBackend` injected through the existing optional `backend=` constructor arg.
+
+`ContextoConfig.local_mode_defaults()` returns a `ContextoConfig` with `api_key=""` (unused in local mode) and the existing `CONTEXTO_*` defaults — keeps `compress()`-time formatting code unchanged.
+
+## 5. Architecture
+
+Ten modules under `local/`. Each has one job. All public-boundary errors are caught at `backend.py` and converted to `False` / `None`; internals raise normally.
+
+### `backend.py` — `LocalBackend`
+
+Same duck-typed contract as `RemoteBackend`. Mirrors TS `LocalBackend` in `packages/contexto/src/local/backend.ts`.
+
+- `__init__(config: LocalBackendConfig, *, embedder=None, summarizer=None, store=None, embed_transport=None, llm_transport=None)` — stores config; lazy `Store.load()` on first ingest/search. Uses the module-level logger (`plugins.context_engine.contexto`) — matches the Python-idiomatic pattern used by `RemoteBackend`, not the TS `logger: Logger` arg. The keyword-only kwargs are test seams that let unit tests inject fakes or `httpx.MockTransport`; production code constructs with just the config.
+- `ingest(payloads: list[WebhookPayload]) -> bool` — embed + (optionally) summarize + insert. Returns success. Never raises.
+- `search(query: str, max_results: int, filter: dict | None = None, min_score: float | None = None) -> SearchResult | None` — embed query, beam-search tree, score, return top-K wrapped as `{"item": ConversationItem-dict, "score": float}` per TS `ScoredQueryResult`. Returns `None` only on failure; an empty result set (empty store, nothing survives filtering) is an empty `SearchResult` — matching `RemoteBackend`, so the tool layer can distinguish "no matches" from "backend down". Never raises.
+
+### `extractor.py`
+
+Pure function `extract_episode_text(payload) -> str`. Hermes' `build_episode_payload` writes `data: {"messages": [...]}` — a flat list of role-tagged messages. TS expects `data.userMessage` / `assistantMessages` / `toolMessages`, a shape OpenClaw produces but Hermes does not.
+
+The extractor reads the flat shape and produces the same Q:/A:/T: text:
+
+- `event.type != 'episode'` or `event.action != 'combined'` → `""`
+- For each message in `data.messages`:
+ - `role == 'user'`: `normalize_message_text` → `strip_metadata_envelope` → prefix `Q:`
+ - `role == 'assistant'`: extract text → prefix `A:` (skip empty)
+ - `role == 'tool'`: extract text → prefix `T:` (skip empty)
+ - Other roles: ignored
+- Join with `\n`
+
+### `store.py`
+
+JSON load/save.
+
+- **Save:** `mkdir(parents=True, exist_ok=True)` on the parent directory, then atomic write via `.tmp` + `os.replace`.
+- **Load:** on parse failure or schema mismatch, rename the file aside to `.corrupted-`, log ERROR, return a fresh empty `StoreState`. Subsequent saves write to the original path; the user can inspect or restore the renamed file. No silent overwrite.
+- Single-writer assumption (no file lock in v1).
+
+### `labeler.py`
+
+Port of TS `generateLabel` in `packages/mindmap/src/labeler.ts`. Same STOP_WORDS set, same three-branch behavior:
+
+- 0 items → `"Empty"`
+- 1 item → first 4 keywords of `content`; fallback `content[:30]`
+- n items → keywords of the item closest to the centroid; fallback to top 3 most-frequent keywords across all items; final fallback `"Cluster"`
+
+Labels are user-visible only via the `paths` field of `SearchResult`.
+
+### `clustering.py`
+
+All mindmap tunables live under `LocalBackendConfig.mindmap` (a nested `MindmapConfig` dataclass). Clustering accesses them as `self._config.mindmap.`.
+
+**Rebuild** handles three cases (scipy.linkage raises with n < 2):
+
+- 0 items → root `ClusterNode("root", "Knowledge", [], children=[], items=[], depth=0, item_count=0)`. No scipy call.
+- 1 item → root with one leaf child.
+- 2+ items → `scipy.cluster.hierarchy.linkage(embeddings, method='average', metric='cosine')`, convert the linkage matrix to a `ClusterNode` tree, cut at `similarity_threshold` (distance ≤ `1 - threshold`) and cap at `max_depth`.
+
+**Incremental insert:** descend from root choosing the child with highest cosine similarity to the item's embedding; if `sim >= similarity_threshold` and `child.depth < max_depth`, descend into it; otherwise create a new child cluster; update centroids on the visited path.
+
+**Rebuild policy.** Rebuild when `new_total < 100` OR `inserts_since_rebuild + new_items_count >= rebuild_interval`. Otherwise insert incrementally. Matches TS `addToMindmap`.
+
+After every add, `state.stats` is updated: `total_items`, `total_clusters`, `inserts_since_rebuild` (reset to 0 on rebuild).
+
+### `retrieval.py`
+
+Beam search over the cluster tree (port of TS `queryMindmapMultiBranch`):
+
+1. Score root's children by cosine sim to the query; keep top `beam_width` above `similarity_threshold`.
+2. Expand each beam entry. Entries with no children passing threshold become terminal.
+3. Collect items from all terminals; dedup by `id`.
+4. Apply `filter` (exact-match on `metadata[key]`); score by cosine sim to query.
+5. Sort descending; apply `min_score`; slice to `max_results`.
+
+Returns `ScoredQueryResult(items, paths, …)`. `paths` is `list[list[str]]` of cluster *labels* (not IDs), matching TS. `items` is `list[{"item": dict, "score": float}]` so callers can rank/threshold downstream; the engine's `_entry_id` and `format_search_results` accept this wrapped shape (and fall back to bare items).
+
+### `embedder.py` and `summarizer.py`
+
+Per-call `with httpx.Client(...) as c:`. Provider-specific defaults:
+
+| Provider | `embed_model` | `llm_model` |
+|---|---|---|
+| `openai` | `text-embedding-3-small` | `gpt-4o-mini` |
+| `openrouter` | `openai/text-embedding-3-small` | `openai/gpt-4o-mini` |
+
+`summarizer.summarize` mirrors TS `summarizeEpisode` (`packages/contexto/src/local/summarizer.ts`): same system prompt, `temperature=0.2`, `response_format={"type": "json_object"}`. On HTTP error, parse failure, or unknown provider, returns a fallback `EpisodeSummary` (matches TS `buildFallback`).
+
+When `CONTEXTO_LOCAL_SUMMARIZE=false`, `backend.py` calls `summarizer.build_synthetic_summary(extracted_text)` instead, which produces an `EpisodeSummary` from raw text without any LLM call. The distinct `key_findings` marker (`"Episode processed (summarization disabled)"`) distinguishes it from the LLM-failure fallback. Downstream metadata/content construction stays branch-free.
+
+### `mindmap_types.py`
+
+Plain `@dataclass` types. Module named `mindmap_types.py` to avoid colliding with `contexto_hermes.types`. The on-disk schema in §8 is the source of truth for field semantics; the dataclasses mirror it.
+
+- **`MindmapConfig`** — `similarity_threshold=0.65`, `max_depth=4`, `max_children=10`, `rebuild_interval=50`. Mirrors TS `DEFAULT_CONFIG`.
+- **`LocalBackendConfig`** — `storage_path`, `provider` (`openai` | `openrouter`), `api_key`, `embed_base_url`, `llm_base_url`, `embed_model: str | None = None` (None → provider default, resolved by `resolved_embed_model()`), `llm_model: str | None = None` (None → provider default, resolved by `resolved_llm_model()`), `summarize=True`, nested `mindmap: MindmapConfig`, `beam_width=3`, `embed_timeout=30.0`, `llm_timeout=60.0`. Classmethod `from_env()` returns `None` on unusable config; rules in §7.
+- **`EvidenceRef`** — `type` (`episode_ref` | `tool_ref` | `file_ref` | `trace_ref`), `value`.
+- **`EpisodeSummary`** — `summary`, `key_findings: list[str]`, `status` (`complete` | `partial` | `blocked`), `confidence: float`, `evidence_refs: list[EvidenceRef]`, `open_questions: list[str] | None`.
+- **`ConversationItem`** — `id`, `role`, `content`, `embedding: list[float]`, `timestamp: str | None`, `metadata: dict[str, Any]`.
+- **`ClusterNode`** — `id`, `label`, `centroid: list[float]`, `children: list[ClusterNode]`, `items: list[ConversationItem]`, `depth`, `item_count`.
+- **`StoreStats`** — `total_items=0`, `total_clusters=0`, `inserts_since_rebuild=0`.
+- **`StoreState`** — `version=1`, `config_snapshot: dict[str, Any]`, `root: ClusterNode | None`, `stats: StoreStats`.
+
+## 6. Item metadata
+
+When `LocalBackend.ingest` builds a `ConversationItem`, the `metadata` object carries these keys (matches TS apart from `episode`):
+
+| Key | Value |
+|---|---|
+| `source` | always the literal `"summary"` |
+| `status` | from `EpisodeSummary.status` (`complete` \| `partial` \| `blocked`) |
+| `confidence` | from `EpisodeSummary.confidence` (float, 0–1) |
+| `evidence_refs` | from `EpisodeSummary.evidence_refs` — list of `{type, value}` objects |
+| `open_questions` | from `EpisodeSummary.open_questions` (list of strings or null) |
+| `trace_ref` | fresh UUID4 per item |
+| `sessionKey` | from the payload's `sessionKey` |
+| `episode` | `{"extracted_text": }` — see below |
+
+`content` is `summary.summary` followed by a `Key findings:` section (newline-separated bullets) when `key_findings` is non-empty.
+
+The `episode` sub-object diverges from TS (which stores three pre-split message lists; Hermes' payload doesn't have them pre-split). `format_search_results` reads `status`, `confidence`, `evidence_refs`, `trace_ref` — present and unchanged.
+
+## 7. Configuration
+
+All env-var driven. Additive to the v1 plugin's `CONTEXTO_*` vars.
+
+| Env var | Default | Purpose |
+|---|---|---|
+| `CONTEXTO_BACKEND` | `remote` | `remote` or `local` |
+| `CONTEXTO_LOCAL_STORAGE_PATH` | `$HERMES_HOME/data/contexto/mindmap.json` (fallback `~/.hermes/data/contexto/mindmap.json`) | JSON store path |
+| `CONTEXTO_LOCAL_PROVIDER` | `openrouter` if `OPENROUTER_API_KEY` set, else `openai` | Embeddings + LLM provider |
+| `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | — | Standard names; matches the selected provider |
+| `CONTEXTO_LOCAL_EMBED_MODEL` | provider-specific (see §5) | Embeddings model override |
+| `CONTEXTO_LOCAL_LLM_MODEL` | provider-specific (see §5) | Summarization model override |
+| `CONTEXTO_LOCAL_SUMMARIZE` | `true` | Opt out of per-ingest LLM summarization |
+| `CONTEXTO_LOCAL_SIMILARITY_THRESHOLD` | `0.65` | Cosine threshold for cluster cuts and beam pruning |
+| `CONTEXTO_LOCAL_MAX_DEPTH` | `4` | Maximum tree depth |
+| `CONTEXTO_LOCAL_MAX_CHILDREN` | `10` | Informational (matches TS; not enforced as a hard cap) |
+| `CONTEXTO_LOCAL_REBUILD_INTERVAL` | `50` | Items between full rebuilds |
+| `CONTEXTO_LOCAL_BEAM_WIDTH` | `3` | Retrieval beam width |
+| `CONTEXTO_LOCAL_EMBED_TIMEOUT` | `30` | Embeddings request timeout (seconds) |
+| `CONTEXTO_LOCAL_LLM_TIMEOUT` | `60` | LLM request timeout (seconds) |
+
+### Provider / key resolution
+
+Explicit `CONTEXTO_LOCAL_PROVIDER` wins and requires its matching key. Explicit never silently falls back to the other provider — failing loudly beats quietly billing the wrong account.
+
+| `CONTEXTO_LOCAL_PROVIDER` | Outcome |
+|---|---|
+| (unset) | Prefer `openrouter` if `OPENROUTER_API_KEY` is set; else `openai` if `OPENAI_API_KEY`; else `from_env` returns `None` |
+| `openai` | Use `OPENAI_API_KEY`. Missing key → `from_env` returns `None` (ERROR log) |
+| `openrouter` | Use `OPENROUTER_API_KEY`. Missing key → `from_env` returns `None` (ERROR log) |
+| any other value | `from_env` returns `None` (ERROR log) |
+
+`CONTEXTO_LOCAL_STORAGE_PATH` follows Hermes' data-dir convention: it resolves `$HERMES_HOME` (the Hermes home dir env var; defaults to `~/.hermes`, set to `/opt/data` in the Hermes Docker image) and appends `data/contexto/mindmap.json`. In a Docker container this lands at `/opt/data/data/contexto/mindmap.json`; locally at `~/.hermes/data/contexto/mindmap.json`.
+
+## 8. Storage format
+
+```json
+{
+ "version": 1,
+ "config_snapshot": {
+ "embed_model": "text-embedding-3-small",
+ "llm_model": "gpt-4o-mini",
+ "provider": "openai",
+ "similarity_threshold": 0.65,
+ "max_depth": 4,
+ "max_children": 10,
+ "rebuild_interval": 50,
+ "beam_width": 3
+ },
+ "stats": { "total_items": 42, "total_clusters": 7, "inserts_since_rebuild": 3 },
+ "root": {
+ "id": "root",
+ "label": "Knowledge",
+ "centroid": [...],
+ "children": [
+ {
+ "id": "cluster-1",
+ "label": "deployment errors",
+ "centroid": [...],
+ "children": [],
+ "items": [
+ {
+ "id": "01H...",
+ "role": "assistant",
+ "content": "...",
+ "embedding": [...],
+ "timestamp": "2026-05-23T12:00:00.000Z",
+ "metadata": { "source": "summary", "status": "complete", "...": "..." }
+ }
+ ],
+ "depth": 1,
+ "item_count": 5
+ }
+ ],
+ "items": [],
+ "depth": 0,
+ "item_count": 42
+ }
+}
+```
+
+- Atomic writes only.
+- Not byte-compatible with TS `MindmapState`. Python uses `snake_case` and a simpler schema. This is intentional — see §2.
+- `version: 1` declared up front. Future format bumps gain a migration path; v1 readers reject unknown versions with an ERROR log + quarantine.
+
+## 9. Data flow
+
+### `ingest`
+
+1. Filter payloads to episode/combined events only; non-episode events are ignored.
+2. For each episode, in order:
+ - Extract Q:/A:/T: text from the episode (see §5 extractor).
+ - Produce an `EpisodeSummary` — via the configured LLM when summarization is enabled, or via the synthetic-summary helper when `CONTEXTO_LOCAL_SUMMARIZE=false`.
+ - Embed the item's content (summary + key findings) via the configured provider.
+ - Build a `ConversationItem` with metadata per §6.
+3. Hand the new items to the clusterer, which decides between full rebuild and incremental insert per §5 and updates `state.stats`.
+4. Save the resulting state to disk.
+
+### `search`
+
+1. Embed the query.
+2. Run beam search over the cluster tree, collecting terminal nodes and their items.
+3. Apply the metadata filter (exact-match on each provided key), score remaining items by cosine similarity, apply `min_score`, slice to `max_results`.
+4. Return a `SearchResult` with `items` and `paths` (possibly empty).
+
+`search` returns `None` only on failure (embed error, unexpected exception). An empty store (no cluster tree yet) or a query where no items survive filtering returns an empty `SearchResult`. The empty-store guard short-circuits before the query embedding is requested.
+
+## 10. Error contract
+
+`ingest` and `search` **never raise.** Mirrors `RemoteBackend`.
+
+| Failure mode | Return | Logged at |
+|---|---|---|
+| Embedder HTTP/network error | `ingest`: `False`. `search`: `None`. | ERROR |
+| Summarizer HTTP/parse error | `ingest`: continues with fallback summary; returns `True` if rest succeeds. | WARNING |
+| `store.save()` I/O error | `ingest`: `False`. | ERROR |
+| `store.load()` corrupt file | file quarantined; fresh empty state returned. | ERROR |
+| `scipy.linkage` failure (NaN, etc.) | `ingest`: `False`. | ERROR |
+| Local construction failure | `from_env_local()` returns `None`; `register()` skips registration. | ERROR |
+| Empty store / no results on search | `search`: empty `SearchResult` (not a failure). | (not logged) |
+
+No `ApiError` / `on_error` / `on_success` callbacks in v1 of the local backend.
+
+## 11. Testing
+
+Located in `contexto-py/tests/local/`.
+
+- **Per-module unit tests.** Extractor (Q:/A:/T: prefixes, envelope stripping); labeler (STOP_WORDS, 0/1/n-item branches); embedder + summarizer (`httpx.MockTransport`, provider model selection, fallback paths, `build_synthetic_summary` shape); clustering (rebuild policy, scipy golden outputs, centroid invariants); retrieval (synthetic trees, beam pruning, `paths` are labels not IDs); store (round-trip, atomic-write, corrupt-file quarantine, parent-dir creation).
+- **Integration round-trip.** `tests/local/test_round_trip.py` instantiates `LocalBackend` with fake embedder/summarizer, ingests fixture episodes (40 → confirms `new_total < 100` rebuild; +20 → confirms threshold reuse), searches, asserts top-K item IDs, reinstantiates against the same path and confirms stats reload. Empty-store guard test: construct against a fresh path, patch `retrieval.beam_search` to raise, call `search` — passes iff the guard fires and `search` returns an empty `SearchResult` without invoking retrieval.
+- **Provider/key matrix.** One parametrized test per row of §7's table, covering explicit/implicit provider selection and explicit/key mismatch.
+- **Registration.** `local` with no provider key (error + no registration); `local` with one of the two keys (constructs `LocalBackend`); `remote` with no `CONTEXTO_API_KEY` (existing behavior preserved); invalid `CONTEXTO_BACKEND` value (warns + falls back to remote).
+- **Behavioral fixtures.** `tests/fixtures/local-backend/` holds small JSON files of `(seed_items, queries, expected_top_k_ids)` for cheap regression coverage.
+- **Error-contract tests.** For each row in §10, assert the public boundary returns the documented value and never raises.
+
+## 12. Risks & open items
+
+1. **Single-writer assumption.** Concurrent ingests against the same path race. Atomic replace prevents corruption; last-writer-wins drops data. Add `fcntl.flock` if multi-process use appears.
+2. **AGNES at scale.** Full rebuild is O(n² log n) time and O(n²) memory. Past ~10–20k items, rebuilds become painful. HNSW (`hnswlib`) is the path forward, not a fundamental rewrite.
+3. **Embedding/LLM cost on every ingest.** With `SUMMARIZE=true`, every episode triggers an embed call plus an LLM call. Opt-out via env var.
+4. **Provider API drift.** Both TS and Python implementations call OpenAI/OpenRouter directly. API changes need to be applied in both languages; the small `httpx` surface bounds the blast radius.
+5. **TS↔Python behavioral drift.** No shared algorithm code. scipy's AGNES may differ subtly from TS's `ml-hclust` in tie-breaking and floating-point order. Behavioral fixtures asserting top-K item IDs (not exact scores) are the practical guard.
+6. **`max_children` not enforced.** Matches TS — the value exists in defaults but isn't used as a hard cap in the build/insert paths.
+7. **Storage path convention.** Default resolved from `$HERMES_HOME` (Hermes' standard home-dir env var; defaults `~/.hermes`, `/opt/data` in container). Works without further configuration in both local and Docker contexts.
+
+## 13. Versioning & compatibility
+
+- `contexto-hermes` semver stays independent of `@ekai/contexto`. Adding `LocalBackend` is a MINOR bump (proposed `0.2.0`).
+- On-disk JSON `version: 1`. Future format changes bump this and gain a migration path.
+- `numpy` and `scipy` added to runtime deps. If wheel-size becomes a complaint, move them to an optional extra (`pip install contexto-hermes[local]`) in a future MINOR.
+- `__compatible_contexto_api__` is unaffected — the local backend doesn't call `api.getcontexto.com`.
diff --git a/packages/contexto-py/README.md b/packages/contexto-py/README.md
index 9e5a63a..3197169 100644
--- a/packages/contexto-py/README.md
+++ b/packages/contexto-py/README.md
@@ -2,14 +2,16 @@
[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`.
+Two interchangeable backends:
+
+- **Remote (default)** — ingestion + mindmap retrieval against `api.getcontexto.com`.
+- **Local** — pure-Python pipeline; embeddings + summarization call the user's own OpenAI/OpenRouter key; state lives on disk as a single JSON file.
## Install
```bash
pip install contexto-hermes
-python -m contexto_hermes.install # symlink into hermes-agent's plugin tree
-export CONTEXTO_API_KEY=ckai_xxx
+contexto-hermes-install # symlink into hermes-agent's plugin tree
```
Then in `~/.hermes/config.yaml`:
@@ -19,21 +21,62 @@ context:
engine: contexto
```
+Pick a backend and set its key:
+
+```bash
+# Remote (default)
+export CONTEXTO_API_KEY=ckai_xxx
+
+# Local — pick a provider; either key works.
+export CONTEXTO_BACKEND=local
+export OPENROUTER_API_KEY=sk-or-xxx # or OPENAI_API_KEY=sk-xxx
+```
+
+For a copy-paste walkthrough with verification + Docker notes, see [`docs/contexto-hermes-quickstart.md`](../../docs/contexto-hermes-quickstart.md).
+
## Configuration
-All config is via env vars. Only `CONTEXTO_API_KEY` is required.
+All config is via env vars. Invalid, out-of-range, or NaN values fall back to the default with a `WARNING`; they never block registration.
+
+### Shared
| Env var | Default | Meaning |
|---|---|---|
-| `CONTEXTO_API_KEY` | — | API key from getcontexto.com (required) |
+| `CONTEXTO_BACKEND` | `remote` | `remote` or `local` |
| `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_MIN_SCORE` | `0.45` remote / `0.35` local | Minimum similarity score for retrieved items (0.0–1.0). Local summaries span a whole drop slice, so relevant hits score lower. |
| `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.
+### Remote backend (`CONTEXTO_BACKEND=remote`)
+
+| Env var | Default | Meaning |
+|---|---|---|
+| `CONTEXTO_API_KEY` | — | API key from getcontexto.com (required for remote) |
+
+### Local backend (`CONTEXTO_BACKEND=local`)
+
+Requires one of `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. Adds `numpy` + `scipy` as runtime deps.
+
+| Env var | Default | Meaning |
+|---|---|---|
+| `CONTEXTO_LOCAL_PROVIDER` | inferred (openrouter wins if both keys set; else openai) | `openai` or `openrouter` |
+| `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | — | Provider key matching `CONTEXTO_LOCAL_PROVIDER` |
+| `CONTEXTO_LOCAL_STORAGE_PATH` | `$HERMES_HOME/data/contexto/mindmap.json` (≈ `~/.hermes/data/contexto/mindmap.json` locally, `/opt/data/data/contexto/mindmap.json` in the Hermes Docker image) | On-disk mindmap JSON path |
+| `CONTEXTO_LOCAL_EMBED_MODEL` | provider default (`text-embedding-3-small` / `openai/text-embedding-3-small`) | Override the embeddings model |
+| `CONTEXTO_LOCAL_LLM_MODEL` | provider default (`gpt-4o-mini` / `openai/gpt-4o-mini`) | Override the summarization model |
+| `CONTEXTO_LOCAL_SUMMARIZE` | `true` | When `false`, skips per-ingest LLM summarization and uses a synthetic summary |
+| `CONTEXTO_LOCAL_SIMILARITY_THRESHOLD` | `0.65` | Cosine threshold for cluster cuts + beam pruning |
+| `CONTEXTO_LOCAL_MAX_DEPTH` | `4` | Maximum tree depth |
+| `CONTEXTO_LOCAL_MAX_CHILDREN` | `10` | Informational (matches TS; not enforced as a hard cap) |
+| `CONTEXTO_LOCAL_REBUILD_INTERVAL` | `50` | Items between full rebuilds |
+| `CONTEXTO_LOCAL_BEAM_WIDTH` | `3` | Retrieval beam width |
+| `CONTEXTO_LOCAL_EMBED_TIMEOUT` | `30` | Embeddings request timeout (seconds) |
+| `CONTEXTO_LOCAL_LLM_TIMEOUT` | `60` | LLM request timeout (seconds) |
+
+`CONTEXTO_API_KEY` is ignored in local mode. Explicit `CONTEXTO_LOCAL_PROVIDER` never silently falls back to the other provider — failing loudly beats quietly billing the wrong account.
`CONTEXTO_MAX_RESULTS` sets recall breadth at compaction time. The `contexto_search` tool takes its own `max_results` (default `5`) for on-demand recall.
@@ -51,7 +94,7 @@ Health is observable via the engine's `get_status()`:
}
```
-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).
+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). The `auth_state` / `last_api_error` fields are remote-specific; the local backend leaves `auth_state="ok"` and surfaces failures through the consecutive-ingest counters and standard logging.
Hermes' `/status` command surfaces only token-level fields directly; `auth_state` transitions are logged at INFO so they appear in hermes-agent logs.
diff --git a/packages/contexto-py/e2e/Dockerfile b/packages/contexto-py/e2e/Dockerfile
new file mode 100644
index 0000000..490b410
--- /dev/null
+++ b/packages/contexto-py/e2e/Dockerfile
@@ -0,0 +1,32 @@
+# Slim E2E test container — exercises LocalBackend against a real provider.
+#
+# Build context: contexto-py root.
+# cd contexto/packages/contexto-py
+# docker build -f e2e/Dockerfile -t contexto-local-e2e .
+#
+# Run (loads e2e/.env automatically):
+# docker run --rm --env-file e2e/.env contexto-local-e2e
+
+FROM python:3.12-slim-bookworm
+
+# Build-essential is required for scipy wheels to fall back to source on some platforms;
+# trim runtime image with --no-install-recommends.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy the package, install with deps.
+COPY pyproject.toml ./
+COPY src/ ./src/
+COPY README.md ./
+
+RUN pip install --no-cache-dir --upgrade pip \
+ && pip install --no-cache-dir .
+
+# Copy the e2e harness.
+COPY e2e/run_local_e2e.py ./e2e/run_local_e2e.py
+
+# Default: run the E2E script. Provider creds must come from --env or --env-file.
+CMD ["python", "/app/e2e/run_local_e2e.py"]
diff --git a/packages/contexto-py/e2e/README.md b/packages/contexto-py/e2e/README.md
new file mode 100644
index 0000000..646bca3
--- /dev/null
+++ b/packages/contexto-py/e2e/README.md
@@ -0,0 +1,117 @@
+# LocalBackend E2E
+
+Two ways to exercise the new `LocalBackend` against a real provider.
+
+## Prereqs
+
+Set the provider key in `e2e/.env` (gitignored):
+
+```
+OPENROUTER_API_KEY=sk-or-v1-...
+# or
+OPENAI_API_KEY=sk-...
+```
+
+## 1. Slim Python container (fast — recommended)
+
+Builds a minimal Python image, installs `contexto-hermes`, runs an ingest +
+search round-trip directly against the configured provider.
+
+```bash
+cd contexto/packages/contexto-py
+docker build -f e2e/Dockerfile -t contexto-local-e2e .
+docker run --rm --env-file e2e/.env contexto-local-e2e
+```
+
+Expected (last line):
+
+```
+... INFO e2e | E2E PASSED
+```
+
+What it verifies:
+
+- Provider/key resolution (spec §7).
+- `extract_episode_text` reads Hermes' flat `data.messages` shape.
+- Real HTTP calls to `{base}/chat/completions` and `{base}/embeddings`.
+- scipy AGNES clustering + atomic JSON write.
+- `version: 1` schema written to disk.
+- Spec §6 metadata (`source`, `status`, `confidence`, `evidence_refs`,
+ `open_questions`, `trace_ref`, `sessionKey`, `episode.extracted_text`).
+- Beam search retrieves semantically relevant items (Kubernetes-shaped
+ episodes outrank an unrelated Italian-restaurant one).
+- Persisted state reloads from disk on a fresh instance.
+
+## 2. Full Hermes container (integration with the agent)
+
+Runs the real `hermes-agent` gateway with `CONTEXTO_BACKEND=local`. Two image
+gaps the compose file works around:
+
+1. **Plugin source.** `plugins/context_engine/contexto/` in the hermes-agent
+ repo is an absolute symlink into this source tree. That works for the
+ non-Docker `hermes` CLI but **not** inside an image — `docker build`'s
+ `COPY` resolves the symlink to a host path the container can't see, leaving
+ a broken link at the plugin slot. The compose file bind-mounts this source
+ onto the plugin path at runtime so the loader always sees the live tree.
+2. **Runtime deps.** The hermes-agent image doesn't ship `numpy` or `scipy`;
+ the local backend can't be constructed without them and the gateway falls
+ back to its built-in compressor (`No module named 'numpy'` in logs). The
+ compose `command` runs `uv pip install numpy scipy` against the image's
+ venv before launching the gateway. Idempotent on restarts.
+
+```bash
+# One-time: build the hermes-agent base image (slow — Playwright + npm).
+cd ../../../hermes-agent
+docker build -t hermes-agent .
+
+# Run the gateway against the local backend.
+cd ../contexto/packages/contexto-py
+export HERMES_UID=$(id -u) HERMES_GID=$(id -g)
+docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up
+```
+
+The container's data dir defaults to `/tmp/hermes-contexto-e2e` on the host so
+the test gateway never shares state with a live `~/.hermes`. If you want it to
+use your real Hermes home (e.g. to exercise your existing config), make sure no
+host gateway is running first — the compose file uses host networking, and two
+gateways sharing `state.db`/`gateway.lock` will corrupt state — then:
+
+```bash
+CONTEXTO_E2E_HOME=$HOME/.hermes docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up
+```
+
+If you'd rather bake everything into the image, copy `src/contexto_hermes` to
+`hermes-agent/plugins/context_engine/contexto/` (as a real directory, not a
+symlink) and add `numpy scipy` to the venv before `docker build`. You can then
+drop both the bind mount and the install step from the compose file.
+
+After driving a chat session that triggers `compress()`, the mindmap lands at:
+
+```
+${CONTEXTO_E2E_HOME:-/tmp/hermes-contexto-e2e}/data/contexto/mindmap.json
+```
+
+(Inside the container that resolves to `/opt/data/data/contexto/mindmap.json`
+via `$HERMES_HOME`.)
+
+Quick check:
+
+```bash
+jq '.version, .stats' "${CONTEXTO_E2E_HOME:-/tmp/hermes-contexto-e2e}/data/contexto/mindmap.json"
+```
+
+## Files in this directory
+
+| File | Purpose |
+|---|---|
+| `.env` | Provider secrets — **gitignored**. |
+| `Dockerfile` | Slim Python container running `run_local_e2e.py`. |
+| `run_local_e2e.py` | The actual end-to-end test script. |
+| `docker-compose.hermes-local.yml` | Compose override for running the full hermes-agent against the local backend. |
+| `README.md` | This file. |
+
+## Cost
+
+A single slim-container run makes ~3 chat completions + ~4 embeddings against
+OpenRouter using `openai/gpt-4o-mini` + `openai/text-embedding-3-small`.
+Approximate cost per run: < $0.01.
diff --git a/packages/contexto-py/e2e/docker-compose.hermes-local.yml b/packages/contexto-py/e2e/docker-compose.hermes-local.yml
new file mode 100644
index 0000000..9abdfaa
--- /dev/null
+++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml
@@ -0,0 +1,60 @@
+# Compose override that runs hermes-agent with the local contexto backend.
+#
+# Pre-req: build the hermes-agent base image once.
+# cd ../../../hermes-agent
+# docker build -t hermes-agent .
+#
+# Then from the contexto-py root:
+# docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up
+#
+# This wires CONTEXTO_BACKEND=local + OPENROUTER_API_KEY into the gateway, so
+# any compress() inside Hermes drives the local mindmap.
+#
+# Plugin install (active bind mount below): the bundled
+# `plugins/context_engine/contexto/` in the hermes-agent repo is an absolute
+# symlink into this checkout. Docker's `COPY . .` does NOT follow that to a
+# usable file inside the image — it ends up as a broken symlink pointing at a
+# host path the container can't see. We bind-mount the source directly onto the
+# image's plugin path at runtime so the loader sees the live tree. Read-only
+# because the plugin code should not be mutated from inside the container.
+#
+# Runtime deps (numpy + scipy): the hermes-agent base image does not ship them,
+# and the local backend can't be constructed without them — the plugin loader
+# logs `No module named 'numpy'` and the gateway silently falls back to the
+# built-in compressor. The `sh -c` command below installs both into the image's
+# venv before launching `hermes gateway run`. `uv pip install` is idempotent,
+# so the step is a fast no-op on restarts. The hermes entrypoint activates
+# /opt/hermes/.venv before exec'ing this command, so `uv` and `hermes` are on
+# PATH and the install targets the right interpreter. NB: we call `hermes
+# gateway run` (not `gateway run`) because the entrypoint's `gateway` → `hermes
+# gateway` wrap only fires when `gateway` is the first container arg — inside
+# our `sh -c` it isn't, and `gateway` isn't a standalone binary.
+# Data dir: defaults to a dedicated scratch dir so the test gateway never
+# touches a live ~/.hermes (two gateways sharing state.db / gateway.lock — or
+# host-network port collisions with one already running — will corrupt state).
+# Opt into your real home only when no host gateway is running:
+# CONTEXTO_E2E_HOME=$HOME/.hermes docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up
+services:
+ gateway:
+ image: hermes-agent
+ container_name: hermes-contexto-local
+ restart: "no"
+ network_mode: host
+ volumes:
+ - ${CONTEXTO_E2E_HOME:-/tmp/hermes-contexto-e2e}:/opt/data
+ - ../src/contexto_hermes:/opt/hermes/plugins/context_engine/contexto:ro
+ environment:
+ - HERMES_UID=${HERMES_UID:-10000}
+ - HERMES_GID=${HERMES_GID:-10000}
+ - CONTEXTO_BACKEND=local
+ - CONTEXTO_LOCAL_PROVIDER=openrouter
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
+ # Optional tunables — defaults are sensible.
+ # - CONTEXTO_LOCAL_SIMILARITY_THRESHOLD=0.65
+ # - CONTEXTO_LOCAL_REBUILD_INTERVAL=50
+ command:
+ - sh
+ - -c
+ - |
+ uv pip install --python /opt/hermes/.venv/bin/python numpy scipy \
+ && exec hermes gateway run
diff --git a/packages/contexto-py/e2e/run_local_e2e.py b/packages/contexto-py/e2e/run_local_e2e.py
new file mode 100644
index 0000000..a0772ad
--- /dev/null
+++ b/packages/contexto-py/e2e/run_local_e2e.py
@@ -0,0 +1,180 @@
+"""End-to-end test of LocalBackend against a real provider (default: OpenRouter).
+
+Reads OPENROUTER_API_KEY (or OPENAI_API_KEY) from the environment, runs an ingest +
+search round-trip, and asserts the mindmap state is persisted as expected.
+
+Usage:
+ cd contexto/packages/contexto-py
+ source e2e/.env && OPENROUTER_API_KEY=$OPENROUTER_API_KEY .venv/bin/python e2e/run_local_e2e.py
+
+Exits non-zero on any failure. Designed to run inside the slim Docker container
+(see e2e/Dockerfile) or directly on the host.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
+)
+log = logging.getLogger("e2e")
+
+
+def _build_episode(idx: int, user: str, assistant: str, session_key: str | None = None) -> dict:
+ return {
+ "event": {"type": "episode", "action": "combined"},
+ "sessionKey": session_key or f"e2e-{idx}",
+ "timestamp": "2026-05-26T12:00:00.000Z",
+ "context": {"sessionId": session_key or f"e2e-{idx}", "model": "openrouter", "provider": "openrouter"},
+ "data": {"messages": [
+ {"role": "user", "content": user},
+ {"role": "assistant", "content": assistant},
+ ]},
+ }
+
+
+def main() -> int:
+ log.info("starting LocalBackend E2E")
+ # Force the storage path into a temp dir so re-runs are clean.
+ tmp = Path(tempfile.mkdtemp(prefix="local-e2e-"))
+ storage = tmp / "mindmap.json"
+ os.environ["CONTEXTO_LOCAL_STORAGE_PATH"] = str(storage)
+ os.environ.setdefault("CONTEXTO_BACKEND", "local")
+
+ # Import AFTER setting env so from_env picks up our path.
+ from contexto_hermes.local.backend import LocalBackend
+ from contexto_hermes.local.mindmap_types import LocalBackendConfig
+ from contexto_hermes.local.store import Store
+
+ cfg = LocalBackendConfig.from_env()
+ if cfg is None:
+ log.error("LocalBackendConfig.from_env() returned None — set OPENROUTER_API_KEY or OPENAI_API_KEY")
+ return 2
+
+ log.info(
+ "provider=%s embed_model=%s llm_model=%s storage=%s",
+ cfg.provider, cfg.embed_model, cfg.llm_model, cfg.storage_path,
+ )
+
+ backend = LocalBackend(cfg)
+
+ # ---- ingest ----
+ episodes = [
+ _build_episode(
+ 1,
+ "How do I deploy a Kubernetes cluster?",
+ "Use `kubectl apply -f deploy.yaml`. Check status with `kubectl get pods`.",
+ ),
+ _build_episode(
+ 2,
+ "What's the best Italian restaurant in town?",
+ "Trattoria Da Mario has great pasta and a wood-fired oven.",
+ ),
+ _build_episode(
+ 3,
+ "My Kubernetes pod is stuck in CrashLoopBackOff.",
+ "Check the container logs with `kubectl logs ` and look for the exit reason. "
+ "Common causes: missing env var, OOM, failing healthcheck.",
+ ),
+ ]
+
+ t0 = time.monotonic()
+ ok = backend.ingest(episodes)
+ t1 = time.monotonic()
+ if not ok:
+ log.error("ingest returned False — see ERROR logs above")
+ return 3
+ log.info("ingest OK in %.2fs", t1 - t0)
+
+ # ---- verify persistence ----
+ if not storage.exists():
+ log.error("mindmap.json not written to %s", storage)
+ return 4
+ raw = json.loads(storage.read_text())
+ log.info(
+ "persisted: version=%s total_items=%s total_clusters=%s",
+ raw["version"], raw["stats"]["total_items"], raw["stats"]["total_clusters"],
+ )
+ if raw["version"] != 1:
+ log.error("expected version=1, got %r", raw["version"])
+ return 5
+ if raw["stats"]["total_items"] != 3:
+ log.error("expected total_items=3, got %r", raw["stats"]["total_items"])
+ return 5
+ if raw["root"] is None:
+ log.error("root cluster is None after 3 episodes")
+ return 5
+
+ # Spot-check first item shape (metadata per spec §6).
+ first_cluster = raw["root"]["children"][0]
+ first_item = first_cluster.get("items", [None])[0] or first_cluster["children"][0]["items"][0]
+ md = first_item["metadata"]
+ log.info("first item metadata keys: %s", sorted(md.keys()))
+ for required_key in ("source", "status", "confidence", "evidence_refs",
+ "open_questions", "trace_ref", "sessionKey", "episode"):
+ if required_key not in md:
+ log.error("metadata missing required key %r", required_key)
+ return 6
+ if md["source"] != "summary":
+ log.error("expected metadata.source='summary', got %r", md["source"])
+ return 6
+
+ # ---- search (semantic relevance) ----
+ t0 = time.monotonic()
+ result = backend.search(
+ "kubernetes pod crashing",
+ max_results=3,
+ filter={"source": "summary"},
+ min_score=0.0,
+ )
+ t1 = time.monotonic()
+ if result is None:
+ log.error("search returned None")
+ return 7
+ log.info("search OK in %.2fs, got %d items", t1 - t0, len(result.items))
+ # SearchResult.items entries are {"item": ConversationItem-dict, "score": float}
+ # per spec §5 (TS ScoredQueryResult parity).
+ for i, entry in enumerate(result.items):
+ item = entry["item"]
+ log.info(
+ " rank %d: score=%.3f content[:80]=%r",
+ i + 1, entry["score"], item["content"][:80],
+ )
+
+ # The kubernetes-shaped items should outrank the restaurant one.
+ top_contents = " ".join(e["item"]["content"].lower() for e in result.items[:2])
+ if "kubernetes" not in top_contents and "kubectl" not in top_contents and "pod" not in top_contents:
+ log.warning(
+ "kubernetes terms not in top-2 results — embeddings may not be well-aligned. "
+ "Top-2 contents: %s", top_contents[:300],
+ )
+ # Don't fail hard — semantic search behavior depends on the provider.
+ # We do require at least one item to come back.
+
+ # ---- second instance reload ----
+ backend2 = LocalBackend(cfg)
+ state = Store(cfg.storage_path).load()
+ if state.stats.total_items != 3:
+ log.error("reload: expected 3 items, got %s", state.stats.total_items)
+ return 8
+ result2 = backend2.search("italian food", max_results=3, filter={"source": "summary"})
+ if result2 is None:
+ log.warning("second-instance search returned None")
+ else:
+ log.info("second-instance search OK, %d items", len(result2.items))
+
+ log.info("E2E PASSED")
+ log.info("mindmap.json at %s (size=%d bytes)", storage, storage.stat().st_size)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/packages/contexto-py/pyproject.toml b/packages/contexto-py/pyproject.toml
index 85db44d..ec32fc8 100644
--- a/packages/contexto-py/pyproject.toml
+++ b/packages/contexto-py/pyproject.toml
@@ -5,7 +5,7 @@ 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)"
+description = "Contexto context engine plugin for hermes-agent — remote or local mindmap retrieval"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
@@ -20,7 +20,11 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
-dependencies = ["httpx>=0.27"]
+dependencies = [
+ "httpx>=0.27",
+ "numpy>=1.26",
+ "scipy>=1.13",
+]
[project.optional-dependencies]
test = ["pytest>=8", "tiktoken>=0.7", "pyyaml>=6"]
diff --git a/packages/contexto-py/src/contexto_hermes/__init__.py b/packages/contexto-py/src/contexto_hermes/__init__.py
index b3c7aa9..1b306c2 100644
--- a/packages/contexto-py/src/contexto_hermes/__init__.py
+++ b/packages/contexto-py/src/contexto_hermes/__init__.py
@@ -2,11 +2,19 @@
Plugin entry point. Hermes' `_EngineCollector` exec's this module and calls
`register(ctx)`; we wire a `ContextoEngine` instance into `ctx`.
+
+The plugin supports two backends:
+ - `remote` (default): HTTP to api.getcontexto.com. Requires CONTEXTO_API_KEY.
+ - `local`: pure-Python pipeline with on-disk mindmap; requires an OpenAI or
+ OpenRouter key. See `local/backend.py`.
+
+The active backend is selected via CONTEXTO_BACKEND.
"""
from __future__ import annotations
import logging
+import os
from typing import Any
from .engine import ContextoEngine
@@ -21,14 +29,53 @@
logger = logging.getLogger("plugins.context_engine.contexto")
+_VALID_BACKENDS = ("remote", "local")
+
+
+def _resolve_backend() -> str:
+ raw = os.environ.get("CONTEXTO_BACKEND", "").strip().lower()
+ if not raw:
+ return "remote"
+ if raw not in _VALID_BACKENDS:
+ logger.warning(
+ "Invalid CONTEXTO_BACKEND=%r; falling back to 'remote'. "
+ "Valid values: %s.",
+ raw, ", ".join(_VALID_BACKENDS),
+ )
+ return "remote"
+ return raw
+
+
def register(ctx: Any) -> None:
"""Plugin registration. Called by hermes-agent's context-engine loader."""
- engine = ContextoEngine.from_env()
- if engine is None:
+ backend = _resolve_backend()
+
+ try:
+ if backend == "local":
+ engine = ContextoEngine.from_env_local()
+ else:
+ engine = ContextoEngine.from_env()
+ except Exception as exc:
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=...`."
+ "Contexto plugin not registered: %s backend construction raised: %s",
+ backend, exc, exc_info=True,
)
return
+
+ if engine is None:
+ if backend == "local":
+ logger.error(
+ "Contexto plugin (local) not registered: local config invalid. "
+ "See prior log lines for the specific reason (provider key missing, "
+ "unknown CONTEXTO_LOCAL_PROVIDER, etc.). "
+ "Hermes will fall back to the default 'compressor' engine."
+ )
+ else:
+ 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)
diff --git a/packages/contexto-py/src/contexto_hermes/engine.py b/packages/contexto-py/src/contexto_hermes/engine.py
index ac47dce..f9e1e7e 100644
--- a/packages/contexto-py/src/contexto_hermes/engine.py
+++ b/packages/contexto-py/src/contexto_hermes/engine.py
@@ -121,6 +121,22 @@ def from_env(cls) -> "ContextoEngine | None":
return None
return cls(config)
+ @classmethod
+ def from_env_local(cls) -> "ContextoEngine | None":
+ """Build a ContextoEngine wired to the LocalBackend.
+
+ Returns None when local credentials/config are unusable; the underlying
+ `LocalBackendConfig.from_env` already logged the specific reason.
+ """
+ from .local.backend import LocalBackend
+ from .local.mindmap_types import LocalBackendConfig
+ local_cfg = LocalBackendConfig.from_env()
+ if local_cfg is None:
+ return None
+ engine_cfg = ContextoConfig.local_mode_defaults()
+ backend = LocalBackend(local_cfg)
+ return cls(engine_cfg, backend=backend)
+
def __init__(self, config: ContextoConfig, backend: Any | None = None) -> None:
super().__init__()
self.config = config
diff --git a/packages/contexto-py/src/contexto_hermes/local/__init__.py b/packages/contexto-py/src/contexto_hermes/local/__init__.py
new file mode 100644
index 0000000..8a5870d
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/__init__.py
@@ -0,0 +1,28 @@
+"""Local mindmap backend — runs embeddings + summarization client-side.
+
+Pure-Python; numpy + scipy + httpx. No Contexto-hosted API call.
+"""
+
+from __future__ import annotations
+
+from .mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ EpisodeSummary,
+ EvidenceRef,
+ LocalBackendConfig,
+ MindmapConfig,
+ StoreState,
+ StoreStats,
+)
+
+__all__ = [
+ "ClusterNode",
+ "ConversationItem",
+ "EpisodeSummary",
+ "EvidenceRef",
+ "LocalBackendConfig",
+ "MindmapConfig",
+ "StoreState",
+ "StoreStats",
+]
diff --git a/packages/contexto-py/src/contexto_hermes/local/backend.py b/packages/contexto-py/src/contexto_hermes/local/backend.py
new file mode 100644
index 0000000..b4a403f
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/backend.py
@@ -0,0 +1,259 @@
+"""LocalBackend orchestrator. Same duck-typed contract as RemoteBackend.
+
+`ingest` + `search` **never raise.** All errors land here, converted to
+`False` / `None`. A search that simply finds nothing (empty store, nothing
+above `min_score`) returns an empty `SearchResult` — `None` is reserved for
+failures, matching `RemoteBackend`, so the tool layer can tell "no matches"
+apart from "backend down".
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from dataclasses import asdict
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+
+from ..types import SearchResult, WebhookPayload
+from .clustering import Clusterer
+from .embedder import Embedder, EmbedError
+from .extractor import extract_episode_text
+from .mindmap_types import (
+ ConversationItem,
+ EpisodeSummary,
+ LocalBackendConfig,
+ StoreState,
+)
+from .retrieval import beam_search
+from .store import Store
+from .summarizer import Summarizer, build_synthetic_summary
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+
+def _utcnow_iso() -> str:
+ dt = datetime.now(timezone.utc)
+ millis = dt.microsecond // 1000
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{millis:03d}Z"
+
+
+class LocalBackend:
+ """Sync, never-raises backend. Lazy-loads the store on first use."""
+
+ def __init__(
+ self,
+ config: LocalBackendConfig,
+ *,
+ embedder: Embedder | None = None,
+ summarizer: Summarizer | None = None,
+ store: Store | None = None,
+ embed_transport: httpx.BaseTransport | None = None,
+ llm_transport: httpx.BaseTransport | None = None,
+ ) -> None:
+ self._config = config
+ self._embedder = embedder if embedder is not None else Embedder(config, transport=embed_transport)
+ self._summarizer = (
+ summarizer if summarizer is not None
+ else Summarizer(config, transport=llm_transport)
+ )
+ self._store = store if store is not None else Store(config.storage_path)
+ self._clusterer = Clusterer(config.mindmap)
+ self._state: StoreState | None = None
+
+ # ----- public surface -------------------------------------------------
+ def ingest(self, payloads: list[WebhookPayload]) -> bool:
+ try:
+ return self._ingest_inner(payloads)
+ except Exception as exc:
+ logger.error("[contexto:local] ingest crashed: %s", exc, exc_info=True)
+ return False
+
+ def search(
+ self,
+ query: str,
+ max_results: int,
+ filter: dict[str, Any] | None = None,
+ min_score: float | None = None,
+ ) -> SearchResult | None:
+ try:
+ return self._search_inner(query, max_results, filter, min_score)
+ except Exception as exc:
+ logger.error("[contexto:local] search crashed: %s", exc, exc_info=True)
+ return None
+
+ # ----- ingest internals -----------------------------------------------
+ def _ingest_inner(self, payloads: list[WebhookPayload]) -> bool:
+ if not payloads:
+ return True
+
+ episodes = [
+ p for p in payloads
+ if isinstance(p, dict)
+ and isinstance(p.get("event"), dict)
+ and p["event"].get("type") == "episode"
+ and p["event"].get("action") == "combined"
+ ]
+ if not episodes:
+ logger.debug("[contexto:local] no episode/combined events")
+ return True
+
+ state = self._load_state()
+
+ new_items: list[ConversationItem] = []
+ for ep in episodes:
+ text = extract_episode_text(ep)
+ if not text:
+ logger.debug("[contexto:local] empty episode text, skipping")
+ continue
+
+ summary = self._summarize(text)
+
+ try:
+ embedding = self._embedder.embed(self._embed_input(summary))
+ except EmbedError as exc:
+ logger.error("[contexto:local] embed failed: %s", exc)
+ return False
+
+ new_items.append(self._build_item(ep, text, summary, embedding))
+
+ if not new_items:
+ return True
+
+ try:
+ new_state = self._clusterer.add(state, new_items)
+ except Exception as exc:
+ logger.error("[contexto:local] cluster failed: %s", exc, exc_info=True)
+ return False
+
+ # Spec §8: config_snapshot records the tunables that shaped this state
+ # so a future reader can detect mismatched configs and decide how to
+ # reconcile. Re-stamped on every save so it never lags behind the
+ # active config.
+ new_state.config_snapshot = self._config_snapshot()
+
+ try:
+ self._store.save(new_state)
+ except OSError as exc:
+ logger.error("[contexto:local] store.save failed: %s", exc)
+ return False
+ self._state = new_state
+ logger.info(
+ "[contexto:local] ingested %d episode(s); total=%d",
+ len(new_items), new_state.stats.total_items,
+ )
+ return True
+
+ def _summarize(self, text: str) -> EpisodeSummary:
+ if not self._config.summarize:
+ return build_synthetic_summary(text)
+ return self._summarizer.summarize(text)
+
+ def _embed_input(self, summary: EpisodeSummary) -> str:
+ parts = [summary.summary]
+ if summary.key_findings:
+ findings = "\n".join(f"- {f}" for f in summary.key_findings)
+ parts.append(f"\nKey findings:\n{findings}")
+ return "\n".join(parts)
+
+ def _build_item(
+ self,
+ ep: WebhookPayload,
+ extracted_text: str,
+ summary: EpisodeSummary,
+ embedding: list[float],
+ ) -> ConversationItem:
+ # Stored content is exactly what was embedded.
+ content = self._embed_input(summary)
+
+ metadata: dict[str, Any] = {
+ "source": "summary",
+ "status": summary.status,
+ "confidence": summary.confidence,
+ "evidence_refs": [asdict(ref) for ref in summary.evidence_refs],
+ "open_questions": summary.open_questions,
+ "trace_ref": str(uuid.uuid4()),
+ "sessionKey": ep.get("sessionKey"),
+ "episode": {"extracted_text": extracted_text},
+ }
+
+ return ConversationItem(
+ id=str(uuid.uuid4()),
+ role="assistant",
+ content=content,
+ embedding=embedding,
+ timestamp=ep.get("timestamp") or _utcnow_iso(),
+ metadata=metadata,
+ )
+
+ # ----- search internals -----------------------------------------------
+ def _search_inner(
+ self,
+ query: str,
+ max_results: int,
+ filter: dict[str, Any] | None,
+ min_score: float | None,
+ ) -> SearchResult | None:
+ state = self._load_state()
+ if state.root is None or state.stats.total_items == 0:
+ # Empty store is a valid "no results" answer, not a failure.
+ # Short-circuits before the embed call.
+ return SearchResult(items=[], paths=[])
+
+ try:
+ query_emb = self._embedder.embed(query)
+ except EmbedError as exc:
+ logger.error("[contexto:local] embed query failed: %s", exc)
+ return None
+
+ result = beam_search(
+ state.root,
+ query_emb,
+ self._config.mindmap,
+ beam_width=self._config.beam_width,
+ max_results=max_results,
+ filter=filter,
+ min_score=min_score,
+ )
+
+ # Spec §5 (retrieval) + TS ScoredQueryResult parity: each result is a
+ # wrapper {"item": {...}, "score": float} so callers can rank or
+ # threshold downstream. The engine's `_entry_id` and `format_search_results`
+ # already accept this wrapped shape (and fall back to bare items), so
+ # nothing on the consumer side changes.
+ items_wire: list[dict[str, Any]] = []
+ for scored in result.scored:
+ item = scored.item
+ items_wire.append({
+ "item": {
+ "id": item.id,
+ "role": item.role,
+ "content": item.content,
+ "timestamp": item.timestamp,
+ "metadata": item.metadata,
+ },
+ "score": scored.score,
+ })
+
+ return SearchResult(items=items_wire, paths=result.paths)
+
+ # ----- state ----------------------------------------------------------
+ def _load_state(self) -> StoreState:
+ if self._state is None:
+ self._state = self._store.load()
+ return self._state
+
+ def _config_snapshot(self) -> dict[str, Any]:
+ """Mindmap-shaping fields persisted alongside the tree. Per spec §8."""
+ return {
+ "embed_model": self._config.resolved_embed_model(),
+ "llm_model": self._config.resolved_llm_model(),
+ "provider": self._config.provider,
+ "similarity_threshold": self._config.mindmap.similarity_threshold,
+ "max_depth": self._config.mindmap.max_depth,
+ "max_children": self._config.mindmap.max_children,
+ "rebuild_interval": self._config.mindmap.rebuild_interval,
+ "beam_width": self._config.beam_width,
+ }
diff --git a/packages/contexto-py/src/contexto_hermes/local/clustering.py b/packages/contexto-py/src/contexto_hermes/local/clustering.py
new file mode 100644
index 0000000..b15e33c
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/clustering.py
@@ -0,0 +1,445 @@
+"""AGNES hierarchical clustering via scipy + incremental insert.
+
+Port of TS `clustering.ts`. scipy's `linkage` matrix is converted to a
+dendrogram structure (similar to ml-hclust's AGNES output), then cut at
+`1 - similarity_threshold` and capped at `max_depth` to produce a
+`ClusterNode` tree.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+import numpy as np
+from scipy.cluster.hierarchy import linkage
+
+from .labeler import generate_label
+from .mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ MindmapConfig,
+ StoreStats,
+ StoreState,
+)
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+
+@dataclass
+class _Dendro:
+ """Minimal dendrogram node. Mirrors ml-hclust's AgnesCluster surface."""
+ is_leaf: bool
+ index: int # original-observation index when is_leaf=True, else -1
+ height: float
+ children: tuple["_Dendro", "_Dendro"] | None = None
+
+
+def _build_dendrogram(Z: np.ndarray, n: int) -> _Dendro:
+ """Convert scipy's (n-1)x4 linkage matrix into a binary dendrogram tree.
+
+ Iterative bottom-up to avoid recursion limit on degenerate chains.
+ """
+ nodes: list[_Dendro] = [_Dendro(is_leaf=True, index=i, height=0.0) for i in range(n)]
+ for row in Z:
+ a_idx = int(row[0])
+ b_idx = int(row[1])
+ d = float(row[2])
+ nodes.append(
+ _Dendro(
+ is_leaf=False,
+ index=-1,
+ height=d,
+ children=(nodes[a_idx], nodes[b_idx]),
+ )
+ )
+ return nodes[-1]
+
+
+def _collect_leaves(node: _Dendro, items: list[ConversationItem]) -> list[ConversationItem]:
+ """Iterative DFS over the dendrogram. Avoids stack overflow on long chains."""
+ result: list[ConversationItem] = []
+ stack: list[_Dendro] = [node]
+ while stack:
+ cur = stack.pop()
+ if cur.is_leaf:
+ result.append(items[cur.index])
+ elif cur.children is not None:
+ # push in order so left subtree is processed first
+ stack.append(cur.children[1])
+ stack.append(cur.children[0])
+ return result
+
+
+def _average_centroid(embeddings: list[list[float]]) -> list[float]:
+ if not embeddings:
+ return []
+ arr = np.asarray(embeddings, dtype=np.float64)
+ return arr.mean(axis=0).tolist()
+
+
+def _update_centroid(centroid: list[float], prev_count: int, new_embedding: list[float]) -> list[float]:
+ """Streaming mean update. Mirrors TS updateCentroid."""
+ if prev_count <= 0 or not centroid:
+ return list(new_embedding)
+ if len(centroid) != len(new_embedding):
+ return list(new_embedding)
+ new_count = prev_count + 1
+ return [
+ (c * prev_count + e) / new_count
+ for c, e in zip(centroid, new_embedding)
+ ]
+
+
+def _cosine_sim(a: list[float], b: list[float]) -> float:
+ if not a or not b or len(a) != len(b):
+ return 0.0
+ arr_a = np.asarray(a, dtype=np.float64)
+ arr_b = np.asarray(b, dtype=np.float64)
+ na = float(np.linalg.norm(arr_a))
+ nb = float(np.linalg.norm(arr_b))
+ if na == 0.0 or nb == 0.0:
+ return 0.0
+ return float(arr_a @ arr_b / (na * nb))
+
+
+def _count_clusters(node: ClusterNode) -> int:
+ """Recursive cluster count. Mirrors TS countClusters."""
+ count = 1 if (node.children or node.items) else 0
+ for child in node.children:
+ count += _count_clusters(child)
+ return count
+
+
+def _collect_items(node: ClusterNode) -> list[ConversationItem]:
+ """Gather all items under `node`. Iterative."""
+ result: list[ConversationItem] = []
+ stack: list[ClusterNode] = [node]
+ while stack:
+ cur = stack.pop()
+ result.extend(cur.items)
+ for child in cur.children:
+ stack.append(child)
+ return result
+
+
+class Clusterer:
+ """Stateful (per-mindmap) cluster builder."""
+
+ def __init__(self, config: MindmapConfig) -> None:
+ self._config = config
+ # Plain int (not itertools.count) so we can fast-forward past whatever
+ # the loaded state already used — see _seed_counter_from.
+ self._next_id = 1
+
+ # ---- public API -------------------------------------------------------
+ def add(self, state: StoreState, items: list[ConversationItem]) -> StoreState:
+ """Add `items` to `state`, choosing between full rebuild and incremental insert.
+
+ Rebuild when `new_total < 100` OR
+ `inserts_since_rebuild + len(items) >= rebuild_interval`.
+ Matches TS addToMindmap.
+ """
+ if not items:
+ return state
+
+ # Seed the counter past whatever the loaded tree already used so a new
+ # incremental insert cannot reuse an existing id (e.g. cluster-1 again
+ # after restart). Idempotent: a no-op when we're already ahead.
+ if state.root is not None:
+ self._seed_counter_from(state.root)
+
+ cur_total = state.stats.total_items
+ new_total = cur_total + len(items)
+ should_rebuild = (
+ new_total < 100
+ or state.stats.inserts_since_rebuild + len(items) >= self._config.rebuild_interval
+ )
+
+ if should_rebuild:
+ all_items = _collect_items(state.root) if state.root is not None else []
+ all_items.extend(items)
+ new_root = self._build(all_items)
+ return StoreState(
+ version=state.version,
+ config_snapshot=state.config_snapshot,
+ root=new_root,
+ stats=StoreStats(
+ total_items=len(all_items),
+ total_clusters=_count_clusters(new_root),
+ inserts_since_rebuild=0,
+ ),
+ )
+
+ # Incremental — mutates state.root in place (TS does the same).
+ # Ensure root exists.
+ if state.root is None:
+ new_root = self._build(list(items))
+ return StoreState(
+ version=state.version,
+ config_snapshot=state.config_snapshot,
+ root=new_root,
+ stats=StoreStats(
+ total_items=len(items),
+ total_clusters=_count_clusters(new_root),
+ inserts_since_rebuild=0,
+ ),
+ )
+
+ for item in items:
+ self._incremental_insert(state.root, item)
+
+ return StoreState(
+ version=state.version,
+ config_snapshot=state.config_snapshot,
+ root=state.root,
+ stats=StoreStats(
+ total_items=new_total,
+ total_clusters=_count_clusters(state.root),
+ inserts_since_rebuild=state.stats.inserts_since_rebuild + len(items),
+ ),
+ )
+
+ # ---- build (rebuild) --------------------------------------------------
+ def _build(self, items: list[ConversationItem]) -> ClusterNode:
+ if len(items) == 0:
+ return ClusterNode(
+ id="root", label="Knowledge", centroid=[], children=[], items=[],
+ depth=0, item_count=0,
+ )
+
+ if len(items) == 1:
+ it = items[0]
+ leaf = ClusterNode(
+ id=self._new_id(),
+ label=generate_label([it], it.embedding),
+ centroid=list(it.embedding),
+ children=[],
+ items=[it],
+ depth=1,
+ item_count=1,
+ )
+ return ClusterNode(
+ id="root",
+ label="Knowledge",
+ centroid=list(it.embedding),
+ children=[leaf],
+ items=[],
+ depth=0,
+ item_count=1,
+ )
+
+ # scipy.linkage with method='average' + metric='cosine'.
+ embeddings = np.asarray([it.embedding for it in items], dtype=np.float64)
+ if not np.isfinite(embeddings).all():
+ raise ValueError("embeddings contain non-finite values")
+ if (np.linalg.norm(embeddings, axis=1) == 0.0).any():
+ # Cosine distance is undefined for zero vectors — scipy emits NaN,
+ # which would silently merge everything at distance 0 below.
+ raise ValueError("embeddings contain zero vectors")
+ Z = linkage(embeddings, method="average", metric="cosine")
+ # Identical points can yield tiny negative distances (float error);
+ # clamp those and any residual NaNs to 0. (Index/count columns are
+ # non-negative, so a whole-matrix clamp is safe.)
+ Z = np.where(np.isnan(Z) | (Z < 0.0), 0.0, Z)
+ tree = _build_dendrogram(Z, len(items))
+
+ distance_threshold = 1.0 - self._config.similarity_threshold
+
+ # If the root merge is below threshold, everything is one cluster.
+ if tree.height <= distance_threshold:
+ centroid = _average_centroid([it.embedding for it in items])
+ single = ClusterNode(
+ id=self._new_id(),
+ label=generate_label(items, centroid),
+ centroid=centroid,
+ children=[],
+ items=list(items),
+ depth=1,
+ item_count=len(items),
+ )
+ return ClusterNode(
+ id="root",
+ label="Knowledge",
+ centroid=centroid,
+ children=[single],
+ items=[],
+ depth=0,
+ item_count=len(items),
+ )
+
+ # Walk the dendrogram, cutting at distance_threshold and depth cap.
+ top_children: list[ClusterNode] = []
+ assert tree.children is not None
+ for child in tree.children:
+ if child.is_leaf:
+ top_children.append(self._dendro_to_tree(child, items, depth=1))
+ elif child.height > distance_threshold and 2 < self._config.max_depth:
+ top_children.append(self._dendro_to_tree(child, items, depth=1))
+ else:
+ leaf_items = _collect_leaves(child, items)
+ centroid = _average_centroid([i.embedding for i in leaf_items])
+ top_children.append(ClusterNode(
+ id=self._new_id(),
+ label=generate_label(leaf_items, centroid),
+ centroid=centroid,
+ children=[],
+ items=leaf_items,
+ depth=1,
+ item_count=len(leaf_items),
+ ))
+
+ all_items: list[ConversationItem] = []
+ for c in top_children:
+ all_items.extend(_collect_items(c))
+ root_centroid = _average_centroid([i.embedding for i in all_items])
+ return ClusterNode(
+ id="root",
+ label="Knowledge",
+ centroid=root_centroid,
+ children=top_children,
+ items=[],
+ depth=0,
+ item_count=len(all_items),
+ )
+
+ def _dendro_to_tree(
+ self,
+ agnes_node: _Dendro,
+ items: list[ConversationItem],
+ depth: int,
+ ) -> ClusterNode:
+ """Walk one subtree of the dendrogram into a ClusterNode. Recursion bounded by max_depth."""
+ if agnes_node.is_leaf:
+ it = items[agnes_node.index]
+ return ClusterNode(
+ id=self._new_id(),
+ label=generate_label([it], it.embedding),
+ centroid=list(it.embedding),
+ children=[],
+ items=[it],
+ depth=depth,
+ item_count=1,
+ )
+
+ assert agnes_node.children is not None
+ distance_threshold = 1.0 - self._config.similarity_threshold
+ child_nodes: list[ClusterNode] = []
+ for child in agnes_node.children:
+ if child.is_leaf:
+ child_nodes.append(self._dendro_to_tree(child, items, depth + 1))
+ elif child.height <= distance_threshold or depth + 1 >= self._config.max_depth:
+ leaf_items = _collect_leaves(child, items)
+ centroid = _average_centroid([i.embedding for i in leaf_items])
+ child_nodes.append(ClusterNode(
+ id=self._new_id(),
+ label=generate_label(leaf_items, centroid),
+ centroid=centroid,
+ children=[],
+ items=leaf_items,
+ depth=depth + 1,
+ item_count=len(leaf_items),
+ ))
+ else:
+ child_nodes.append(self._dendro_to_tree(child, items, depth + 1))
+
+ all_items: list[ConversationItem] = []
+ for c in child_nodes:
+ all_items.extend(_collect_items(c))
+ centroid = _average_centroid([i.embedding for i in all_items])
+ return ClusterNode(
+ id=self._new_id(),
+ label=generate_label(all_items, centroid),
+ centroid=centroid,
+ children=child_nodes,
+ items=[],
+ depth=depth,
+ item_count=len(all_items),
+ )
+
+ # ---- incremental insert ----------------------------------------------
+ def _incremental_insert(self, node: ClusterNode, item: ConversationItem) -> None:
+ """Descend, choosing the best-similarity child; create new child if none qualifies.
+
+ Iterative (bounded by max_depth anyway). Updates centroids on the visited path.
+ """
+ path: list[ClusterNode] = [node]
+ while True:
+ current = path[-1]
+ current.item_count += 1
+
+ if not current.children:
+ # Leaf-ish — drop the item here.
+ prev_count = len(current.items)
+ current.items.append(item)
+ current.centroid = _update_centroid(
+ current.centroid, prev_count, item.embedding
+ )
+ current.label = generate_label(current.items, current.centroid)
+ break
+
+ # Score children by cosine sim to item.embedding
+ best_child: ClusterNode | None = None
+ best_sim = -1.0
+ for child in current.children:
+ sim = _cosine_sim(item.embedding, child.centroid)
+ if sim > best_sim:
+ best_sim = sim
+ best_child = child
+
+ if (
+ best_child is not None
+ and best_sim >= self._config.similarity_threshold
+ and best_child.depth < self._config.max_depth
+ ):
+ # Update current centroid on the way down (matches TS).
+ current.centroid = _update_centroid(
+ current.centroid, current.item_count - 1, item.embedding
+ )
+ path.append(best_child)
+ continue
+
+ # No good match — create a new child cluster under current.
+ current.children.append(ClusterNode(
+ id=self._new_id(),
+ label=generate_label([item], item.embedding),
+ centroid=list(item.embedding),
+ children=[],
+ items=[item],
+ depth=current.depth + 1,
+ item_count=1,
+ ))
+ current.centroid = _update_centroid(
+ current.centroid, current.item_count - 1, item.embedding
+ )
+ break
+
+ def _new_id(self) -> str:
+ cid = f"cluster-{self._next_id}"
+ self._next_id += 1
+ return cid
+
+ def _seed_counter_from(self, root: ClusterNode) -> None:
+ """Push `_next_id` past the largest `cluster-N` already in the tree.
+
+ Guards against ID collisions when a persisted state is reloaded and the
+ next operation is an incremental insert (which would otherwise restart
+ the counter at 1 and produce a duplicate id under root).
+ """
+ max_n = 0
+ stack: list[ClusterNode] = [root]
+ while stack:
+ node = stack.pop()
+ if node.id.startswith("cluster-"):
+ try:
+ n = int(node.id.split("-", 1)[1])
+ except (ValueError, IndexError):
+ n = 0
+ if n > max_n:
+ max_n = n
+ stack.extend(node.children)
+ if self._next_id <= max_n:
+ self._next_id = max_n + 1
+
+
+__all__ = ["Clusterer"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/embedder.py b/packages/contexto-py/src/contexto_hermes/local/embedder.py
new file mode 100644
index 0000000..c19caac
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/embedder.py
@@ -0,0 +1,90 @@
+"""Embeddings client. POST to {/embeddings} on OpenAI or OpenRouter.
+
+Per-call `httpx.Client` (sync). Provider-specific base URLs + default models live
+in `LocalBackendConfig`. The embedder raises on HTTP / parse failures; the
+caller (LocalBackend) wraps these for the never-raises contract.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import httpx
+
+from .mindmap_types import LocalBackendConfig
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+
+class EmbedError(RuntimeError):
+ """Raised when the embeddings endpoint fails (HTTP / parse / network)."""
+
+
+class Embedder:
+ def __init__(
+ self,
+ config: LocalBackendConfig,
+ transport: httpx.BaseTransport | None = None,
+ ) -> None:
+ self._config = config
+ self._transport = transport
+
+ def embed(self, text: str) -> list[float]:
+ """Return a single vector. Raises EmbedError on any failure."""
+ url = f"{self._config.embed_base_url}/embeddings"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self._config.api_key}",
+ }
+ body: dict[str, Any] = {
+ "model": self._config.resolved_embed_model(),
+ "input": text,
+ }
+
+ try:
+ with self._client() as client:
+ response = client.post(url, headers=headers, json=body)
+ except httpx.HTTPError as exc:
+ raise EmbedError(f"embed network error: {exc}") from exc
+
+ if not response.is_success:
+ preview = ""
+ try:
+ preview = response.text[:200]
+ except Exception:
+ pass
+ raise EmbedError(
+ f"embed HTTP {response.status_code}: {preview}"
+ )
+
+ try:
+ data = response.json()
+ except ValueError as exc:
+ raise EmbedError(f"embed response not JSON: {exc}") from exc
+
+ if not isinstance(data, dict):
+ raise EmbedError("embed response top-level was not an object")
+
+ items = data.get("data")
+ if not isinstance(items, list) or not items:
+ raise EmbedError("embed response missing data[]")
+ first = items[0]
+ if not isinstance(first, dict):
+ raise EmbedError("embed response data[0] not an object")
+ vec = first.get("embedding")
+ if not isinstance(vec, list) or not vec:
+ raise EmbedError("embed response missing data[0].embedding")
+ try:
+ return [float(x) for x in vec]
+ except (TypeError, ValueError) as exc:
+ raise EmbedError(f"embed vector contained non-numeric: {exc}") from exc
+
+ def _client(self) -> httpx.Client:
+ kwargs: dict[str, Any] = {"timeout": self._config.embed_timeout}
+ if self._transport is not None:
+ kwargs["transport"] = self._transport
+ return httpx.Client(**kwargs)
+
+
+__all__ = ["Embedder", "EmbedError"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/extractor.py b/packages/contexto-py/src/contexto_hermes/local/extractor.py
new file mode 100644
index 0000000..91fa5fb
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/extractor.py
@@ -0,0 +1,58 @@
+"""Extract Q:/A:/T: episode text from a Hermes WebhookPayload.
+
+Hermes' `build_episode_payload` writes `data: {"messages": [...]}` — a flat list of
+role-tagged messages. TS expects `data.userMessage` / `assistantMessages` / `toolMessages`,
+a shape OpenClaw produces but Hermes does not. This extractor reads the flat shape
+and emits the same Q:/A:/T: text TS produces.
+
+Spec: §5 (extractor).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..helpers import normalize_message_text, strip_metadata_envelope
+from ..types import WebhookPayload
+
+
+def extract_episode_text(payload: WebhookPayload) -> str:
+ """Return Q:/A:/T:-prefixed text or `""` for non-episode events."""
+ event = payload.get("event") if isinstance(payload, dict) else None
+ if not isinstance(event, dict):
+ return ""
+ if event.get("type") != "episode" or event.get("action") != "combined":
+ return ""
+
+ data = payload.get("data") if isinstance(payload, dict) else None
+ if not isinstance(data, dict):
+ return ""
+ messages = data.get("messages")
+ if not isinstance(messages, list):
+ return ""
+
+ parts: list[str] = []
+ for msg in messages:
+ if not isinstance(msg, dict):
+ continue
+ role = msg.get("role")
+ text = normalize_message_text(msg)
+ if role == "user":
+ if not text:
+ continue
+ stripped = strip_metadata_envelope(text)
+ parts.append(f"Q: {stripped}")
+ elif role == "assistant":
+ if not text:
+ continue
+ parts.append(f"A: {text}")
+ elif role == "tool":
+ if not text:
+ continue
+ parts.append(f"T: {text}")
+ # Other roles (system, etc.) ignored.
+
+ return "\n".join(parts)
+
+
+__all__ = ["extract_episode_text"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/labeler.py b/packages/contexto-py/src/contexto_hermes/local/labeler.py
new file mode 100644
index 0000000..7205910
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/labeler.py
@@ -0,0 +1,91 @@
+"""Cluster label generator. Port of TS `generateLabel` in packages/mindmap/src/labeler.ts."""
+
+from __future__ import annotations
+
+import re
+from collections import Counter
+from typing import Iterable, Sequence
+
+from .mindmap_types import ConversationItem
+
+# Verbatim from TS labeler.ts:4-18.
+STOP_WORDS: frozenset[str] = frozenset({
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
+ "should", "may", "might", "can", "shall", "to", "of", "in", "for",
+ "on", "with", "at", "by", "from", "as", "into", "about", "like",
+ "through", "after", "over", "between", "out", "against", "during",
+ "without", "before", "under", "around", "among", "and", "but", "or",
+ "nor", "not", "so", "yet", "both", "either", "neither", "each",
+ "every", "all", "any", "few", "more", "most", "other", "some",
+ "such", "no", "only", "own", "same", "than", "too", "very",
+ "just", "because", "if", "when", "where", "how", "what", "which",
+ "who", "whom", "this", "that", "these", "those", "i", "me", "my",
+ "we", "our", "you", "your", "he", "him", "his", "she", "her",
+ "it", "its", "they", "them", "their",
+})
+
+_NON_WORD_RE = re.compile(r"[^a-z0-9\s]")
+_WS_RE = re.compile(r"\s+")
+
+
+def extract_keywords(text: str) -> list[str]:
+ """Lowercase, strip non-word, split, keep words >2 chars not in STOP_WORDS."""
+ if not text:
+ return []
+ lowered = text.lower()
+ no_punct = _NON_WORD_RE.sub(" ", lowered)
+ words = _WS_RE.split(no_punct.strip())
+ return [w for w in words if len(w) > 2 and w not in STOP_WORDS]
+
+
+def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
+ if not a or not b or len(a) != len(b):
+ return 0.0
+ dot = 0.0
+ na = 0.0
+ nb = 0.0
+ for ai, bi in zip(a, b):
+ dot += ai * bi
+ na += ai * ai
+ nb += bi * bi
+ if na == 0.0 or nb == 0.0:
+ return 0.0
+ return dot / ((na ** 0.5) * (nb ** 0.5))
+
+
+def generate_label(items: Iterable[ConversationItem], centroid: Sequence[float]) -> str:
+ """Three-branch behavior matching TS labeler.ts:28-62."""
+ items_list = list(items)
+
+ if len(items_list) == 0:
+ return "Empty"
+
+ if len(items_list) == 1:
+ words = extract_keywords(items_list[0].content)
+ first_four = " ".join(words[:4])
+ return first_four or items_list[0].content[:30]
+
+ # Find item closest to centroid (ties: first-occurrence, matches TS `>` not `>=`)
+ best_item = items_list[0]
+ best_sim = -1.0
+ for item in items_list:
+ sim = _cosine(item.embedding, centroid)
+ if sim > best_sim:
+ best_sim = sim
+ best_item = item
+
+ representative = extract_keywords(best_item.content)
+ if representative:
+ return " ".join(representative[:4])
+
+ # Fallback: top-3 most frequent across all items.
+ freq: Counter[str] = Counter()
+ for item in items_list:
+ freq.update(extract_keywords(item.content))
+ # Counter.most_common is stable; matches JS sort-by-frequency w/ insertion ordering.
+ top_three = [w for w, _ in freq.most_common(3)]
+ return " ".join(top_three) or "Cluster"
+
+
+__all__ = ["STOP_WORDS", "extract_keywords", "generate_label"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/mindmap_types.py b/packages/contexto-py/src/contexto_hermes/local/mindmap_types.py
new file mode 100644
index 0000000..19d4e64
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/mindmap_types.py
@@ -0,0 +1,220 @@
+"""Dataclasses for the local mindmap backend.
+
+Module named `mindmap_types.py` to avoid colliding with `contexto_hermes.types`.
+The on-disk JSON schema (spec §8) is the source of truth for field semantics;
+these dataclasses mirror it.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from ..types import _env_bool, _env_float, _env_int
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+
+# Provider-specific defaults. Spec §5.
+_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
+ "openai": {
+ "embed_model": "text-embedding-3-small",
+ "llm_model": "gpt-4o-mini",
+ "embed_base_url": "https://api.openai.com/v1",
+ "llm_base_url": "https://api.openai.com/v1",
+ },
+ "openrouter": {
+ "embed_model": "openai/text-embedding-3-small",
+ "llm_model": "openai/gpt-4o-mini",
+ "embed_base_url": "https://openrouter.ai/api/v1",
+ "llm_base_url": "https://openrouter.ai/api/v1",
+ },
+}
+
+
+def _default_storage_path() -> str:
+ """Resolve `$HERMES_HOME/data/contexto/mindmap.json`, falling back to `~/.hermes`."""
+ base = os.environ.get("HERMES_HOME") or "~/.hermes"
+ return str(Path(base).expanduser() / "data" / "contexto" / "mindmap.json")
+
+
+@dataclass
+class MindmapConfig:
+ """Mirrors TS `DEFAULT_CONFIG` in packages/mindmap/src/types.ts:80-85."""
+
+ similarity_threshold: float = 0.65
+ max_depth: int = 4
+ max_children: int = 10 # informational; not enforced (matches TS)
+ rebuild_interval: int = 50
+
+
+@dataclass
+class LocalBackendConfig:
+ """Configuration for the local mindmap backend.
+
+ `from_env()` returns None when credentials/config are unusable. Provider/key
+ resolution rules are in spec §7.
+
+ `embed_model` and `llm_model` are nullable: `None` means "use the provider
+ default" (resolved at call time by `Embedder`/`Summarizer`). `from_env`
+ leaves them as None when the user did not override; direct construction may
+ do the same.
+ """
+
+ storage_path: str
+ provider: str # "openai" | "openrouter"
+ api_key: str
+ embed_base_url: str
+ llm_base_url: str
+ embed_model: str | None = None
+ llm_model: str | None = None
+ summarize: bool = True
+ mindmap: MindmapConfig = field(default_factory=MindmapConfig)
+ beam_width: int = 3
+ embed_timeout: float = 30.0
+ llm_timeout: float = 60.0
+
+ def resolved_embed_model(self) -> str:
+ """Provider default when `embed_model` is None."""
+ if self.embed_model:
+ return self.embed_model
+ return _PROVIDER_DEFAULTS[self.provider]["embed_model"]
+
+ def resolved_llm_model(self) -> str:
+ """Provider default when `llm_model` is None."""
+ if self.llm_model:
+ return self.llm_model
+ return _PROVIDER_DEFAULTS[self.provider]["llm_model"]
+
+ @classmethod
+ def from_env(cls) -> "LocalBackendConfig | None":
+ """Read CONTEXTO_LOCAL_* env vars. Returns None on unusable config."""
+ # Provider selection (spec §7 table)
+ explicit_provider = os.environ.get("CONTEXTO_LOCAL_PROVIDER", "").strip().lower()
+ openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
+ openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
+
+ provider: str
+ api_key: str
+ if explicit_provider:
+ if explicit_provider == "openai":
+ if not openai_key:
+ logger.error(
+ "CONTEXTO_LOCAL_PROVIDER=openai but OPENAI_API_KEY is unset."
+ )
+ return None
+ provider, api_key = "openai", openai_key
+ elif explicit_provider == "openrouter":
+ if not openrouter_key:
+ logger.error(
+ "CONTEXTO_LOCAL_PROVIDER=openrouter but OPENROUTER_API_KEY is unset."
+ )
+ return None
+ provider, api_key = "openrouter", openrouter_key
+ else:
+ logger.error(
+ "Unknown CONTEXTO_LOCAL_PROVIDER=%r (expected 'openai' or 'openrouter').",
+ explicit_provider,
+ )
+ return None
+ else:
+ # Implicit: prefer openrouter, then openai.
+ if openrouter_key:
+ provider, api_key = "openrouter", openrouter_key
+ elif openai_key:
+ provider, api_key = "openai", openai_key
+ else:
+ logger.error(
+ "Local backend requires OPENROUTER_API_KEY or OPENAI_API_KEY."
+ )
+ return None
+
+ defaults = _PROVIDER_DEFAULTS[provider]
+ # None ⇒ use provider default at call time (resolved_embed_model / resolved_llm_model).
+ embed_model = os.environ.get("CONTEXTO_LOCAL_EMBED_MODEL", "").strip() or None
+ llm_model = os.environ.get("CONTEXTO_LOCAL_LLM_MODEL", "").strip() or None
+ storage_path = os.environ.get("CONTEXTO_LOCAL_STORAGE_PATH", "").strip() or _default_storage_path()
+
+ mindmap = MindmapConfig(
+ similarity_threshold=_env_float(
+ "CONTEXTO_LOCAL_SIMILARITY_THRESHOLD",
+ default=0.65,
+ minimum=0.0,
+ maximum=1.0,
+ ),
+ max_depth=_env_int("CONTEXTO_LOCAL_MAX_DEPTH", default=4, minimum=1),
+ max_children=_env_int("CONTEXTO_LOCAL_MAX_CHILDREN", default=10, minimum=1),
+ rebuild_interval=_env_int("CONTEXTO_LOCAL_REBUILD_INTERVAL", default=50, minimum=1),
+ )
+
+ return cls(
+ storage_path=storage_path,
+ provider=provider,
+ api_key=api_key,
+ embed_model=embed_model,
+ llm_model=llm_model,
+ embed_base_url=defaults["embed_base_url"],
+ llm_base_url=defaults["llm_base_url"],
+ summarize=_env_bool("CONTEXTO_LOCAL_SUMMARIZE", default=True),
+ mindmap=mindmap,
+ beam_width=_env_int("CONTEXTO_LOCAL_BEAM_WIDTH", default=3, minimum=1),
+ embed_timeout=_env_float("CONTEXTO_LOCAL_EMBED_TIMEOUT", default=30.0, minimum=0.0),
+ llm_timeout=_env_float("CONTEXTO_LOCAL_LLM_TIMEOUT", default=60.0, minimum=0.0),
+ )
+
+
+@dataclass
+class EvidenceRef:
+ type: str # "episode_ref" | "tool_ref" | "file_ref" | "trace_ref"
+ value: str
+
+
+@dataclass
+class EpisodeSummary:
+ """Mirrors TS EpisodeSummary in packages/contexto/src/local/types.ts."""
+
+ summary: str
+ key_findings: list[str]
+ status: str # "complete" | "partial" | "blocked"
+ confidence: float
+ evidence_refs: list[EvidenceRef] = field(default_factory=list)
+ open_questions: list[str] | None = None
+
+
+@dataclass
+class ConversationItem:
+ id: str
+ role: str
+ content: str
+ embedding: list[float]
+ timestamp: str | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class ClusterNode:
+ id: str
+ label: str
+ centroid: list[float]
+ children: list["ClusterNode"] = field(default_factory=list)
+ items: list[ConversationItem] = field(default_factory=list)
+ depth: int = 0
+ item_count: int = 0
+
+
+@dataclass
+class StoreStats:
+ total_items: int = 0
+ total_clusters: int = 0
+ inserts_since_rebuild: int = 0
+
+
+@dataclass
+class StoreState:
+ version: int = 1
+ config_snapshot: dict[str, Any] = field(default_factory=dict)
+ root: ClusterNode | None = None
+ stats: StoreStats = field(default_factory=StoreStats)
diff --git a/packages/contexto-py/src/contexto_hermes/local/retrieval.py b/packages/contexto-py/src/contexto_hermes/local/retrieval.py
new file mode 100644
index 0000000..f44c5a9
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/retrieval.py
@@ -0,0 +1,124 @@
+"""Beam search retrieval over the cluster tree.
+
+Port of TS `queryMindmapMultiBranch`. Returns a flat list of scored items plus
+`paths` — a list of label paths (not IDs) leading to each terminal node.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+from .clustering import _collect_items, _cosine_sim
+from .mindmap_types import ClusterNode, ConversationItem, MindmapConfig
+
+
+@dataclass
+class ScoredItem:
+ item: ConversationItem
+ score: float
+
+
+@dataclass
+class BeamResult:
+ items: list[ConversationItem]
+ paths: list[list[str]] = field(default_factory=list)
+ scored: list[ScoredItem] = field(default_factory=list)
+
+
+@dataclass
+class _BeamEntry:
+ node: ClusterNode
+ path: list[str]
+
+
+def beam_search(
+ root: ClusterNode,
+ query_embedding: list[float],
+ config: MindmapConfig,
+ *,
+ beam_width: int,
+ max_results: int,
+ filter: dict[str, Any] | None = None,
+ min_score: float | None = None,
+) -> BeamResult:
+ """Run beam search; return scored items + label paths."""
+ if root is None:
+ return BeamResult(items=[], paths=[], scored=[])
+
+ threshold = config.similarity_threshold
+ terminals: list[_BeamEntry] = []
+
+ # Seed beam with root's children that pass the threshold.
+ root_candidates = sorted(
+ (
+ (child, _cosine_sim(query_embedding, child.centroid))
+ for child in root.children
+ ),
+ key=lambda c: c[1],
+ reverse=True,
+ )
+ qualified = [(c, s) for c, s in root_candidates if s >= threshold][:beam_width]
+
+ if not qualified:
+ # Fall back to collecting from root itself.
+ terminals.append(_BeamEntry(node=root, path=[]))
+ beam: list[_BeamEntry] = []
+ else:
+ beam = [_BeamEntry(node=c, path=[c.label]) for c, _ in qualified]
+
+ # Expand level by level.
+ while beam:
+ next_candidates: list[tuple[_BeamEntry, float]] = []
+ for entry in beam:
+ if not entry.node.children:
+ terminals.append(entry)
+ continue
+ child_scores = [
+ (child, _cosine_sim(query_embedding, child.centroid))
+ for child in entry.node.children
+ ]
+ qualified_children = [(c, s) for c, s in child_scores if s >= threshold]
+ if not qualified_children:
+ terminals.append(entry)
+ continue
+ for child, sim in qualified_children:
+ next_candidates.append((
+ _BeamEntry(node=child, path=entry.path + [child.label]),
+ sim,
+ ))
+ next_candidates.sort(key=lambda pair: pair[1], reverse=True)
+ beam = [entry for entry, _ in next_candidates[:beam_width]]
+
+ # Gather items from all terminal nodes, dedup by id.
+ seen: set[str] = set()
+ all_items: list[ConversationItem] = []
+ for terminal in terminals:
+ for item in _collect_items(terminal.node):
+ if item.id in seen:
+ continue
+ seen.add(item.id)
+ all_items.append(item)
+
+ # Apply metadata filter (exact match).
+ if filter:
+ all_items = [
+ it for it in all_items
+ if all(it.metadata.get(k) == v for k, v in filter.items())
+ ]
+
+ scored = [
+ ScoredItem(item=it, score=_cosine_sim(query_embedding, it.embedding))
+ for it in all_items
+ ]
+ scored.sort(key=lambda s: s.score, reverse=True)
+
+ if min_score is not None:
+ scored = [s for s in scored if s.score >= min_score]
+
+ scored = scored[:max_results]
+ paths = [t.path for t in terminals]
+ return BeamResult(items=[s.item for s in scored], paths=paths, scored=scored)
+
+
+__all__ = ["beam_search", "BeamResult", "ScoredItem"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/store.py b/packages/contexto-py/src/contexto_hermes/local/store.py
new file mode 100644
index 0000000..b549d24
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/store.py
@@ -0,0 +1,188 @@
+"""JSON load/save for the local mindmap state.
+
+- Atomic write via `.tmp` + `os.replace`.
+- Parent directory autocreated.
+- Corrupt or wrong-version files are renamed to `.corrupted-`
+ and a fresh empty `StoreState` is returned. Single-writer assumption (v1).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any
+
+from .mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ StoreState,
+ StoreStats,
+)
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+SCHEMA_VERSION = 1
+
+
+def _node_from_dict(d: Any) -> ClusterNode | None:
+ if d is None:
+ return None
+ if not isinstance(d, dict):
+ raise ValueError("ClusterNode must be a dict")
+ children = [_node_from_dict(c) for c in d.get("children", [])]
+ items = [_item_from_dict(i) for i in d.get("items", [])]
+ return ClusterNode(
+ id=str(d["id"]),
+ label=str(d.get("label", "")),
+ centroid=list(d.get("centroid") or []),
+ children=[c for c in children if c is not None],
+ items=items,
+ depth=int(d.get("depth", 0)),
+ item_count=int(d.get("item_count", 0)),
+ )
+
+
+def _item_from_dict(d: Any) -> ConversationItem:
+ if not isinstance(d, dict):
+ raise ValueError("ConversationItem must be a dict")
+ return ConversationItem(
+ id=str(d["id"]),
+ role=str(d.get("role", "")),
+ content=str(d.get("content", "")),
+ embedding=list(d.get("embedding") or []),
+ timestamp=d.get("timestamp"),
+ metadata=dict(d.get("metadata") or {}),
+ )
+
+
+def _state_to_dict(state: StoreState) -> dict[str, Any]:
+ return {
+ "version": state.version,
+ "config_snapshot": state.config_snapshot,
+ "stats": asdict(state.stats),
+ "root": _node_to_dict(state.root) if state.root is not None else None,
+ }
+
+
+def _node_to_dict(node: ClusterNode) -> dict[str, Any]:
+ return {
+ "id": node.id,
+ "label": node.label,
+ "centroid": node.centroid,
+ "children": [_node_to_dict(c) for c in node.children],
+ "items": [_item_to_dict(i) for i in node.items],
+ "depth": node.depth,
+ "item_count": node.item_count,
+ }
+
+
+def _item_to_dict(item: ConversationItem) -> dict[str, Any]:
+ return {
+ "id": item.id,
+ "role": item.role,
+ "content": item.content,
+ "embedding": item.embedding,
+ "timestamp": item.timestamp,
+ "metadata": item.metadata,
+ }
+
+
+class Store:
+ """File-backed mindmap store. Lazy-loads on first call to load()."""
+
+ def __init__(self, path: str) -> None:
+ self._path = Path(path).expanduser()
+
+ @property
+ def path(self) -> Path:
+ return self._path
+
+ def load(self) -> StoreState:
+ """Read state from disk. Quarantine corrupt/incompatible files."""
+ if not self._path.exists():
+ return _empty_state()
+
+ try:
+ raw = self._path.read_text(encoding="utf-8")
+ data = json.loads(raw)
+ except (OSError, ValueError) as exc:
+ self._quarantine(reason=f"unreadable: {exc}")
+ return _empty_state()
+
+ if not isinstance(data, dict):
+ self._quarantine(reason="top-level JSON is not an object")
+ return _empty_state()
+
+ version = data.get("version")
+ if version != SCHEMA_VERSION:
+ self._quarantine(reason=f"unknown schema version: {version!r}")
+ return _empty_state()
+
+ # Validate `stats` BEFORE indexing it — a non-dict truthy value (e.g.
+ # `"garbage"` or `[1,2]`) would have raised AttributeError on
+ # `stats_dict.get(...)`. Quarantine in that case.
+ raw_stats = data.get("stats")
+ if raw_stats is not None and not isinstance(raw_stats, dict):
+ self._quarantine(reason=f"`stats` must be an object, got {type(raw_stats).__name__}")
+ return _empty_state()
+ stats_dict: dict[str, Any] = raw_stats or {}
+
+ try:
+ root = _node_from_dict(data.get("root"))
+ stats = StoreStats(
+ total_items=int(stats_dict.get("total_items", 0)),
+ total_clusters=int(stats_dict.get("total_clusters", 0)),
+ inserts_since_rebuild=int(stats_dict.get("inserts_since_rebuild", 0)),
+ )
+ config_snapshot = data.get("config_snapshot") or {}
+ if not isinstance(config_snapshot, dict):
+ config_snapshot = {}
+ except (KeyError, TypeError, ValueError, AttributeError) as exc:
+ self._quarantine(reason=f"schema mismatch: {exc}")
+ return _empty_state()
+
+ return StoreState(
+ version=SCHEMA_VERSION,
+ config_snapshot=config_snapshot,
+ root=root,
+ stats=stats,
+ )
+
+ def save(self, state: StoreState) -> None:
+ """Atomic write to `self._path`. Raises on I/O failure."""
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
+ payload = _state_to_dict(state)
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
+ with open(tmp, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, self._path)
+
+ def _quarantine(self, *, reason: str) -> None:
+ """Rename the bad file aside; fresh state returned to caller."""
+ millis = int(time.time() * 1000)
+ backup = self._path.with_suffix(self._path.suffix + f".corrupted-{millis}")
+ try:
+ os.replace(self._path, backup)
+ logger.error(
+ "[contexto:local] mindmap store quarantined (%s): renamed %s → %s",
+ reason, self._path, backup,
+ )
+ except OSError as exc:
+ logger.error(
+ "[contexto:local] mindmap store quarantine FAILED (%s); leaving file in place: %s",
+ reason, exc,
+ )
+
+
+def _empty_state() -> StoreState:
+ return StoreState(version=SCHEMA_VERSION, config_snapshot={}, root=None, stats=StoreStats())
+
+
+__all__ = ["Store", "SCHEMA_VERSION"]
diff --git a/packages/contexto-py/src/contexto_hermes/local/summarizer.py b/packages/contexto-py/src/contexto_hermes/local/summarizer.py
new file mode 100644
index 0000000..33ca318
--- /dev/null
+++ b/packages/contexto-py/src/contexto_hermes/local/summarizer.py
@@ -0,0 +1,205 @@
+"""LLM summarization client + synthetic-summary fallback.
+
+Mirrors TS `summarizeEpisode` in packages/contexto/src/local/summarizer.ts.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+import httpx
+
+from .mindmap_types import EpisodeSummary, EvidenceRef, LocalBackendConfig
+
+logger = logging.getLogger("plugins.context_engine.contexto")
+
+
+# Verbatim from TS summarizer.ts:16-32.
+SUMMARIZE_SYSTEM_PROMPT = """You are a concise summarizer. Given a conversation episode (user question + assistant answer + tool outputs), produce a JSON object with exactly these fields:
+
+{
+ "status": "complete" | "partial" | "blocked",
+ "summary": "",
+ "key_findings": ["", "", ...],
+ "evidence_refs": [{"type": "", "value": ""}],
+ "open_questions": [""],
+ "confidence": <0.0 to 1.0>
+}
+
+Rules:
+- Set status to "complete" if the episode fully resolved the user's request, "partial" if only partly, "blocked" if unable to proceed.
+- summary should be 1-3 sentences capturing the essence.
+- key_findings should have at least one entry.
+- evidence_refs should reference relevant tools, files, or episodes mentioned.
+- Respond ONLY with valid JSON, no markdown fences, no extra text."""
+
+_VALID_STATUSES = ("complete", "partial", "blocked")
+
+
+class Summarizer:
+ def __init__(
+ self,
+ config: LocalBackendConfig,
+ transport: httpx.BaseTransport | None = None,
+ ) -> None:
+ self._config = config
+ self._transport = transport
+
+ def summarize(self, text: str) -> EpisodeSummary:
+ """Run an LLM summarization. Never raises — returns fallback on error."""
+ url = f"{self._config.llm_base_url}/chat/completions"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self._config.api_key}",
+ }
+ body: dict[str, Any] = {
+ "model": self._config.resolved_llm_model(),
+ "temperature": 0.2,
+ "response_format": {"type": "json_object"},
+ "messages": [
+ {"role": "system", "content": SUMMARIZE_SYSTEM_PROMPT},
+ {"role": "user", "content": text},
+ ],
+ }
+
+ try:
+ with self._client() as client:
+ response = client.post(url, headers=headers, json=body)
+ except httpx.HTTPError as exc:
+ logger.warning("[contexto:local] summarize network error: %s", exc)
+ return _build_fallback(text)
+ except Exception as exc: # never-raises contract bubbles up; be safe
+ logger.warning("[contexto:local] summarize unexpected error: %s", exc)
+ return _build_fallback(text)
+
+ if not response.is_success:
+ preview = ""
+ try:
+ preview = response.text[:200]
+ except Exception:
+ pass
+ logger.warning(
+ "[contexto:local] summarize HTTP %d: %s",
+ response.status_code, preview,
+ )
+ return _build_fallback(text)
+
+ try:
+ envelope = response.json()
+ except ValueError as exc:
+ logger.warning("[contexto:local] summarize response not JSON: %s", exc)
+ return _build_fallback(text)
+
+ try:
+ raw = envelope["choices"][0]["message"]["content"]
+ except (KeyError, TypeError, IndexError):
+ logger.warning("[contexto:local] summarize response missing choices/message/content")
+ return _build_fallback(text)
+
+ if not raw:
+ logger.warning("[contexto:local] summarize empty content")
+ return _build_fallback(text)
+
+ return _parse_summary(raw, text)
+
+ def _client(self) -> httpx.Client:
+ kwargs: dict[str, Any] = {"timeout": self._config.llm_timeout}
+ if self._transport is not None:
+ kwargs["transport"] = self._transport
+ return httpx.Client(**kwargs)
+
+
+def _parse_summary(raw: str, original: str) -> EpisodeSummary:
+ """Parse the LLM JSON; graceful degradation per TS parseSummary."""
+ try:
+ parsed = json.loads(raw)
+ except ValueError as exc:
+ logger.warning("[contexto:local] failed to parse summary JSON: %s", exc)
+ return _build_fallback(original)
+ if not isinstance(parsed, dict):
+ return _build_fallback(original)
+
+ summary_text = parsed.get("summary")
+ if not isinstance(summary_text, str) or not summary_text:
+ summary_text = original[:200]
+
+ raw_findings = parsed.get("key_findings")
+ if isinstance(raw_findings, list) and raw_findings:
+ key_findings = [str(f) for f in raw_findings]
+ else:
+ key_findings = ["Episode processed"]
+
+ raw_status = parsed.get("status")
+ status = raw_status if raw_status in _VALID_STATUSES else "partial"
+
+ raw_conf = parsed.get("confidence")
+ if isinstance(raw_conf, (int, float)) and 0.0 <= raw_conf <= 1.0:
+ confidence = float(raw_conf)
+ else:
+ confidence = 0.5
+
+ evidence_refs: list[EvidenceRef] = []
+ raw_refs = parsed.get("evidence_refs")
+ if isinstance(raw_refs, list):
+ for ref in raw_refs:
+ if (
+ isinstance(ref, dict)
+ and isinstance(ref.get("type"), str)
+ and isinstance(ref.get("value"), str)
+ ):
+ evidence_refs.append(EvidenceRef(type=ref["type"], value=ref["value"]))
+
+ raw_questions = parsed.get("open_questions")
+ open_questions: list[str] | None
+ if isinstance(raw_questions, list):
+ open_questions = [q for q in raw_questions if isinstance(q, str)]
+ else:
+ open_questions = None
+
+ return EpisodeSummary(
+ summary=summary_text,
+ key_findings=key_findings,
+ status=status,
+ confidence=confidence,
+ evidence_refs=evidence_refs,
+ open_questions=open_questions,
+ )
+
+
+def _build_fallback(text: str) -> EpisodeSummary:
+ """Fallback summary used on LLM failure. Matches TS buildFallback exactly."""
+ truncated = text[:200] + ("..." if len(text) > 200 else "")
+ return EpisodeSummary(
+ summary=truncated,
+ key_findings=["Episode processed (fallback — LLM summarization unavailable)"],
+ status="partial",
+ confidence=0.0,
+ evidence_refs=[],
+ open_questions=None,
+ )
+
+
+def build_synthetic_summary(text: str) -> EpisodeSummary:
+ """Build a summary from raw text without any LLM call.
+
+ Used when `CONTEXTO_LOCAL_SUMMARIZE=false`. The distinct `key_findings` marker
+ distinguishes this from the LLM-failure fallback.
+ """
+ truncated = text[:200] + ("..." if len(text) > 200 else "")
+ return EpisodeSummary(
+ summary=truncated,
+ key_findings=["Episode processed (summarization disabled)"],
+ status="partial",
+ confidence=0.0,
+ evidence_refs=[],
+ open_questions=None,
+ )
+
+
+__all__ = [
+ "Summarizer",
+ "SUMMARIZE_SYSTEM_PROMPT",
+ "build_synthetic_summary",
+]
diff --git a/packages/contexto-py/src/contexto_hermes/plugin.yaml b/packages/contexto-py/src/contexto_hermes/plugin.yaml
index b3a48e2..43c38f6 100644
--- a/packages/contexto-py/src/contexto_hermes/plugin.yaml
+++ b/packages/contexto-py/src/contexto_hermes/plugin.yaml
@@ -1,20 +1,62 @@
name: contexto
-description: Contexto context engine — full episodes + mindmap retrieval (remote)
+description: Contexto context engine — remote (api.getcontexto.com) or local (on-disk mindmap)
version: 0.1.0
# Auth and tunables are env-var driven. No YAML config block.
env_vars:
- - name: CONTEXTO_API_KEY
- required: true
- description: API key from getcontexto.com
+ # Backend selector
+ - name: CONTEXTO_BACKEND
+ default: "remote"
+ description: '"remote" (default) or "local"'
+
+ # Shared (both backends)
- name: CONTEXTO_ENABLED
default: "true"
- name: CONTEXTO_MAX_CONTEXT_CHARS
default: "2000"
- name: CONTEXTO_MIN_SCORE
default: "0.45"
+ description: Minimum similarity for retrieved items. Default 0.45 (remote) / 0.35 (local).
- name: CONTEXTO_MAX_RESULTS
default: "7"
- name: CONTEXTO_SEARCH_TIMEOUT
default: "10"
- name: CONTEXTO_INGEST_TIMEOUT
default: "30"
+
+ # Remote backend
+ - name: CONTEXTO_API_KEY
+ required: false # required only when CONTEXTO_BACKEND=remote
+ description: API key from getcontexto.com (remote backend only)
+
+ # Local backend — credentials
+ - name: CONTEXTO_LOCAL_PROVIDER
+ description: '"openai" or "openrouter". Defaults to whichever key is set (openrouter wins if both).'
+ - name: OPENAI_API_KEY
+ description: Used by local backend when CONTEXTO_LOCAL_PROVIDER=openai.
+ - name: OPENROUTER_API_KEY
+ description: Used by local backend when CONTEXTO_LOCAL_PROVIDER=openrouter.
+
+ # Local backend — storage & tunables
+ - name: CONTEXTO_LOCAL_STORAGE_PATH
+ description: 'Mindmap JSON path. Defaults to $HERMES_HOME/data/contexto/mindmap.json (or ~/.hermes/...).'
+ - name: CONTEXTO_LOCAL_EMBED_MODEL
+ description: Override the provider's default embeddings model.
+ - name: CONTEXTO_LOCAL_LLM_MODEL
+ description: Override the provider's default summarization model.
+ - name: CONTEXTO_LOCAL_SUMMARIZE
+ default: "true"
+ description: Set false to skip per-ingest LLM summarization (uses synthetic summary instead).
+ - name: CONTEXTO_LOCAL_SIMILARITY_THRESHOLD
+ default: "0.65"
+ - name: CONTEXTO_LOCAL_MAX_DEPTH
+ default: "4"
+ - name: CONTEXTO_LOCAL_MAX_CHILDREN
+ default: "10"
+ - name: CONTEXTO_LOCAL_REBUILD_INTERVAL
+ default: "50"
+ - name: CONTEXTO_LOCAL_BEAM_WIDTH
+ default: "3"
+ - name: CONTEXTO_LOCAL_EMBED_TIMEOUT
+ default: "30"
+ - name: CONTEXTO_LOCAL_LLM_TIMEOUT
+ default: "60"
diff --git a/packages/contexto-py/src/contexto_hermes/types.py b/packages/contexto-py/src/contexto_hermes/types.py
index eeeb905..d514d03 100644
--- a/packages/contexto-py/src/contexto_hermes/types.py
+++ b/packages/contexto-py/src/contexto_hermes/types.py
@@ -124,6 +124,26 @@ def from_env(cls) -> "ContextoConfig | None":
ingest_timeout=_env_float("CONTEXTO_INGEST_TIMEOUT", default=30.0, minimum=0.0),
)
+ @classmethod
+ def local_mode_defaults(cls) -> "ContextoConfig":
+ """ContextoConfig wired for local mode. CONTEXTO_API_KEY is not used.
+
+ Tunables read CONTEXTO_* env vars where set (so `min_score`,
+ `max_context_chars`, etc. still affect `compress()`).
+ """
+ return cls(
+ api_key="", # unused; LocalBackend uses provider-specific creds
+ context_enabled=_env_bool("CONTEXTO_ENABLED", default=True),
+ max_context_chars=_env_int("CONTEXTO_MAX_CONTEXT_CHARS", default=2000, minimum=1),
+ # Lower than remote's 0.45: locally one summary spans a whole drop
+ # slice, so query-to-summary cosine for a relevant hit often lands
+ # in the 0.35–0.45 band.
+ min_score=_env_float("CONTEXTO_MIN_SCORE", default=0.35, minimum=0.0, maximum=1.0),
+ max_results=_env_int("CONTEXTO_MAX_RESULTS", default=7, minimum=1),
+ search_timeout=_env_float("CONTEXTO_SEARCH_TIMEOUT", default=10.0, minimum=0.0),
+ ingest_timeout=_env_float("CONTEXTO_INGEST_TIMEOUT", default=30.0, minimum=0.0),
+ )
+
@dataclass
class ApiError:
@@ -136,7 +156,11 @@ class ApiError:
@dataclass
class SearchResult:
- """Parsed /v1/mindmap/search response."""
+ """Parsed mindmap-search response. Returned by both backends.
+
+ `paths` is a list of cluster-label paths (not IDs) leading to each terminal
+ node in beam search. Matches TS `ScoredQueryResult.paths`.
+ """
items: list[dict[str, Any]] = field(default_factory=list)
- paths: list[dict[str, Any]] = field(default_factory=list)
+ paths: list[list[str]] = field(default_factory=list)
diff --git a/packages/contexto-py/tests/local/__init__.py b/packages/contexto-py/tests/local/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/contexto-py/tests/local/conftest.py b/packages/contexto-py/tests/local/conftest.py
new file mode 100644
index 0000000..030fed5
--- /dev/null
+++ b/packages/contexto-py/tests/local/conftest.py
@@ -0,0 +1,46 @@
+"""Shared fixtures for local-backend tests."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from contexto_hermes.local.mindmap_types import LocalBackendConfig, MindmapConfig
+
+
+@pytest.fixture
+def base_config(tmp_path) -> LocalBackendConfig:
+ """A LocalBackendConfig wired for tests — no real HTTP calls."""
+ return LocalBackendConfig(
+ storage_path=str(tmp_path / "mindmap.json"),
+ provider="openai",
+ api_key="sk-test",
+ embed_base_url="https://api.openai.com/v1",
+ llm_base_url="https://api.openai.com/v1",
+ embed_model="text-embedding-3-small",
+ llm_model="gpt-4o-mini",
+ summarize=True,
+ mindmap=MindmapConfig(),
+ beam_width=3,
+ embed_timeout=5.0,
+ llm_timeout=10.0,
+ )
+
+
+@pytest.fixture
+def openrouter_config(tmp_path) -> LocalBackendConfig:
+ return LocalBackendConfig(
+ storage_path=str(tmp_path / "mindmap.json"),
+ provider="openrouter",
+ api_key="sk-or-test",
+ embed_base_url="https://openrouter.ai/api/v1",
+ llm_base_url="https://openrouter.ai/api/v1",
+ embed_model="openai/text-embedding-3-small",
+ llm_model="openai/gpt-4o-mini",
+ summarize=True,
+ mindmap=MindmapConfig(),
+ beam_width=3,
+ embed_timeout=5.0,
+ llm_timeout=10.0,
+ )
diff --git a/packages/contexto-py/tests/local/test_backend.py b/packages/contexto-py/tests/local/test_backend.py
new file mode 100644
index 0000000..5ab054f
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_backend.py
@@ -0,0 +1,261 @@
+"""Tests for LocalBackend — orchestration + error contract."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+import numpy as np
+import pytest
+
+from contexto_hermes.local.backend import LocalBackend
+from contexto_hermes.local.embedder import EmbedError
+from contexto_hermes.local.mindmap_types import (
+ EpisodeSummary,
+ EvidenceRef,
+ LocalBackendConfig,
+ MindmapConfig,
+)
+from contexto_hermes.local.store import Store
+
+
+def _payload(text: str, *, session_key: str = "sess-1") -> dict:
+ return {
+ "event": {"type": "episode", "action": "combined"},
+ "sessionKey": session_key,
+ "timestamp": "2026-05-23T00:00:00.000Z",
+ "context": {"sessionId": "s"},
+ "data": {"messages": [
+ {"role": "user", "content": text},
+ {"role": "assistant", "content": f"answered: {text}"},
+ ]},
+ }
+
+
+def _normalize(v: list[float]) -> list[float]:
+ arr = np.asarray(v, dtype=np.float64)
+ n = float(np.linalg.norm(arr))
+ return (arr / n).tolist() if n > 0 else v
+
+
+class FakeEmbedder:
+ """Deterministic embedder: hash bytes → small float vector."""
+
+ def __init__(self, dim: int = 8, fail: bool = False):
+ self._dim = dim
+ self._fail = fail
+
+ def embed(self, text: str) -> list[float]:
+ if self._fail:
+ raise EmbedError("fake embed failure")
+ # Very simple hash-based mapping so similar text → similar vectors.
+ import hashlib
+ h = hashlib.sha256(text.encode("utf-8")).digest()
+ vec = [(h[i] - 128) / 128.0 for i in range(self._dim)]
+ return _normalize(vec)
+
+
+class FakeSummarizer:
+ def __init__(self, status: str = "complete", confidence: float = 0.9):
+ self._status = status
+ self._confidence = confidence
+
+ def summarize(self, text: str) -> EpisodeSummary:
+ return EpisodeSummary(
+ summary=f"summary of: {text[:60]}",
+ key_findings=["fact-1", "fact-2"],
+ status=self._status,
+ confidence=self._confidence,
+ evidence_refs=[EvidenceRef(type="episode_ref", value="ep1")],
+ open_questions=None,
+ )
+
+
+def _make_backend(config: LocalBackendConfig, *, embed_fail=False, summarizer=None) -> LocalBackend:
+ return LocalBackend(
+ config,
+ embedder=FakeEmbedder(fail=embed_fail),
+ summarizer=summarizer or FakeSummarizer(),
+ )
+
+
+class TestIngestHappyPath:
+ def test_round_trip(self, base_config):
+ backend = _make_backend(base_config)
+ ok = backend.ingest([_payload("hello world")])
+ assert ok is True
+
+ result = backend.search("hello", max_results=5)
+ assert result is not None
+ assert len(result.items) == 1
+ entry = result.items[0]
+ # Spec §5 / TS parity: each result is a {"item": ..., "score": ...} wrapper.
+ assert "item" in entry and "score" in entry
+ assert isinstance(entry["score"], float)
+ item = entry["item"]
+ # FakeSummarizer.summary echoes the extractor output (`Q: hello world\n...`).
+ assert item["content"].startswith("summary of: Q: hello world")
+ assert item["metadata"]["source"] == "summary"
+ assert item["metadata"]["status"] == "complete"
+ assert item["metadata"]["confidence"] == 0.9
+ assert item["metadata"]["evidence_refs"] == [{"type": "episode_ref", "value": "ep1"}]
+ assert item["metadata"]["sessionKey"] == "sess-1"
+ assert "trace_ref" in item["metadata"]
+ assert "extracted_text" in item["metadata"]["episode"]
+
+ def test_persists_to_disk(self, base_config):
+ backend = _make_backend(base_config)
+ backend.ingest([_payload("episode A")])
+ # File exists.
+ from pathlib import Path
+ path = Path(base_config.storage_path)
+ assert path.exists()
+ data = json.loads(path.read_text())
+ assert data["version"] == 1
+ assert data["stats"]["total_items"] == 1
+
+ def test_config_snapshot_populated(self, base_config):
+ # Spec §8 + F8: every save records the mindmap-shaping tunables.
+ backend = _make_backend(base_config)
+ backend.ingest([_payload("episode A")])
+ from pathlib import Path
+ snapshot = json.loads(Path(base_config.storage_path).read_text())["config_snapshot"]
+ # Resolved (not None) — defaults swapped in.
+ assert snapshot["embed_model"] == "text-embedding-3-small"
+ assert snapshot["llm_model"] == "gpt-4o-mini"
+ assert snapshot["provider"] == "openai"
+ assert snapshot["similarity_threshold"] == 0.65
+ assert snapshot["rebuild_interval"] == 50
+ assert snapshot["max_depth"] == 4
+ assert snapshot["beam_width"] == 3
+
+ def test_synthetic_summary_when_disabled(self, base_config):
+ base_config.summarize = False
+ backend = LocalBackend(base_config, embedder=FakeEmbedder(), summarizer=None)
+ # summarizer instantiated by ctor but never called.
+ ok = backend.ingest([_payload("hi")])
+ assert ok is True
+ result = backend.search("hi", max_results=5)
+ assert result is not None
+ assert "summarization disabled" in result.items[0]["item"]["content"]
+
+
+class TestIngestFiltering:
+ def test_non_episode_events_filtered(self, base_config):
+ backend = _make_backend(base_config)
+ non_ep = {
+ "event": {"type": "metric", "action": "snapshot"},
+ "data": {"messages": []},
+ }
+ ok = backend.ingest([non_ep])
+ assert ok is True # graceful no-op
+ # Nothing persisted.
+ from pathlib import Path
+ assert not Path(base_config.storage_path).exists()
+
+ def test_empty_episode_text_skipped(self, base_config):
+ backend = _make_backend(base_config)
+ empty = {
+ "event": {"type": "episode", "action": "combined"},
+ "sessionKey": "s",
+ "data": {"messages": [{"role": "system", "content": "x"}]},
+ }
+ ok = backend.ingest([empty])
+ assert ok is True
+ # No item recorded.
+ from pathlib import Path
+ assert not Path(base_config.storage_path).exists()
+
+
+class TestNeverRaisesContract:
+ def test_embed_failure_returns_False(self, base_config):
+ backend = _make_backend(base_config, embed_fail=True)
+ assert backend.ingest([_payload("x")]) is False
+
+ def test_search_on_empty_store_returns_empty_result(self, base_config):
+ # Empty store is "no results", not a failure — None is reserved for errors.
+ backend = _make_backend(base_config)
+ result = backend.search("anything", max_results=5)
+ assert result is not None
+ assert result.items == [] and result.paths == []
+
+ def test_search_embed_failure_returns_None(self, base_config):
+ # Ingest with working embedder, then swap to a failing embedder for search.
+ backend = _make_backend(base_config)
+ backend.ingest([_payload("x")])
+ backend._embedder = FakeEmbedder(fail=True)
+ assert backend.search("q", max_results=5) is None
+
+ def test_store_write_failure_returns_False(self, base_config, monkeypatch):
+ backend = _make_backend(base_config)
+
+ def raise_on_save(self, state):
+ raise OSError("disk full")
+ monkeypatch.setattr(Store, "save", raise_on_save)
+ assert backend.ingest([_payload("x")]) is False
+
+ def test_clustering_failure_returns_False(self, base_config, monkeypatch):
+ backend = _make_backend(base_config)
+ # Force the embedder to return non-finite values; clustering must reject.
+ class BrokenEmbedder:
+ def embed(self, text: str) -> list[float]:
+ return [float("nan"), 0.0, 0.0]
+ backend._embedder = BrokenEmbedder()
+ # Need 2+ items to trigger scipy.linkage (rebuild < 100 always rebuilds).
+ ok = backend.ingest([_payload("a"), _payload("b")])
+ assert ok is False
+
+ def test_unexpected_exception_returns_False(self, base_config, monkeypatch):
+ backend = _make_backend(base_config)
+
+ class Boom:
+ def embed(self, text):
+ raise RuntimeError("synthetic boom")
+
+ backend._embedder = Boom()
+ # Should not raise.
+ assert backend.ingest([_payload("x")]) is False
+
+ def test_search_unexpected_exception_returns_None(self, base_config, monkeypatch):
+ backend = _make_backend(base_config)
+ backend.ingest([_payload("x")])
+
+ # Now monkey-patch beam_search via the module to raise.
+ import contexto_hermes.local.backend as mod
+ monkeypatch.setattr(mod, "beam_search", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")))
+ assert backend.search("q", max_results=5) is None
+
+
+class TestSearchShape:
+ def test_paths_are_lists_of_strings(self, base_config):
+ backend = _make_backend(base_config)
+ backend.ingest([_payload(f"episode {i}") for i in range(3)])
+ result = backend.search("episode 0", max_results=3)
+ assert result is not None
+ assert isinstance(result.paths, list)
+ for p in result.paths:
+ assert isinstance(p, list)
+ for label in p:
+ assert isinstance(label, str)
+
+ def test_items_are_wrapped_with_score(self, base_config):
+ backend = _make_backend(base_config)
+ backend.ingest([_payload(f"episode {i}") for i in range(3)])
+ result = backend.search("episode 0", max_results=3)
+ assert result is not None
+ for entry in result.items:
+ assert set(entry.keys()) == {"item", "score"}
+ assert isinstance(entry["score"], float)
+ assert "id" in entry["item"] and "content" in entry["item"]
+ # Scores are non-ascending.
+ scores = [e["score"] for e in result.items]
+ assert scores == sorted(scores, reverse=True)
+
+ def test_empty_result_when_no_results_pass_filter(self, base_config):
+ backend = _make_backend(base_config)
+ backend.ingest([_payload("hi")])
+ # Filter on metadata that doesn't exist — no matches, but not a failure.
+ result = backend.search("hi", max_results=5, filter={"source": "raw"})
+ assert result is not None
+ assert result.items == []
diff --git a/packages/contexto-py/tests/local/test_clustering.py b/packages/contexto-py/tests/local/test_clustering.py
new file mode 100644
index 0000000..276689d
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_clustering.py
@@ -0,0 +1,192 @@
+"""Tests for the local clusterer."""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import pytest
+
+from contexto_hermes.local.clustering import Clusterer
+from contexto_hermes.local.mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ MindmapConfig,
+ StoreState,
+ StoreStats,
+)
+
+
+def _item(i: int, embedding: list[float], content: str | None = None) -> ConversationItem:
+ return ConversationItem(
+ id=f"item-{i}",
+ role="assistant",
+ content=content or f"item-{i} content",
+ embedding=embedding,
+ )
+
+
+def _normalize(v: list[float]) -> list[float]:
+ arr = np.asarray(v, dtype=np.float64)
+ n = float(np.linalg.norm(arr))
+ return (arr / n).tolist() if n > 0 else v
+
+
+def _empty_state() -> StoreState:
+ return StoreState()
+
+
+class TestRebuildPolicy:
+ def test_rebuild_when_total_under_100(self):
+ # All items below 100 should ALWAYS trigger rebuild (`new_total < 100`),
+ # regardless of inserts_since_rebuild.
+ clusterer = Clusterer(MindmapConfig(rebuild_interval=10))
+ state = _empty_state()
+ state.stats.inserts_since_rebuild = 5 # well below interval
+ items = [_item(i, [1.0, 0.0]) for i in range(5)]
+ state = clusterer.add(state, items)
+ assert state.stats.inserts_since_rebuild == 0 # rebuild reset counter
+ assert state.stats.total_items == 5
+
+ def test_rebuild_when_threshold_hit_after_100(self):
+ clusterer = Clusterer(MindmapConfig(rebuild_interval=20))
+ state = _empty_state()
+ # Seed with 100 items (forces rebuild)
+ seed = [_item(i, _normalize([1.0 + i * 0.01, i * 0.005])) for i in range(100)]
+ state = clusterer.add(state, seed)
+ assert state.stats.total_items == 100
+ assert state.stats.inserts_since_rebuild == 0
+
+ # Add 10 more — should NOT rebuild (total >= 100, increment < interval)
+ more = [_item(100 + i, _normalize([0.0, 1.0 + i * 0.01])) for i in range(10)]
+ state = clusterer.add(state, more)
+ assert state.stats.total_items == 110
+ assert state.stats.inserts_since_rebuild == 10
+
+ # Add 10 more — incremental sum hits 20, triggers rebuild.
+ more2 = [_item(110 + i, _normalize([1.0, 1.0 + i * 0.01])) for i in range(10)]
+ state = clusterer.add(state, more2)
+ assert state.stats.total_items == 120
+ assert state.stats.inserts_since_rebuild == 0
+
+
+class TestBuildShapes:
+ def test_zero_items_empty_root(self):
+ clusterer = Clusterer(MindmapConfig())
+ state = clusterer.add(_empty_state(), [])
+ assert state.root is None
+
+ def test_one_item_root_with_single_leaf(self):
+ clusterer = Clusterer(MindmapConfig())
+ state = clusterer.add(_empty_state(), [_item(0, [1.0, 0.0])])
+ assert state.root is not None
+ assert state.root.id == "root"
+ assert state.root.item_count == 1
+ assert len(state.root.children) == 1
+ assert state.root.children[0].items[0].id == "item-0"
+
+ def test_two_distant_items_get_split(self):
+ # Two clusters of orthogonal vectors should be separated.
+ clusterer = Clusterer(MindmapConfig(similarity_threshold=0.5, max_depth=3))
+ items = [
+ _item(0, [1.0, 0.0]),
+ _item(1, [1.0, 0.01]),
+ _item(2, [0.0, 1.0]),
+ _item(3, [0.01, 1.0]),
+ ]
+ state = clusterer.add(_empty_state(), items)
+ assert state.root is not None
+ # Root should have >= 2 children (the two clusters)
+ assert len(state.root.children) >= 2
+ assert state.root.item_count == 4
+
+
+class TestIncrementalInsert:
+ def test_incremental_path_updates_centroid(self):
+ clusterer = Clusterer(MindmapConfig(rebuild_interval=200))
+ state = _empty_state()
+ # Seed with 100 items so threshold is met (no rebuild on incremental adds).
+ seed = [_item(i, _normalize([1.0, i * 0.01])) for i in range(100)]
+ state = clusterer.add(state, seed)
+ before_total = state.stats.total_items
+ before_inserts = state.stats.inserts_since_rebuild # 0
+
+ # Add one more item (incremental path).
+ state = clusterer.add(state, [_item(100, _normalize([1.0, 0.5]))])
+ assert state.stats.total_items == before_total + 1
+ assert state.stats.inserts_since_rebuild == before_inserts + 1
+ assert state.root is not None
+ assert state.root.item_count == 101
+
+
+class TestStatsInvariants:
+ def test_total_items_matches_item_count(self):
+ clusterer = Clusterer(MindmapConfig())
+ items = [_item(i, _normalize([1.0, i * 0.01])) for i in range(20)]
+ state = clusterer.add(_empty_state(), items)
+ assert state.root is not None
+ assert state.stats.total_items == state.root.item_count == 20
+
+ def test_total_clusters_positive(self):
+ clusterer = Clusterer(MindmapConfig())
+ items = [_item(i, _normalize([1.0, i * 0.01])) for i in range(20)]
+ state = clusterer.add(_empty_state(), items)
+ assert state.stats.total_clusters >= 1
+
+
+def _collect_cluster_ids(node: ClusterNode) -> list[str]:
+ ids = [node.id]
+ for child in node.children:
+ ids.extend(_collect_cluster_ids(child))
+ return ids
+
+
+class TestClusterIdUniquenessAfterReload:
+ """Reviewer-reproduced scenario: incremental insert against a loaded state
+ must not reuse `cluster-N` ids that already exist in the tree.
+ """
+
+ def test_incremental_insert_after_reload_does_not_collide(self):
+ # Phase 1 — build state with a fresh clusterer; capture the resulting IDs.
+ builder = Clusterer(MindmapConfig(rebuild_interval=1000))
+ seed = [_item(i, _normalize([1.0, i * 0.01])) for i in range(100)]
+ state = builder.add(_empty_state(), seed)
+ first_ids = _collect_cluster_ids(state.root)
+ assert state.root is not None
+ assert len(set(first_ids)) == len(first_ids), "seed build should already be unique"
+
+ # Phase 2 — simulate a process restart: throw away the builder, mint a
+ # FRESH Clusterer (which starts its counter at 1) against the loaded
+ # state. Forced incremental path: new_total >= 100 AND well under
+ # rebuild_interval.
+ reloaded = Clusterer(MindmapConfig(rebuild_interval=1000))
+ # Force the incremental path: new_total stays >= 100 and far under
+ # rebuild_interval.
+ extra = [_item(200 + i, _normalize([0.0, 1.0 + i * 0.01])) for i in range(3)]
+ state = reloaded.add(state, extra)
+
+ all_ids = _collect_cluster_ids(state.root)
+ assert len(set(all_ids)) == len(all_ids), (
+ f"duplicate cluster ids after reload+incremental: {sorted(all_ids)}"
+ )
+ # Specifically: every new id should be strictly larger than every preexisting id.
+ preexisting_max = max(
+ int(cid.split("-", 1)[1]) for cid in first_ids if cid.startswith("cluster-")
+ )
+ new_ids = [cid for cid in all_ids if cid not in set(first_ids) and cid.startswith("cluster-")]
+ for cid in new_ids:
+ assert int(cid.split("-", 1)[1]) > preexisting_max
+
+ def test_seed_counter_idempotent(self):
+ # Two consecutive incremental adds against the same reloaded state
+ # should still produce unique ids (the seed step is idempotent).
+ builder = Clusterer(MindmapConfig(rebuild_interval=1000))
+ state = builder.add(_empty_state(), [
+ _item(i, _normalize([1.0, i * 0.01])) for i in range(100)
+ ])
+
+ reloaded = Clusterer(MindmapConfig(rebuild_interval=1000))
+ state = reloaded.add(state, [_item(200, _normalize([0.0, 1.0]))])
+ state = reloaded.add(state, [_item(201, _normalize([0.0, 1.0]))])
+ ids = _collect_cluster_ids(state.root)
+ assert len(set(ids)) == len(ids)
diff --git a/packages/contexto-py/tests/local/test_config.py b/packages/contexto-py/tests/local/test_config.py
new file mode 100644
index 0000000..10bb65e
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_config.py
@@ -0,0 +1,207 @@
+"""Tests for LocalBackendConfig.from_env — spec §7 resolution rules."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+from contexto_hermes.local.mindmap_types import LocalBackendConfig, MindmapConfig
+
+
+_LOCAL_ENV_VARS = (
+ "CONTEXTO_LOCAL_PROVIDER",
+ "CONTEXTO_LOCAL_STORAGE_PATH",
+ "CONTEXTO_LOCAL_EMBED_MODEL",
+ "CONTEXTO_LOCAL_LLM_MODEL",
+ "CONTEXTO_LOCAL_SUMMARIZE",
+ "CONTEXTO_LOCAL_SIMILARITY_THRESHOLD",
+ "CONTEXTO_LOCAL_MAX_DEPTH",
+ "CONTEXTO_LOCAL_MAX_CHILDREN",
+ "CONTEXTO_LOCAL_REBUILD_INTERVAL",
+ "CONTEXTO_LOCAL_BEAM_WIDTH",
+ "CONTEXTO_LOCAL_EMBED_TIMEOUT",
+ "CONTEXTO_LOCAL_LLM_TIMEOUT",
+ "OPENAI_API_KEY",
+ "OPENROUTER_API_KEY",
+ "HERMES_HOME",
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ for var in _LOCAL_ENV_VARS:
+ monkeypatch.delenv(var, raising=False)
+
+
+class TestProviderResolution:
+ def test_implicit_prefers_openrouter_when_both_keys_set(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.provider == "openrouter"
+ assert cfg.api_key == "sk-or"
+ # No override → field stays None; provider default resolved at call time.
+ assert cfg.embed_model is None
+ assert cfg.llm_model is None
+ assert cfg.resolved_embed_model() == "openai/text-embedding-3-small"
+ assert cfg.resolved_llm_model() == "openai/gpt-4o-mini"
+
+ def test_implicit_falls_back_to_openai_when_only_openai_key(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.provider == "openai"
+ assert cfg.embed_model is None
+ assert cfg.llm_model is None
+ assert cfg.resolved_embed_model() == "text-embedding-3-small"
+ assert cfg.resolved_llm_model() == "gpt-4o-mini"
+
+ def test_nullable_models_resolve_to_provider_defaults_directly(self):
+ # Spec §11: `None` means "use provider default" — verify the contract
+ # holds when constructing the dataclass directly (not via from_env).
+ cfg = LocalBackendConfig(
+ storage_path="/tmp/x.json",
+ provider="openai",
+ api_key="sk",
+ embed_base_url="https://api.openai.com/v1",
+ llm_base_url="https://api.openai.com/v1",
+ )
+ assert cfg.embed_model is None
+ assert cfg.llm_model is None
+ assert cfg.resolved_embed_model() == "text-embedding-3-small"
+ assert cfg.resolved_llm_model() == "gpt-4o-mini"
+
+ def test_implicit_returns_none_when_no_key(self):
+ assert LocalBackendConfig.from_env() is None
+
+ def test_explicit_openai_uses_openai_key(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "openai")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or") # ignored
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.provider == "openai"
+ assert cfg.api_key == "sk-openai"
+
+ def test_explicit_openrouter_uses_openrouter_key(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "openrouter")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.provider == "openrouter"
+ assert cfg.api_key == "sk-or"
+
+ def test_explicit_openai_missing_key_returns_none(self, monkeypatch, caplog):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "openai")
+ # only openrouter present; must NOT silently fall back
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is None
+
+ def test_explicit_openrouter_missing_key_returns_none(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "openrouter")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") # MUST NOT fall back
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is None
+
+ def test_unknown_provider_returns_none(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "gemini")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is None
+
+ def test_provider_case_insensitive(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_LOCAL_PROVIDER", "OpenAI")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.provider == "openai"
+
+
+class TestStoragePathDefault:
+ def test_default_uses_hermes_home(self, monkeypatch, tmp_path):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.storage_path == str(tmp_path / "data" / "contexto" / "mindmap.json")
+
+ def test_default_falls_back_to_home_hermes(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.storage_path == str(
+ Path("~/.hermes").expanduser() / "data" / "contexto" / "mindmap.json"
+ )
+
+ def test_explicit_storage_path_wins(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("CONTEXTO_LOCAL_STORAGE_PATH", "/tmp/explicit/path.json")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.storage_path == "/tmp/explicit/path.json"
+
+
+class TestModelOverrides:
+ def test_embed_model_override(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("CONTEXTO_LOCAL_EMBED_MODEL", "custom-embed")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.embed_model == "custom-embed"
+
+ def test_llm_model_override(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("CONTEXTO_LOCAL_LLM_MODEL", "custom-llm")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.llm_model == "custom-llm"
+
+
+class TestMindmapTunables:
+ def test_defaults(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.mindmap == MindmapConfig(
+ similarity_threshold=0.65,
+ max_depth=4,
+ max_children=10,
+ rebuild_interval=50,
+ )
+ assert cfg.beam_width == 3
+ assert cfg.embed_timeout == 30.0
+ assert cfg.llm_timeout == 60.0
+ assert cfg.summarize is True
+
+ def test_overrides(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("CONTEXTO_LOCAL_SIMILARITY_THRESHOLD", "0.75")
+ monkeypatch.setenv("CONTEXTO_LOCAL_MAX_DEPTH", "5")
+ monkeypatch.setenv("CONTEXTO_LOCAL_MAX_CHILDREN", "20")
+ monkeypatch.setenv("CONTEXTO_LOCAL_REBUILD_INTERVAL", "100")
+ monkeypatch.setenv("CONTEXTO_LOCAL_BEAM_WIDTH", "5")
+ monkeypatch.setenv("CONTEXTO_LOCAL_EMBED_TIMEOUT", "15")
+ monkeypatch.setenv("CONTEXTO_LOCAL_LLM_TIMEOUT", "120")
+ monkeypatch.setenv("CONTEXTO_LOCAL_SUMMARIZE", "false")
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.mindmap.similarity_threshold == 0.75
+ assert cfg.mindmap.max_depth == 5
+ assert cfg.mindmap.max_children == 20
+ assert cfg.mindmap.rebuild_interval == 100
+ assert cfg.beam_width == 5
+ assert cfg.embed_timeout == 15.0
+ assert cfg.llm_timeout == 120.0
+ assert cfg.summarize is False
+
+ def test_invalid_threshold_uses_default(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "sk")
+ monkeypatch.setenv("CONTEXTO_LOCAL_SIMILARITY_THRESHOLD", "2.0") # out of range
+ cfg = LocalBackendConfig.from_env()
+ assert cfg is not None
+ assert cfg.mindmap.similarity_threshold == 0.65
diff --git a/packages/contexto-py/tests/local/test_embedder.py b/packages/contexto-py/tests/local/test_embedder.py
new file mode 100644
index 0000000..d91c20a
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_embedder.py
@@ -0,0 +1,110 @@
+"""Tests for the embeddings client."""
+
+from __future__ import annotations
+
+import json
+from typing import Callable
+
+import httpx
+import pytest
+
+from contexto_hermes.local.embedder import Embedder, EmbedError
+
+
+def _mock(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.MockTransport:
+ return httpx.MockTransport(handler)
+
+
+class TestRequest:
+ def test_openai_url_and_headers(self, base_config):
+ captured: list[httpx.Request] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req)
+ return httpx.Response(200, json={"data": [{"embedding": [0.1, 0.2]}]})
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ emb.embed("hello")
+ req = captured[0]
+ assert str(req.url) == "https://api.openai.com/v1/embeddings"
+ assert req.method == "POST"
+ assert req.headers["authorization"] == "Bearer sk-test"
+ assert req.headers["content-type"].startswith("application/json")
+
+ def test_openrouter_url(self, openrouter_config):
+ captured: list[httpx.Request] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req)
+ return httpx.Response(200, json={"data": [{"embedding": [0.1]}]})
+
+ emb = Embedder(openrouter_config, transport=_mock(handler))
+ emb.embed("hi")
+ assert str(captured[0].url) == "https://openrouter.ai/api/v1/embeddings"
+ assert captured[0].headers["authorization"] == "Bearer sk-or-test"
+
+ def test_body_uses_model_and_input(self, base_config):
+ captured: list[bytes] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req.content)
+ return httpx.Response(200, json={"data": [{"embedding": [0.1]}]})
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ emb.embed("hello world")
+ body = json.loads(captured[0])
+ assert body["model"] == "text-embedding-3-small"
+ assert body["input"] == "hello world"
+
+ def test_openrouter_uses_namespaced_model(self, openrouter_config):
+ captured: list[bytes] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req.content)
+ return httpx.Response(200, json={"data": [{"embedding": [0.1]}]})
+
+ emb = Embedder(openrouter_config, transport=_mock(handler))
+ emb.embed("hi")
+ body = json.loads(captured[0])
+ assert body["model"] == "openai/text-embedding-3-small"
+
+
+class TestResponse:
+ def test_parses_data_zero_embedding(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"data": [{"embedding": [1.5, -0.5, 0.0]}]})
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ assert emb.embed("x") == [1.5, -0.5, 0.0]
+
+ def test_http_error_raises_EmbedError(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(429, text="rate limit")
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ with pytest.raises(EmbedError):
+ emb.embed("x")
+
+ def test_network_error_raises_EmbedError(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("no route")
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ with pytest.raises(EmbedError):
+ emb.embed("x")
+
+ def test_malformed_response_raises(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"data": []}) # empty list
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ with pytest.raises(EmbedError):
+ emb.embed("x")
+
+ def test_non_json_response_raises(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, text="not json")
+
+ emb = Embedder(base_config, transport=_mock(handler))
+ with pytest.raises(EmbedError):
+ emb.embed("x")
diff --git a/packages/contexto-py/tests/local/test_extractor.py b/packages/contexto-py/tests/local/test_extractor.py
new file mode 100644
index 0000000..687c7d0
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_extractor.py
@@ -0,0 +1,125 @@
+"""Tests for extract_episode_text."""
+
+from __future__ import annotations
+
+from contexto_hermes.local.extractor import extract_episode_text
+
+
+def _payload(messages, event_type="episode", action="combined"):
+ return {
+ "event": {"type": event_type, "action": action},
+ "sessionKey": "s",
+ "timestamp": "2026-05-23T00:00:00.000Z",
+ "context": {"sessionId": "s"},
+ "data": {"messages": messages},
+ }
+
+
+class TestEventGate:
+ def test_non_episode_event_returns_empty(self):
+ p = _payload([{"role": "user", "content": "hi"}], event_type="metric")
+ assert extract_episode_text(p) == ""
+
+ def test_non_combined_action_returns_empty(self):
+ p = _payload([{"role": "user", "content": "hi"}], action="start")
+ assert extract_episode_text(p) == ""
+
+ def test_missing_event_returns_empty(self):
+ assert extract_episode_text({"data": {"messages": []}}) == ""
+
+ def test_missing_data_returns_empty(self):
+ assert extract_episode_text({"event": {"type": "episode", "action": "combined"}}) == ""
+
+ def test_non_list_messages_returns_empty(self):
+ p = {
+ "event": {"type": "episode", "action": "combined"},
+ "data": {"messages": "not a list"},
+ }
+ assert extract_episode_text(p) == ""
+
+
+class TestPrefixes:
+ def test_q_prefix_on_user(self):
+ out = extract_episode_text(_payload([{"role": "user", "content": "hello"}]))
+ assert out == "Q: hello"
+
+ def test_a_prefix_on_assistant(self):
+ out = extract_episode_text(_payload([{"role": "assistant", "content": "hi"}]))
+ assert out == "A: hi"
+
+ def test_t_prefix_on_tool(self):
+ out = extract_episode_text(_payload([{"role": "tool", "content": "result"}]))
+ assert out == "T: result"
+
+ def test_other_roles_ignored(self):
+ out = extract_episode_text(_payload([
+ {"role": "system", "content": "ignored"},
+ {"role": "user", "content": "kept"},
+ ]))
+ assert out == "Q: kept"
+
+ def test_q_a_t_ordering_preserved(self):
+ out = extract_episode_text(_payload([
+ {"role": "user", "content": "What's the weather?"},
+ {"role": "assistant", "content": "Looking it up."},
+ {"role": "tool", "content": "Sunny, 22C"},
+ {"role": "assistant", "content": "It's sunny."},
+ ]))
+ assert out == (
+ "Q: What's the weather?\n"
+ "A: Looking it up.\n"
+ "T: Sunny, 22C\n"
+ "A: It's sunny."
+ )
+
+
+class TestContentShapes:
+ def test_list_content_blocks_concatenated(self):
+ out = extract_episode_text(_payload([
+ {"role": "assistant", "content": [
+ {"type": "text", "text": "part1"},
+ {"type": "text", "text": "part2"},
+ ]},
+ ]))
+ assert out == "A: part1part2"
+
+ def test_list_content_skips_non_text_blocks(self):
+ out = extract_episode_text(_payload([
+ {"role": "assistant", "content": [
+ {"type": "text", "text": "hello"},
+ {"type": "tool_use", "id": "1", "name": "foo"}, # skipped
+ ]},
+ ]))
+ assert out == "A: hello"
+
+ def test_none_content_skipped(self):
+ out = extract_episode_text(_payload([
+ {"role": "assistant", "content": None},
+ {"role": "assistant", "content": "real"},
+ ]))
+ assert out == "A: real"
+
+ def test_empty_user_content_skipped(self):
+ out = extract_episode_text(_payload([
+ {"role": "user", "content": ""},
+ {"role": "user", "content": "real"},
+ ]))
+ assert out == "Q: real"
+
+
+class TestEnvelopeStripping:
+ def test_user_metadata_envelope_stripped(self):
+ envelope = (
+ "Sender (untrusted metadata): ```json\n{\"name\": \"x\"}\n```\n"
+ "Actual user question?"
+ )
+ out = extract_episode_text(_payload([{"role": "user", "content": envelope}]))
+ assert out == "Q: Actual user question?"
+
+ def test_assistant_envelope_NOT_stripped(self):
+ # Only user messages get envelope stripping (matches TS).
+ text = (
+ "Sender (untrusted metadata): ```json\n{}\n```\nstill here"
+ )
+ out = extract_episode_text(_payload([{"role": "assistant", "content": text}]))
+ assert out.startswith("A: Sender")
diff --git a/packages/contexto-py/tests/local/test_labeler.py b/packages/contexto-py/tests/local/test_labeler.py
new file mode 100644
index 0000000..f92f3da
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_labeler.py
@@ -0,0 +1,96 @@
+"""Tests for the local labeler — port of TS labeler.ts."""
+
+from __future__ import annotations
+
+from contexto_hermes.local.labeler import (
+ STOP_WORDS,
+ extract_keywords,
+ generate_label,
+)
+from contexto_hermes.local.mindmap_types import ConversationItem
+
+
+def _item(content: str, embedding=None) -> ConversationItem:
+ return ConversationItem(
+ id=content[:10] or "_",
+ role="assistant",
+ content=content,
+ embedding=embedding or [1.0, 0.0],
+ )
+
+
+class TestExtractKeywords:
+ def test_lowercases(self):
+ assert extract_keywords("Hello WORLD") == ["hello", "world"]
+
+ def test_strips_punctuation(self):
+ assert extract_keywords("foo, bar! baz?") == ["foo", "bar", "baz"]
+
+ def test_filters_words_3_chars_or_less(self):
+ assert extract_keywords("a be the cat") == ["cat"]
+
+ def test_filters_stop_words(self):
+ result = extract_keywords("the deployment failed")
+ assert "the" not in result
+ assert "deployment" in result
+ assert "failed" in result
+
+ def test_empty_string(self):
+ assert extract_keywords("") == []
+
+
+class TestStopWords:
+ def test_classic_stop_words_present(self):
+ for w in ("the", "and", "is", "have", "this", "that"):
+ assert w in STOP_WORDS
+
+ def test_keeps_meaningful_words(self):
+ for w in ("deployment", "error", "kubernetes"):
+ assert w not in STOP_WORDS
+
+
+class TestGenerateLabel:
+ def test_empty_items(self):
+ assert generate_label([], [0.0, 0.0]) == "Empty"
+
+ def test_single_item_uses_first_four_keywords(self):
+ item = _item("kubernetes deployment failed with network error")
+ assert generate_label([item], [1.0, 0.0]) == "kubernetes deployment failed network"
+
+ def test_single_item_fallback_to_content_prefix(self):
+ # All stop-words; keyword extraction returns nothing.
+ item = _item("the and or but")
+ label = generate_label([item], [1.0, 0.0])
+ assert label == "the and or but" # content[:30]
+
+ def test_multiple_items_uses_centroid_nearest(self):
+ # Two items; one matches the centroid direction.
+ items = [
+ _item("apple banana cherry date", embedding=[1.0, 0.0]),
+ _item("kubernetes deployment errors failed", embedding=[0.0, 1.0]),
+ ]
+ centroid = [0.0, 1.0]
+ label = generate_label(items, centroid)
+ # The "kubernetes..." item is closer to centroid
+ assert label == "kubernetes deployment errors failed"
+
+ def test_multiple_items_fallback_to_top_frequency(self):
+ # Two items whose nearest-centroid item has no keywords —
+ # falls back to top-3 frequency across all items.
+ items = [
+ _item("the and or but", embedding=[1.0, 0.0]), # centroid-nearest
+ _item("deployment deployment errors", embedding=[0.0, 1.0]),
+ ]
+ centroid = [1.0, 0.0]
+ label = generate_label(items, centroid)
+ # Top-3 frequency: "deployment" (2), "errors" (1)
+ assert "deployment" in label.split()
+ assert "errors" in label.split()
+
+ def test_multiple_items_final_fallback(self):
+ # All items contain only stop-words / short tokens
+ items = [
+ _item("the a is", embedding=[1.0, 0.0]),
+ _item("of in at", embedding=[0.0, 1.0]),
+ ]
+ assert generate_label(items, [1.0, 1.0]) == "Cluster"
diff --git a/packages/contexto-py/tests/local/test_register.py b/packages/contexto-py/tests/local/test_register.py
new file mode 100644
index 0000000..9ba186d
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_register.py
@@ -0,0 +1,107 @@
+"""Tests for backend-aware register()."""
+
+from __future__ import annotations
+
+import importlib
+import logging
+
+import pytest
+
+
+class _CapturingCtx:
+ def __init__(self) -> None:
+ self.registered: list = []
+
+ def register_context_engine(self, engine) -> None:
+ self.registered.append(engine)
+
+
+_LOCAL_ENV_VARS = (
+ "CONTEXTO_BACKEND",
+ "CONTEXTO_API_KEY",
+ "CONTEXTO_LOCAL_PROVIDER",
+ "CONTEXTO_LOCAL_STORAGE_PATH",
+ "OPENAI_API_KEY",
+ "OPENROUTER_API_KEY",
+ "HERMES_HOME",
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_env(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ for var in _LOCAL_ENV_VARS:
+ monkeypatch.delenv(var, raising=False)
+ # Always sandbox the local storage path so tests don't write to ~/.hermes.
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+
+def _reimport():
+ import contexto_hermes
+ return importlib.reload(contexto_hermes)
+
+
+class TestBackendSelection:
+ def test_default_is_remote(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc")
+ ctx = _CapturingCtx()
+ _reimport().register(ctx)
+ assert len(ctx.registered) == 1
+ assert ctx.registered[0].name == "contexto"
+ # Remote backend is the default.
+ from contexto_hermes.client import RemoteBackend
+ assert isinstance(ctx.registered[0].client, RemoteBackend)
+
+ def test_local_with_openai_key_registers_local_backend(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "local")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ ctx = _CapturingCtx()
+ _reimport().register(ctx)
+ assert len(ctx.registered) == 1
+ from contexto_hermes.local.backend import LocalBackend
+ assert isinstance(ctx.registered[0].client, LocalBackend)
+
+ def test_local_with_openrouter_key(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "local")
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
+ ctx = _CapturingCtx()
+ _reimport().register(ctx)
+ assert len(ctx.registered) == 1
+ from contexto_hermes.local.backend import LocalBackend
+ assert isinstance(ctx.registered[0].client, LocalBackend)
+ assert ctx.registered[0].client._config.provider == "openrouter"
+
+ def test_local_without_any_key_does_not_register(self, monkeypatch, caplog):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "local")
+ ctx = _CapturingCtx()
+ with caplog.at_level(logging.ERROR, logger="plugins.context_engine.contexto"):
+ _reimport().register(ctx)
+ assert ctx.registered == []
+ assert any("local" in r.message.lower() for r in caplog.records)
+
+
+class TestBackendValidation:
+ def test_invalid_backend_falls_back_to_remote(self, monkeypatch, caplog):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "redis")
+ monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc")
+ ctx = _CapturingCtx()
+ with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"):
+ _reimport().register(ctx)
+ assert len(ctx.registered) == 1
+ from contexto_hermes.client import RemoteBackend
+ assert isinstance(ctx.registered[0].client, RemoteBackend)
+ assert any("CONTEXTO_BACKEND" in r.message for r in caplog.records)
+
+ def test_remote_explicit_works(self, monkeypatch):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "remote")
+ monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc")
+ ctx = _CapturingCtx()
+ _reimport().register(ctx)
+ assert len(ctx.registered) == 1
+
+ def test_remote_without_api_key_does_not_register(self, monkeypatch, caplog):
+ monkeypatch.setenv("CONTEXTO_BACKEND", "remote")
+ ctx = _CapturingCtx()
+ with caplog.at_level(logging.ERROR, logger="plugins.context_engine.contexto"):
+ _reimport().register(ctx)
+ assert ctx.registered == []
+ assert any("CONTEXTO_API_KEY" in r.message for r in caplog.records)
diff --git a/packages/contexto-py/tests/local/test_retrieval.py b/packages/contexto-py/tests/local/test_retrieval.py
new file mode 100644
index 0000000..713282e
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_retrieval.py
@@ -0,0 +1,155 @@
+"""Tests for beam_search."""
+
+from __future__ import annotations
+
+import numpy as np
+
+from contexto_hermes.local.mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ MindmapConfig,
+)
+from contexto_hermes.local.retrieval import beam_search
+
+
+def _normalize(v: list[float]) -> list[float]:
+ arr = np.asarray(v, dtype=np.float64)
+ n = float(np.linalg.norm(arr))
+ return (arr / n).tolist() if n > 0 else v
+
+
+def _item(i: int, embedding: list[float], **md) -> ConversationItem:
+ return ConversationItem(
+ id=f"item-{i}",
+ role="assistant",
+ content=f"content {i}",
+ embedding=embedding,
+ metadata=md,
+ )
+
+
+def _node(
+ id: str, label: str, centroid: list[float],
+ children=None, items=None, depth=1, item_count=None,
+) -> ClusterNode:
+ items = items or []
+ children = children or []
+ return ClusterNode(
+ id=id, label=label, centroid=centroid,
+ children=children, items=items, depth=depth,
+ item_count=item_count if item_count is not None else len(items),
+ )
+
+
+def _root_with(children: list[ClusterNode], centroid: list[float] | None = None) -> ClusterNode:
+ total = sum(c.item_count for c in children)
+ return _node("root", "Knowledge", centroid or [0.5, 0.5], children=children, depth=0, item_count=total)
+
+
+class TestBasics:
+ def test_empty_tree_no_terminals_returns_no_items(self):
+ # root with no children — falls through to root as terminal (empty itself)
+ root = _root_with([])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, [1.0, 0.0], cfg, beam_width=3, max_results=5)
+ assert out.items == []
+ assert out.scored == []
+
+ def test_paths_use_labels_not_ids(self):
+ leaf = _node("cluster-1", "deployment errors", _normalize([1.0, 0.0]),
+ items=[_item(0, _normalize([1.0, 0.0]))])
+ root = _root_with([leaf])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=5)
+ assert out.paths == [["deployment errors"]]
+ # IDs must NOT appear in paths
+ assert "cluster-1" not in [p for path in out.paths for p in path]
+
+
+class TestPruning:
+ def test_children_below_threshold_pruned(self):
+ # Three top-level clusters: one matches, two don't.
+ a = _node("a", "A", _normalize([1.0, 0.0]),
+ items=[_item(1, _normalize([1.0, 0.05]))])
+ b = _node("b", "B", _normalize([0.0, 1.0]),
+ items=[_item(2, _normalize([0.0, 1.0]))])
+ c = _node("c", "C", _normalize([-1.0, 0.0]),
+ items=[_item(3, _normalize([-1.0, 0.0]))])
+ root = _root_with([a, b, c])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=5)
+ # Only items from cluster A should be returned.
+ assert {it.id for it in out.items} == {"item-1"}
+
+ def test_beam_width_limits_branches(self):
+ # Five clusters, all somewhat aligned with the query.
+ children = [
+ _node(f"c{i}", f"L{i}", _normalize([1.0, i * 0.05]),
+ items=[_item(i, _normalize([1.0, i * 0.05]))])
+ for i in range(5)
+ ]
+ root = _root_with(children)
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ # beam_width=2 keeps only the top-2 branches.
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=2, max_results=10)
+ assert len(out.paths) == 2
+
+ def test_no_root_children_pass_falls_back_to_root(self):
+ # Two children both orthogonal to query.
+ a = _node("a", "A", _normalize([0.0, 1.0]),
+ items=[_item(1, _normalize([0.0, 1.0]))])
+ root = _root_with([a])
+ cfg = MindmapConfig(similarity_threshold=0.9)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=5)
+ # paths should contain at least the empty path (root)
+ assert any(p == [] for p in out.paths)
+
+
+class TestFiltering:
+ def test_exact_match_filter(self):
+ a = _node("a", "A", _normalize([1.0, 0.0]), items=[
+ _item(1, _normalize([1.0, 0.0]), source="summary"),
+ _item(2, _normalize([1.0, 0.0]), source="raw"),
+ _item(3, _normalize([1.0, 0.0]), source="summary"),
+ ])
+ root = _root_with([a])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=10,
+ filter={"source": "summary"})
+ assert {it.id for it in out.items} == {"item-1", "item-3"}
+
+ def test_min_score_filter(self):
+ a = _node("a", "A", _normalize([1.0, 0.0]), items=[
+ _item(1, _normalize([1.0, 0.0])), # sim=1.0
+ _item(2, _normalize([1.0, 0.6])), # sim≈0.857
+ _item(3, _normalize([1.0, 1.5])), # sim≈0.554
+ ])
+ root = _root_with([a])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=10,
+ min_score=0.8)
+ ids = {it.id for it in out.items}
+ assert "item-1" in ids
+ assert "item-2" in ids
+ assert "item-3" not in ids
+
+ def test_max_results_slices(self):
+ a = _node("a", "A", _normalize([1.0, 0.0]), items=[
+ _item(i, _normalize([1.0, i * 0.01])) for i in range(10)
+ ])
+ root = _root_with([a])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=3)
+ assert len(out.items) == 3
+
+
+class TestDedup:
+ def test_items_deduped_by_id(self):
+ # Same item id in two clusters (degenerate, but tree shouldn't double-count).
+ dup = _item(7, _normalize([1.0, 0.0]))
+ a = _node("a", "A", _normalize([1.0, 0.0]), items=[dup])
+ b = _node("b", "B", _normalize([1.0, 0.0]), items=[dup])
+ root = _root_with([a, b])
+ cfg = MindmapConfig(similarity_threshold=0.5)
+ out = beam_search(root, _normalize([1.0, 0.0]), cfg, beam_width=3, max_results=10)
+ assert [it.id for it in out.items] == ["item-7"]
diff --git a/packages/contexto-py/tests/local/test_round_trip.py b/packages/contexto-py/tests/local/test_round_trip.py
new file mode 100644
index 0000000..ad78299
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_round_trip.py
@@ -0,0 +1,158 @@
+"""Integration round-trip for LocalBackend — fake embedder/summarizer, real scipy."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from contexto_hermes.local.backend import LocalBackend
+from contexto_hermes.local.mindmap_types import (
+ EpisodeSummary,
+ EvidenceRef,
+ LocalBackendConfig,
+ MindmapConfig,
+)
+
+
+def _payload(text: str, *, idx: int) -> dict:
+ return {
+ "event": {"type": "episode", "action": "combined"},
+ "sessionKey": f"sess-{idx}",
+ "timestamp": "2026-05-23T00:00:00.000Z",
+ "context": {"sessionId": f"sess-{idx}"},
+ "data": {"messages": [
+ {"role": "user", "content": text},
+ {"role": "assistant", "content": f"answered: {text}"},
+ ]},
+ }
+
+
+class _DeterministicEmbedder:
+ """Hash-based embedder. Texts with shared tokens get nearby vectors."""
+
+ def __init__(self, dim: int = 32) -> None:
+ self._dim = dim
+
+ def embed(self, text: str) -> list[float]:
+ words = text.lower().split()
+ if not words:
+ words = [""]
+ vec = np.zeros(self._dim, dtype=np.float64)
+ for w in words:
+ h = hashlib.sha256(w.encode("utf-8")).digest()
+ v = np.frombuffer(h[: self._dim], dtype=np.uint8).astype(np.float64)
+ v = (v - 128.0) / 128.0
+ vec += v
+ norm = float(np.linalg.norm(vec))
+ if norm > 0:
+ vec /= norm
+ return vec.tolist()
+
+
+class _StaticSummarizer:
+ """Echoes its input text as the summary — so search-by-content works."""
+
+ def summarize(self, text: str) -> EpisodeSummary:
+ return EpisodeSummary(
+ summary=text,
+ key_findings=["fact"],
+ status="complete",
+ confidence=0.95,
+ evidence_refs=[EvidenceRef(type="episode_ref", value="x")],
+ open_questions=None,
+ )
+
+
+@pytest.fixture
+def cfg(tmp_path) -> LocalBackendConfig:
+ return LocalBackendConfig(
+ storage_path=str(tmp_path / "mindmap.json"),
+ provider="openai",
+ api_key="sk-test",
+ embed_model="text-embedding-3-small",
+ llm_model="gpt-4o-mini",
+ embed_base_url="https://api.openai.com/v1",
+ llm_base_url="https://api.openai.com/v1",
+ summarize=True,
+ mindmap=MindmapConfig(similarity_threshold=0.4, rebuild_interval=20),
+ beam_width=3,
+ embed_timeout=5.0,
+ llm_timeout=10.0,
+ )
+
+
+def _make_backend(cfg: LocalBackendConfig) -> LocalBackend:
+ return LocalBackend(cfg, embedder=_DeterministicEmbedder(), summarizer=_StaticSummarizer())
+
+
+class TestRoundTrip:
+ def test_40_then_20_episodes(self, cfg):
+ backend = _make_backend(cfg)
+ # First batch — 40 items, total < 100 forces rebuild.
+ batch1 = [
+ _payload(f"deployment failed in cluster {i}", idx=i) for i in range(20)
+ ] + [
+ _payload(f"weather sunny today {i}", idx=100 + i) for i in range(20)
+ ]
+ assert backend.ingest(batch1) is True
+
+ from contexto_hermes.local.store import Store
+ state = Store(cfg.storage_path).load()
+ assert state.stats.total_items == 40
+ assert state.stats.inserts_since_rebuild == 0 # rebuilt
+
+ # Search — items with overlapping tokens should rank highest.
+ result = backend.search("deployment failed", max_results=5)
+ assert result is not None
+ # Top result should match the deployment-shaped texts.
+ top_contents = " ".join(e["item"]["content"] for e in result.items[:3]).lower()
+ assert "deployment" in top_contents
+
+ # Second batch — total goes to 60, still < 100 → rebuild on every add.
+ batch2 = [_payload(f"hiking trip {i}", idx=200 + i) for i in range(20)]
+ assert backend.ingest(batch2) is True
+ state = Store(cfg.storage_path).load()
+ assert state.stats.total_items == 60
+
+ def test_reinstantiate_against_same_path_reloads_stats(self, cfg):
+ backend = _make_backend(cfg)
+ backend.ingest([_payload(f"hello {i}", idx=i) for i in range(5)])
+ # Throw away the backend; new instance against same path picks up the state.
+ backend2 = _make_backend(cfg)
+ # Searching forces a load.
+ result = backend2.search("hello", max_results=5)
+ assert result is not None
+ assert len(result.items) == 5
+
+ def test_persisted_disk_schema(self, cfg):
+ backend = _make_backend(cfg)
+ backend.ingest([_payload(f"text {i}", idx=i) for i in range(3)])
+ raw = json.loads(Path(cfg.storage_path).read_text())
+ assert raw["version"] == 1
+ assert raw["stats"]["total_items"] == 3
+ assert raw["root"] is not None
+
+
+class TestEmptyStoreGuard:
+ def test_empty_store_returns_empty_result(self, cfg):
+ # Patch beam_search to raise; the empty-store guard must short-circuit
+ # before retrieval is invoked, returning empty results (not None —
+ # that's reserved for failures).
+ backend = _make_backend(cfg)
+ import contexto_hermes.local.backend as mod
+ original = mod.beam_search
+
+ def boom(*a, **kw):
+ raise AssertionError("beam_search should not be called on empty store")
+
+ mod.beam_search = boom
+ try:
+ result = backend.search("anything", max_results=5)
+ assert result is not None
+ assert result.items == [] and result.paths == []
+ finally:
+ mod.beam_search = original
diff --git a/packages/contexto-py/tests/local/test_store.py b/packages/contexto-py/tests/local/test_store.py
new file mode 100644
index 0000000..c340b3d
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_store.py
@@ -0,0 +1,164 @@
+"""Tests for the local mindmap store."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from contexto_hermes.local.mindmap_types import (
+ ClusterNode,
+ ConversationItem,
+ StoreState,
+ StoreStats,
+)
+from contexto_hermes.local.store import Store, SCHEMA_VERSION
+
+
+def _sample_state() -> StoreState:
+ item = ConversationItem(
+ id="item-1",
+ role="assistant",
+ content="hello world",
+ embedding=[0.1, 0.2, 0.3],
+ timestamp="2026-05-23T00:00:00.000Z",
+ metadata={"source": "summary", "status": "complete"},
+ )
+ cluster = ClusterNode(
+ id="cluster-1",
+ label="deployment errors",
+ centroid=[0.1, 0.2, 0.3],
+ children=[],
+ items=[item],
+ depth=1,
+ item_count=1,
+ )
+ root = ClusterNode(
+ id="root",
+ label="Knowledge",
+ centroid=[0.1, 0.2, 0.3],
+ children=[cluster],
+ items=[],
+ depth=0,
+ item_count=1,
+ )
+ return StoreState(
+ version=SCHEMA_VERSION,
+ config_snapshot={"similarity_threshold": 0.65},
+ root=root,
+ stats=StoreStats(total_items=1, total_clusters=2, inserts_since_rebuild=0),
+ )
+
+
+class TestLoadMissingFile:
+ def test_returns_empty_state(self, tmp_path):
+ store = Store(str(tmp_path / "missing.json"))
+ state = store.load()
+ assert state.version == SCHEMA_VERSION
+ assert state.root is None
+ assert state.stats == StoreStats()
+
+
+class TestRoundTrip:
+ def test_save_then_load(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ store = Store(str(path))
+ store.save(_sample_state())
+ loaded = store.load()
+ assert loaded.version == SCHEMA_VERSION
+ assert loaded.stats.total_items == 1
+ assert loaded.stats.total_clusters == 2
+ assert loaded.root is not None
+ assert loaded.root.id == "root"
+ assert loaded.root.children[0].label == "deployment errors"
+ assert loaded.root.children[0].items[0].content == "hello world"
+ assert loaded.root.children[0].items[0].metadata["source"] == "summary"
+
+ def test_save_creates_parent_dirs(self, tmp_path):
+ path = tmp_path / "a" / "b" / "c" / "mindmap.json"
+ store = Store(str(path))
+ store.save(_sample_state())
+ assert path.exists()
+
+ def test_atomic_write_uses_tmp_then_rename(self, tmp_path):
+ # The .tmp file should not exist after a successful save.
+ path = tmp_path / "mindmap.json"
+ store = Store(str(path))
+ store.save(_sample_state())
+ assert path.exists()
+ assert not path.with_suffix(".json.tmp").exists()
+
+
+class TestQuarantine:
+ def test_unparseable_json_is_quarantined(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ path.write_text("not json {{", encoding="utf-8")
+ store = Store(str(path))
+ state = store.load()
+ assert state.root is None
+ # Original file moved aside; subsequent saves still work.
+ assert any(p.name.startswith("mindmap.json.corrupted-") for p in tmp_path.iterdir())
+ # File no longer at the original path.
+ assert not path.exists()
+
+ def test_unknown_version_is_quarantined(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ path.write_text(json.dumps({"version": 99, "root": None, "stats": {}}), encoding="utf-8")
+ store = Store(str(path))
+ state = store.load()
+ assert state.version == SCHEMA_VERSION
+ assert state.root is None
+ assert any(p.name.startswith("mindmap.json.corrupted-") for p in tmp_path.iterdir())
+
+ def test_non_dict_stats_is_quarantined(self, tmp_path):
+ # F9: `stats` must be an object. A string / list would previously raise
+ # AttributeError on `.get(...)` before quarantine. Verify it now
+ # quarantines cleanly and returns a fresh state.
+ path = tmp_path / "mindmap.json"
+ path.write_text(
+ json.dumps({"version": SCHEMA_VERSION, "stats": "broken", "root": None}),
+ encoding="utf-8",
+ )
+ store = Store(str(path))
+ state = store.load()
+ assert state.root is None
+ assert state.stats == StoreStats()
+ assert any(p.name.startswith("mindmap.json.corrupted-") for p in tmp_path.iterdir())
+
+ def test_top_level_array_is_quarantined(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ path.write_text("[]", encoding="utf-8")
+ store = Store(str(path))
+ state = store.load()
+ assert state.root is None
+ assert any(p.name.startswith("mindmap.json.corrupted-") for p in tmp_path.iterdir())
+
+ def test_save_after_quarantine_writes_fresh_state(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ path.write_text("garbage", encoding="utf-8")
+ store = Store(str(path))
+ store.load() # quarantine
+ store.save(_sample_state())
+ # Original path now contains the fresh state.
+ assert path.exists()
+ loaded = store.load()
+ assert loaded.stats.total_items == 1
+
+
+class TestSchema:
+ def test_disk_has_version_field(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ Store(str(path)).save(_sample_state())
+ raw = json.loads(path.read_text())
+ assert raw["version"] == SCHEMA_VERSION
+ assert "config_snapshot" in raw
+ assert "stats" in raw
+ assert "root" in raw
+
+ def test_empty_state_persists_with_none_root(self, tmp_path):
+ path = tmp_path / "mindmap.json"
+ store = Store(str(path))
+ store.save(StoreState(version=SCHEMA_VERSION))
+ raw = json.loads(path.read_text())
+ assert raw["root"] is None
diff --git a/packages/contexto-py/tests/local/test_summarizer.py b/packages/contexto-py/tests/local/test_summarizer.py
new file mode 100644
index 0000000..f78b08c
--- /dev/null
+++ b/packages/contexto-py/tests/local/test_summarizer.py
@@ -0,0 +1,172 @@
+"""Tests for the LLM summarizer."""
+
+from __future__ import annotations
+
+import json
+from typing import Callable
+
+import httpx
+import pytest
+
+from contexto_hermes.local.summarizer import (
+ SUMMARIZE_SYSTEM_PROMPT,
+ Summarizer,
+ build_synthetic_summary,
+)
+
+
+def _mock(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.MockTransport:
+ return httpx.MockTransport(handler)
+
+
+def _success(content: dict) -> httpx.Response:
+ return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps(content)}}]})
+
+
+class TestRequest:
+ def test_url_headers_method(self, base_config):
+ captured: list[httpx.Request] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req)
+ return _success({"summary": "s", "status": "complete", "key_findings": ["x"], "confidence": 0.9})
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ s.summarize("episode text")
+ req = captured[0]
+ assert str(req.url) == "https://api.openai.com/v1/chat/completions"
+ assert req.method == "POST"
+ assert req.headers["authorization"] == "Bearer sk-test"
+
+ def test_body_has_temperature_and_response_format(self, base_config):
+ captured: list[bytes] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ captured.append(req.content)
+ return _success({"summary": "s", "status": "complete", "key_findings": ["x"], "confidence": 0.5})
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ s.summarize("episode text")
+ body = json.loads(captured[0])
+ assert body["temperature"] == 0.2
+ assert body["response_format"] == {"type": "json_object"}
+ assert body["model"] == "gpt-4o-mini"
+ assert body["messages"][0] == {"role": "system", "content": SUMMARIZE_SYSTEM_PROMPT}
+ assert body["messages"][1] == {"role": "user", "content": "episode text"}
+
+ def test_system_prompt_verbatim(self):
+ # Spot-check key invariants from the TS prompt
+ assert "concise summarizer" in SUMMARIZE_SYSTEM_PROMPT
+ assert '"status": "complete" | "partial" | "blocked"' in SUMMARIZE_SYSTEM_PROMPT
+ assert "Respond ONLY with valid JSON, no markdown fences, no extra text." in SUMMARIZE_SYSTEM_PROMPT
+
+
+class TestResponseParsing:
+ def test_well_formed_summary(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return _success({
+ "summary": "the assistant explained X",
+ "key_findings": ["finding-a", "finding-b"],
+ "status": "complete",
+ "confidence": 0.91,
+ "evidence_refs": [{"type": "file_ref", "value": "foo.py"}],
+ "open_questions": ["q1"],
+ })
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("text")
+ assert out.summary == "the assistant explained X"
+ assert out.key_findings == ["finding-a", "finding-b"]
+ assert out.status == "complete"
+ assert out.confidence == 0.91
+ assert out.evidence_refs[0].type == "file_ref"
+ assert out.evidence_refs[0].value == "foo.py"
+ assert out.open_questions == ["q1"]
+
+ def test_invalid_status_coerced_to_partial(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return _success({"summary": "s", "status": "weird", "key_findings": ["x"], "confidence": 0.5})
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("text")
+ assert out.status == "partial"
+
+ def test_invalid_confidence_coerced_to_half(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return _success({"summary": "s", "status": "complete", "key_findings": ["x"], "confidence": 1.5})
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("text")
+ assert out.confidence == 0.5
+
+ def test_evidence_refs_filtered_to_valid_dicts(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return _success({
+ "summary": "s",
+ "status": "complete",
+ "key_findings": ["x"],
+ "confidence": 0.5,
+ "evidence_refs": [
+ {"type": "tool_ref", "value": "ok"},
+ {"type": 123, "value": "bad"}, # bad
+ "not a dict",
+ ],
+ })
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("text")
+ assert len(out.evidence_refs) == 1
+ assert out.evidence_refs[0].type == "tool_ref"
+
+
+class TestFallback:
+ def test_http_error_returns_fallback(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(500, text="server boom")
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("text")
+ assert "fallback" in out.key_findings[0]
+ assert out.confidence == 0.0
+ assert out.status == "partial"
+
+ def test_parse_error_returns_fallback(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"choices": [{"message": {"content": "not json"}}]})
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("hello")
+ assert "fallback" in out.key_findings[0]
+
+ def test_network_error_returns_fallback(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("nope")
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ out = s.summarize("x")
+ assert "fallback" in out.key_findings[0]
+
+ def test_fallback_truncates_long_text(self, base_config):
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(500)
+
+ s = Summarizer(base_config, transport=_mock(handler))
+ long_text = "x" * 500
+ out = s.summarize(long_text)
+ assert out.summary.endswith("...")
+ assert len(out.summary) == 203
+
+
+class TestSyntheticSummary:
+ def test_distinct_marker(self):
+ out = build_synthetic_summary("hello")
+ assert out.summary == "hello"
+ assert out.key_findings == ["Episode processed (summarization disabled)"]
+ assert out.status == "partial"
+ assert out.confidence == 0.0
+ assert out.evidence_refs == []
+
+ def test_truncates_long_text(self):
+ out = build_synthetic_summary("x" * 500)
+ assert out.summary.endswith("...")
+ assert len(out.summary) == 203
diff --git a/packages/contexto-py/tests/test_plugin_yaml.py b/packages/contexto-py/tests/test_plugin_yaml.py
index fc33312..6ff141a 100644
--- a/packages/contexto-py/tests/test_plugin_yaml.py
+++ b/packages/contexto-py/tests/test_plugin_yaml.py
@@ -32,10 +32,40 @@ def test_required_env_vars_present() -> None:
assert required in names, f"missing env var: {required}"
-def test_api_key_marked_required() -> None:
+def test_api_key_not_unconditionally_required() -> None:
+ # As of v0.1.0, CONTEXTO_API_KEY is only needed when CONTEXTO_BACKEND=remote;
+ # the local backend uses provider keys. So the manifest must NOT mark it required.
data = _load()
api_key_entry = next(ev for ev in data["env_vars"] if ev["name"] == "CONTEXTO_API_KEY")
- assert api_key_entry.get("required") is True
+ assert api_key_entry.get("required") is False
+
+
+def test_backend_selector_declared() -> None:
+ data = _load()
+ names = {ev["name"] for ev in data["env_vars"]}
+ assert "CONTEXTO_BACKEND" in names
+
+
+def test_local_backend_env_vars_declared() -> None:
+ data = _load()
+ names = {ev["name"] for ev in data["env_vars"]}
+ for required in (
+ "CONTEXTO_LOCAL_PROVIDER",
+ "CONTEXTO_LOCAL_STORAGE_PATH",
+ "CONTEXTO_LOCAL_EMBED_MODEL",
+ "CONTEXTO_LOCAL_LLM_MODEL",
+ "CONTEXTO_LOCAL_SUMMARIZE",
+ "CONTEXTO_LOCAL_SIMILARITY_THRESHOLD",
+ "CONTEXTO_LOCAL_MAX_DEPTH",
+ "CONTEXTO_LOCAL_MAX_CHILDREN",
+ "CONTEXTO_LOCAL_REBUILD_INTERVAL",
+ "CONTEXTO_LOCAL_BEAM_WIDTH",
+ "CONTEXTO_LOCAL_EMBED_TIMEOUT",
+ "CONTEXTO_LOCAL_LLM_TIMEOUT",
+ "OPENAI_API_KEY",
+ "OPENROUTER_API_KEY",
+ ):
+ assert required in names, f"missing env var: {required}"
def test_version_matches_package() -> None: