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: