From 02d37f053d181a778f11a6790c5d597281b34e4d Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Tue, 26 May 2026 19:55:15 +0000 Subject: [PATCH 01/11] =?UTF-8?q?docs:=20design=20spec=20for=20Contexto=20?= =?UTF-8?q?=C3=97=20Hermes=20local=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...23-contexto-hermes-local-backend-design.md | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 docs/specs/2026-05-23-contexto-hermes-local-backend-design.md 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..d02f5e7 --- /dev/null +++ b/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md @@ -0,0 +1,347 @@ +# 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/data/contexto/mindmap.json`. +- 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, logger: Logger)` — stores config; lazy `Store.load()` on first ingest/search. +- `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. Returns `None` on failure OR when no items survive filtering (matches TS). 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. + +### `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_model` (None → provider default), `llm_model` (None → provider default), `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/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` should align with Hermes' broader data-dir convention if one exists; the default above is a placeholder pending that check. + +## 8. Storage format + +```json +{ + "version": 1, + "config_snapshot": { "embed_model": "...", "similarity_threshold": 0.65, "rebuild_interval": 50 }, + "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`. Return `None` when the result list is empty. + +`search` returns `None` when the store is empty (no cluster tree yet), when no items survive filtering, or on any failure. The empty-store guard short-circuits before retrieval is invoked. + +## 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`: `None`. | (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 `None`. +- **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.** `~/.hermes/data/contexto/mindmap.json` chosen by analogy to OpenClaw's `~/.openclaw/`. Should align with Hermes' real data-dir convention if one exists. + +## 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`. From 37b345cbcec56923bc0cdcdc6ea426c5d7ac2138 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Thu, 28 May 2026 23:33:52 +0000 Subject: [PATCH 02/11] feat(contexto-py): LocalBackend for on-disk mindmap retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the LocalBackend per the approved 2026-05-23 design: embeddings + summarization call the user's OpenAI/OpenRouter provider, and state lives on disk as a single JSON file. Selectable via CONTEXTO_BACKEND=local; remote stays the default. - 10 modules under src/contexto_hermes/local/ (mindmap_types, extractor, store, labeler, embedder, summarizer, clustering, retrieval, backend, __init__) - scipy AGNES (linkage average/cosine) + beam-search retrieval - Atomic JSON writes with corrupt-file quarantine + version:1 schema - Backend-aware register() reading CONTEXTO_BACKEND - ContextoEngine.from_env_local() + ContextoConfig.local_mode_defaults() - SearchResult.paths typed list[list[str]] (cluster labels) - Search items wrapped as {item, score} mirroring TS ScoredQueryResult - LocalBackendConfig.embed_model/llm_model nullable with provider-default fallback - config_snapshot populated on every save (embed_model, mindmap tunables) - Store quarantines non-dict stats explicitly - 124 tests under tests/local/ (336 total in contexto-py, all green) - Docker E2E harness (e2e/Dockerfile + run_local_e2e.py) verified against real OpenRouter - pyproject.toml bumped 0.1.0 -> 0.2.0; adds numpy + scipy - Spec §7 storage path resolved to honor \$HERMES_HOME --- ...23-contexto-hermes-local-backend-design.md | 27 +- packages/contexto-py/e2e/Dockerfile | 32 ++ packages/contexto-py/e2e/README.md | 91 ++++ .../e2e/docker-compose.hermes-local.yml | 39 ++ packages/contexto-py/e2e/run_local_e2e.py | 174 ++++++++ packages/contexto-py/pyproject.toml | 10 +- .../src/contexto_hermes/__init__.py | 59 ++- .../contexto-py/src/contexto_hermes/engine.py | 16 + .../src/contexto_hermes/local/__init__.py | 28 ++ .../src/contexto_hermes/local/backend.py | 260 +++++++++++ .../src/contexto_hermes/local/clustering.py | 409 ++++++++++++++++++ .../src/contexto_hermes/local/embedder.py | 90 ++++ .../src/contexto_hermes/local/extractor.py | 58 +++ .../src/contexto_hermes/local/labeler.py | 91 ++++ .../contexto_hermes/local/mindmap_types.py | 220 ++++++++++ .../src/contexto_hermes/local/retrieval.py | 124 ++++++ .../src/contexto_hermes/local/store.py | 188 ++++++++ .../src/contexto_hermes/local/summarizer.py | 205 +++++++++ .../src/contexto_hermes/plugin.yaml | 51 ++- .../contexto-py/src/contexto_hermes/types.py | 26 +- packages/contexto-py/tests/local/__init__.py | 0 packages/contexto-py/tests/local/conftest.py | 46 ++ .../contexto-py/tests/local/test_backend.py | 257 +++++++++++ .../tests/local/test_clustering.py | 134 ++++++ .../contexto-py/tests/local/test_config.py | 207 +++++++++ .../contexto-py/tests/local/test_embedder.py | 110 +++++ .../contexto-py/tests/local/test_extractor.py | 125 ++++++ .../contexto-py/tests/local/test_labeler.py | 96 ++++ .../contexto-py/tests/local/test_register.py | 107 +++++ .../contexto-py/tests/local/test_retrieval.py | 155 +++++++ .../tests/local/test_round_trip.py | 155 +++++++ .../contexto-py/tests/local/test_store.py | 164 +++++++ .../tests/local/test_summarizer.py | 172 ++++++++ .../contexto-py/tests/test_plugin_yaml.py | 34 +- 34 files changed, 3933 insertions(+), 27 deletions(-) create mode 100644 packages/contexto-py/e2e/Dockerfile create mode 100644 packages/contexto-py/e2e/README.md create mode 100644 packages/contexto-py/e2e/docker-compose.hermes-local.yml create mode 100644 packages/contexto-py/e2e/run_local_e2e.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/__init__.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/backend.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/clustering.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/embedder.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/extractor.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/labeler.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/mindmap_types.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/retrieval.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/store.py create mode 100644 packages/contexto-py/src/contexto_hermes/local/summarizer.py create mode 100644 packages/contexto-py/tests/local/__init__.py create mode 100644 packages/contexto-py/tests/local/conftest.py create mode 100644 packages/contexto-py/tests/local/test_backend.py create mode 100644 packages/contexto-py/tests/local/test_clustering.py create mode 100644 packages/contexto-py/tests/local/test_config.py create mode 100644 packages/contexto-py/tests/local/test_embedder.py create mode 100644 packages/contexto-py/tests/local/test_extractor.py create mode 100644 packages/contexto-py/tests/local/test_labeler.py create mode 100644 packages/contexto-py/tests/local/test_register.py create mode 100644 packages/contexto-py/tests/local/test_retrieval.py create mode 100644 packages/contexto-py/tests/local/test_round_trip.py create mode 100644 packages/contexto-py/tests/local/test_store.py create mode 100644 packages/contexto-py/tests/local/test_summarizer.py 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 index d02f5e7..771d558 100644 --- a/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md +++ b/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md @@ -23,7 +23,7 @@ The v1 plugin spec ([§2 Out of scope](./2026-05-22-contexto-hermes-plugin-desig - 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/data/contexto/mindmap.json`. +- 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) @@ -95,9 +95,9 @@ Ten modules under `local/`. Each has one job. All public-boundary errors are cau Same duck-typed contract as `RemoteBackend`. Mirrors TS `LocalBackend` in `packages/contexto/src/local/backend.ts`. -- `__init__(config: LocalBackendConfig, logger: Logger)` — stores config; lazy `Store.load()` on first ingest/search. +- `__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. Returns `None` on failure OR when no items survive filtering (matches TS). 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` on failure OR when no items survive filtering (matches TS). Never raises. ### `extractor.py` @@ -157,7 +157,7 @@ Beam search over the cluster tree (port of TS `queryMindmapMultiBranch`): 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. +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` @@ -177,7 +177,7 @@ When `CONTEXTO_LOCAL_SUMMARIZE=false`, `backend.py` calls `summarizer.build_synt 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_model` (None → provider default), `llm_model` (None → provider default), `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. +- **`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]`. @@ -211,7 +211,7 @@ 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/data/contexto/mindmap.json` | JSON store path | +| `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 | @@ -236,14 +236,23 @@ Explicit `CONTEXTO_LOCAL_PROVIDER` wins and requires its matching key. Explicit | `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` should align with Hermes' broader data-dir convention if one exists; the default above is a placeholder pending that check. +`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": "...", "similarity_threshold": 0.65, "rebuild_interval": 50 }, + "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", @@ -337,7 +346,7 @@ Located in `contexto-py/tests/local/`. 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.** `~/.hermes/data/contexto/mindmap.json` chosen by analogy to OpenClaw's `~/.openclaw/`. Should align with Hermes' real data-dir convention if one exists. +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 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..e1d572a --- /dev/null +++ b/packages/contexto-py/e2e/README.md @@ -0,0 +1,91 @@ +# 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`. The bundled +`plugins/context_engine/contexto/` is symlinked to this source tree in the +hermes-agent repo, so a fresh image build picks up the changes automatically. + +```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 +``` + +After driving a chat session that triggers `compress()`, the mindmap lands at: + +``` +~/.hermes/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' ~/.hermes/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..26ea253 --- /dev/null +++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml @@ -0,0 +1,39 @@ +# 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. +# +# Note: the bundled `plugins/context_engine/contexto/` lives at +# `/opt/hermes/plugins/context_engine/contexto` inside the image. The +# hermes-agent repo already symlinks that path back to this source tree, so a +# fresh image build picks up v0.2.0 automatically — no bind-mount needed. +# If you want to iterate on contexto-py without rebuilding hermes-agent, mount +# the source over the image's copy: +# +# - ../../../contexto/packages/contexto-py/src/contexto_hermes:/opt/hermes/plugins/context_engine/contexto +# +services: + gateway: + image: hermes-agent + container_name: hermes-contexto-local + restart: "no" + network_mode: host + volumes: + - ${HOME}/.hermes:/opt/data + 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: ["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..3902976 --- /dev/null +++ b/packages/contexto-py/e2e/run_local_e2e.py @@ -0,0 +1,174 @@ +"""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)) + for i, item in enumerate(result.items): + log.info(" rank %d: content[:80]=%r", i + 1, item["content"][:80]) + + # The kubernetes-shaped items should outrank the restaurant one. + top_contents = " ".join(it["content"].lower() for it 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..ac70d30 100644 --- a/packages/contexto-py/pyproject.toml +++ b/packages/contexto-py/pyproject.toml @@ -4,8 +4,8 @@ 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)" +version = "0.2.0" +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..51450c0 100644 --- a/packages/contexto-py/src/contexto_hermes/__init__.py +++ b/packages/contexto-py/src/contexto_hermes/__init__.py @@ -2,17 +2,25 @@ 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 __all__ = ["ContextoEngine", "register", "__compatible_contexto_api__"] -__version__ = "0.1.0" +__version__ = "0.2.0" # Pinned `api.getcontexto.com` schema version compatible with this release. # Bumped independently from `@ekai/contexto`'s semver. @@ -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..fda7576 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/local/backend.py @@ -0,0 +1,260 @@ +"""LocalBackend orchestrator. Same duck-typed contract as RemoteBackend. + +`ingest` + `search` **never raise.** All errors land here, converted to +`False` / `None`. +""" + +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: + content_parts = [summary.summary] + if summary.key_findings: + findings = "\n".join(f"- {f}" for f in summary.key_findings) + content_parts.append(f"\nKey findings:\n{findings}") + content = "\n".join(content_parts) + + 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: + return None + + 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, + ) + + if not result.scored: + return None + + # 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..b2cfa35 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/local/clustering.py @@ -0,0 +1,409 @@ +"""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 itertools +import logging +from dataclasses import dataclass +from typing import Any, Iterable + +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 + self._counter = itertools.count(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 + + 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") + Z = linkage(embeddings, method="average", metric="cosine") + # scipy may produce tiny negatives for identical points; clamp to 0. + Z = np.where(np.isnan(Z), 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: + return f"cluster-{next(self._counter)}" + + +__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..4bc6e4c 100644 --- a/packages/contexto-py/src/contexto_hermes/plugin.yaml +++ b/packages/contexto-py/src/contexto_hermes/plugin.yaml @@ -1,11 +1,14 @@ name: contexto -description: Contexto context engine — full episodes + mindmap retrieval (remote) -version: 0.1.0 +description: Contexto context engine — remote (api.getcontexto.com) or local (on-disk mindmap) +version: 0.2.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 @@ -18,3 +21,41 @@ env_vars: 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..13a5dac 100644 --- a/packages/contexto-py/src/contexto_hermes/types.py +++ b/packages/contexto-py/src/contexto_hermes/types.py @@ -124,6 +124,23 @@ 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), + min_score=_env_float("CONTEXTO_MIN_SCORE", default=0.45, 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 +153,12 @@ 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`. (Type changed in + v0.2.0 from `list[dict]` to `list[list[str]]` to match the local backend.) + """ 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..5fe2362 --- /dev/null +++ b/packages/contexto-py/tests/local/test_backend.py @@ -0,0 +1,257 @@ +"""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_None(self, base_config): + backend = _make_backend(base_config) + assert backend.search("anything", max_results=5) is None + + 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_returns_none_when_no_results_pass_filter(self, base_config): + backend = _make_backend(base_config) + backend.ingest([_payload("hi")]) + # Filter on metadata that doesn't exist. + result = backend.search("hi", max_results=5, filter={"source": "raw"}) + assert result is None 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..5f747cc --- /dev/null +++ b/packages/contexto-py/tests/local/test_clustering.py @@ -0,0 +1,134 @@ +"""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 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..2e74e7a --- /dev/null +++ b/packages/contexto-py/tests/local/test_round_trip.py @@ -0,0 +1,155 @@ +"""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_none(self, cfg): + # Patch beam_search to raise; the empty-store guard must short-circuit + # before retrieval is invoked. + 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: + assert backend.search("anything", max_results=5) is None + 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..a20cc92 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.2.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: From 99036f742dfb3fc0fef4889f16362f49e3a1347f Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Thu, 28 May 2026 23:45:27 +0000 Subject: [PATCH 03/11] fix(contexto-py/e2e): unwrap search items + activate Hermes bind mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e/run_local_e2e.py: SearchResult.items are {"item": ..., "score": ...} wrappers per spec §5. The script read item["content"] directly, which KeyError'd after a successful real-provider search. Now unwraps and also logs the score so the rank ordering is visible. - e2e/docker-compose.hermes-local.yml: the hermes-agent repo's plugins/context_engine/contexto symlink works for the local hermes CLI but not inside a Docker image — docker build's COPY resolves the absolute symlink to a host path the container can't see, leaving a broken link at the plugin slot. Make the bind mount of src/contexto_hermes onto the plugin path active and read-only. - e2e/README.md: replace the misleading "no bind-mount needed" note with the actual constraint, plus an alternative (copy the plugin tree into the image before docker build) for users who don't want a bind mount. --- packages/contexto-py/e2e/README.md | 14 +++++++++++--- .../e2e/docker-compose.hermes-local.yml | 17 ++++++++--------- packages/contexto-py/e2e/run_local_e2e.py | 12 +++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/contexto-py/e2e/README.md b/packages/contexto-py/e2e/README.md index e1d572a..3069c50 100644 --- a/packages/contexto-py/e2e/README.md +++ b/packages/contexto-py/e2e/README.md @@ -44,9 +44,13 @@ What it verifies: ## 2. Full Hermes container (integration with the agent) -Runs the real `hermes-agent` gateway with `CONTEXTO_BACKEND=local`. The bundled -`plugins/context_engine/contexto/` is symlinked to this source tree in the -hermes-agent repo, so a fresh image build picks up the changes automatically. +Runs the real `hermes-agent` gateway with `CONTEXTO_BACKEND=local`. The +hermes-agent repo's `plugins/context_engine/contexto/` is an absolute symlink +into this source tree, which 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 works around that by bind-mounting this source onto the plugin path at +runtime, so the loader always sees the live tree. ```bash # One-time: build the hermes-agent base image (slow — Playwright + npm). @@ -59,6 +63,10 @@ export HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up ``` +If you'd rather bake the plugin into the image, copy `src/contexto_hermes` to +`hermes-agent/plugins/context_engine/contexto/` (as a real directory, not a +symlink) before `docker build`, then drop the bind mount from the compose file. + After driving a chat session that triggers `compress()`, the mindmap lands at: ``` diff --git a/packages/contexto-py/e2e/docker-compose.hermes-local.yml b/packages/contexto-py/e2e/docker-compose.hermes-local.yml index 26ea253..c756fec 100644 --- a/packages/contexto-py/e2e/docker-compose.hermes-local.yml +++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml @@ -10,15 +10,13 @@ # This wires CONTEXTO_BACKEND=local + OPENROUTER_API_KEY into the gateway, so # any compress() inside Hermes drives the local mindmap. # -# Note: the bundled `plugins/context_engine/contexto/` lives at -# `/opt/hermes/plugins/context_engine/contexto` inside the image. The -# hermes-agent repo already symlinks that path back to this source tree, so a -# fresh image build picks up v0.2.0 automatically — no bind-mount needed. -# If you want to iterate on contexto-py without rebuilding hermes-agent, mount -# the source over the image's copy: -# -# - ../../../contexto/packages/contexto-py/src/contexto_hermes:/opt/hermes/plugins/context_engine/contexto -# +# 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. services: gateway: image: hermes-agent @@ -27,6 +25,7 @@ services: network_mode: host volumes: - ${HOME}/.hermes:/opt/data + - ../src/contexto_hermes:/opt/hermes/plugins/context_engine/contexto:ro environment: - HERMES_UID=${HERMES_UID:-10000} - HERMES_GID=${HERMES_GID:-10000} diff --git a/packages/contexto-py/e2e/run_local_e2e.py b/packages/contexto-py/e2e/run_local_e2e.py index 3902976..a0772ad 100644 --- a/packages/contexto-py/e2e/run_local_e2e.py +++ b/packages/contexto-py/e2e/run_local_e2e.py @@ -140,11 +140,17 @@ def main() -> int: log.error("search returned None") return 7 log.info("search OK in %.2fs, got %d items", t1 - t0, len(result.items)) - for i, item in enumerate(result.items): - log.info(" rank %d: content[:80]=%r", i + 1, item["content"][:80]) + # 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(it["content"].lower() for it in result.items[:2]) + 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. " From c00a6377f233c2b01175800627ac60b89d5362c2 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Fri, 29 May 2026 00:01:46 +0000 Subject: [PATCH 04/11] fix(contexto-py): seed cluster-id counter from loaded state; refresh README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clustering.py: Clusterer reset its counter to 1 on every construction. After a process restart, an incremental insert against a loaded tree produced a fresh `cluster-1` under root alongside the persisted `cluster-1`. Replace the itertools.count with a plain `_next_id` int and fast-forward it past the largest `cluster-N` already in the tree on every add() that has a root. Idempotent across multiple adds. - tests/local/test_clustering.py: new TestClusterIdUniquenessAfterReload reproduces the reviewer's scenario (100 items, rebuild_interval=1000, fresh Clusterer against loaded state) and asserts unique ids across reload + multiple incremental adds. - README.md: was still remote-only — said "Only CONTEXTO_API_KEY is required" and described the remote env vars exclusively. Document both backends, the CONTEXTO_BACKEND selector, the local provider/key resolution rules, and the full CONTEXTO_LOCAL_* table so users installing 0.2.0 can enable the new backend from the package README alone. --- packages/contexto-py/README.md | 53 +++++++++++++++-- .../src/contexto_hermes/local/clustering.py | 37 +++++++++++- .../tests/local/test_clustering.py | 58 +++++++++++++++++++ 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/packages/contexto-py/README.md b/packages/contexto-py/README.md index 9e5a63a..8130b2d 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 (v0.2.0+)** — 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 ``` Then in `~/.hermes/config.yaml`: @@ -19,13 +21,26 @@ 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 +``` + ## 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) | @@ -33,7 +48,33 @@ All config is via env vars. Only `CONTEXTO_API_KEY` is required. | `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 +92,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/src/contexto_hermes/local/clustering.py b/packages/contexto-py/src/contexto_hermes/local/clustering.py index b2cfa35..575613f 100644 --- a/packages/contexto-py/src/contexto_hermes/local/clustering.py +++ b/packages/contexto-py/src/contexto_hermes/local/clustering.py @@ -8,7 +8,6 @@ from __future__ import annotations -import itertools import logging from dataclasses import dataclass from typing import Any, Iterable @@ -130,7 +129,9 @@ class Clusterer: def __init__(self, config: MindmapConfig) -> None: self._config = config - self._counter = itertools.count(1) + # 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: @@ -143,6 +144,12 @@ def add(self, state: StoreState, items: list[ConversationItem]) -> StoreState: 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 = ( @@ -403,7 +410,31 @@ def _incremental_insert(self, node: ClusterNode, item: ConversationItem) -> None break def _new_id(self) -> str: - return f"cluster-{next(self._counter)}" + 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/tests/local/test_clustering.py b/packages/contexto-py/tests/local/test_clustering.py index 5f747cc..276689d 100644 --- a/packages/contexto-py/tests/local/test_clustering.py +++ b/packages/contexto-py/tests/local/test_clustering.py @@ -132,3 +132,61 @@ def test_total_clusters_positive(self): 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) From 6b807bcaf84decd5bd6e29e51c27e84843e86a41 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Fri, 29 May 2026 00:15:52 +0000 Subject: [PATCH 05/11] fix(contexto-py/e2e): install numpy+scipy into hermes image before launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes-agent base image doesn't ship numpy or scipy. Bind-mounting the plugin source is necessary but not sufficient: on first agent request the local backend fails to construct ("No module named 'numpy'") and the gateway silently falls back to its built-in compressor. Wrap the gateway command in `sh -c` so `uv pip install` runs against the image's venv (already activated by the entrypoint) before exec'ing `gateway run`. The install is idempotent — fast no-op on restarts. Document both image gaps (plugin source + runtime deps) in the e2e README. --- packages/contexto-py/e2e/README.md | 26 ++++++++++++------- .../e2e/docker-compose.hermes-local.yml | 16 +++++++++++- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/contexto-py/e2e/README.md b/packages/contexto-py/e2e/README.md index 3069c50..8bceb03 100644 --- a/packages/contexto-py/e2e/README.md +++ b/packages/contexto-py/e2e/README.md @@ -44,13 +44,20 @@ What it verifies: ## 2. Full Hermes container (integration with the agent) -Runs the real `hermes-agent` gateway with `CONTEXTO_BACKEND=local`. The -hermes-agent repo's `plugins/context_engine/contexto/` is an absolute symlink -into this source tree, which 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 works around that by bind-mounting this source onto the plugin path at -runtime, so the loader always sees the live tree. +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). @@ -63,9 +70,10 @@ export HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose -f e2e/docker-compose.hermes-local.yml --env-file e2e/.env up ``` -If you'd rather bake the plugin into the image, copy `src/contexto_hermes` to +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) before `docker build`, then drop the bind mount from the compose file. +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: diff --git a/packages/contexto-py/e2e/docker-compose.hermes-local.yml b/packages/contexto-py/e2e/docker-compose.hermes-local.yml index c756fec..04abc22 100644 --- a/packages/contexto-py/e2e/docker-compose.hermes-local.yml +++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml @@ -17,6 +17,15 @@ # 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 `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 `gateway` are +# already on PATH and the install targets the right interpreter. services: gateway: image: hermes-agent @@ -35,4 +44,9 @@ services: # Optional tunables — defaults are sensible. # - CONTEXTO_LOCAL_SIMILARITY_THRESHOLD=0.65 # - CONTEXTO_LOCAL_REBUILD_INTERVAL=50 - command: ["gateway", "run"] + command: + - sh + - -c + - | + uv pip install --python /opt/hermes/.venv/bin/python numpy scipy \ + && exec gateway run From ed14fdd36c98b71f8d6077269fecf9b16edef76a Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Fri, 29 May 2026 00:19:07 +0000 Subject: [PATCH 06/11] docs: contexto-hermes quickstart with copy-paste install paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/contexto-hermes-quickstart.md — a focused install guide for both backends. Covers prereqs, the shared pip+symlink step, the per-backend env vars, a verify step (log grep + jq on the mindmap), the two Docker gotchas (symlinked plugin source + missing numpy/scipy in the image), and a troubleshooting table for the common failure modes. Linked from docs/SUMMARY.md. Package README points to the new doc for the copy-paste walkthrough. Also normalizes the install command in the README to `contexto-hermes-install` (the script entry point shipped in pyproject.toml) instead of `python -m contexto_hermes.install` — both work, but the script form is shorter and is what the quickstart uses. --- docs/SUMMARY.md | 1 + docs/contexto-hermes-quickstart.md | 106 +++++++++++++++++++++++++++++ packages/contexto-py/README.md | 4 +- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/contexto-hermes-quickstart.md 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..6bf8c6d --- /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** *(v0.2.0+)* | +|---|---|---| +| 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 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/packages/contexto-py/README.md b/packages/contexto-py/README.md index 8130b2d..af07a38 100644 --- a/packages/contexto-py/README.md +++ b/packages/contexto-py/README.md @@ -11,7 +11,7 @@ Two interchangeable backends: ```bash pip install contexto-hermes -python -m contexto_hermes.install # symlink into hermes-agent's plugin tree +contexto-hermes-install # symlink into hermes-agent's plugin tree ``` Then in `~/.hermes/config.yaml`: @@ -32,6 +32,8 @@ 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. Invalid, out-of-range, or NaN values fall back to the default with a `WARNING`; they never block registration. From 7e444b82ede9269925905ee466434d2fb118c815 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Fri, 29 May 2026 00:29:16 +0000 Subject: [PATCH 07/11] fix(contexto-py/e2e): call `hermes gateway run`, not `gateway run` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes-agent entrypoint wraps a bare `gateway run` as `hermes gateway run` only when `gateway` is the first container arg (its `command -v "$1"` check fails, so it falls through to `exec hermes "$@"`). Inside our `sh -c` wrapper the first arg is `sh`, which resolves on PATH — the entrypoint exec's `sh` directly and never reaches the `hermes` fallback. `gateway` isn't a standalone binary, so the container died with `sh: exec: gateway: not found`. Fix: call `hermes gateway run` explicitly inside the `sh -c`. `hermes` is on PATH because the entrypoint activates /opt/hermes/.venv before exec. Same fix applied to the Docker snippet in docs/contexto-hermes-quickstart.md. --- docs/contexto-hermes-quickstart.md | 2 +- .../contexto-py/e2e/docker-compose.hermes-local.yml | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/contexto-hermes-quickstart.md b/docs/contexto-hermes-quickstart.md index 6bf8c6d..5d23f07 100644 --- a/docs/contexto-hermes-quickstart.md +++ b/docs/contexto-hermes-quickstart.md @@ -85,7 +85,7 @@ The hermes-agent base image needs two adjustments when using the local backend: - -c - | uv pip install --python /opt/hermes/.venv/bin/python numpy scipy \ - && exec gateway run + && 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). diff --git a/packages/contexto-py/e2e/docker-compose.hermes-local.yml b/packages/contexto-py/e2e/docker-compose.hermes-local.yml index 04abc22..5439988 100644 --- a/packages/contexto-py/e2e/docker-compose.hermes-local.yml +++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml @@ -22,10 +22,13 @@ # 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 `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 `gateway` are -# already on PATH and the install targets the right interpreter. +# 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. services: gateway: image: hermes-agent @@ -49,4 +52,4 @@ services: - -c - | uv pip install --python /opt/hermes/.venv/bin/python numpy scipy \ - && exec gateway run + && exec hermes gateway run From 22f6d7413c499b34af243f1d98a34c3a80e1b8cf Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Fri, 29 May 2026 00:42:04 +0000 Subject: [PATCH 08/11] docs: top-level README covers Hermes runtime; renumber contexto-hermes to 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level README: - Quick Start now has two subsections: OpenClaw (existing) and Hermes (new — pip install + contexto-hermes-install + config.yaml + key + run). Points to docs/contexto-hermes-quickstart.md for the fully local setup. - Hero copy, "Why Contexto", "What You Get", "Who Should Use This", and the Quick Start lead now mention both runtimes instead of OpenClaw only. - Roadmap no longer lists "Local backend" — shipped in this release. Version renumber 0.2.0 -> 0.1.0: - contexto-hermes was never on PyPI as 0.1.0; the bump to 0.2.0 was internal noise. Renumber so the first public release lands as 0.1.0. - pyproject.toml, __init__.py, plugin.yaml, types.py docstring, README, test_plugin_yaml.py comment, and the v0.2.0+ marker in the quickstart all updated. Verification: 338 passed, 2 skipped. `python -m build` produces clean contexto_hermes-0.1.0.{tar.gz,whl}; `twine check` passes both. --- README.md | 40 +++++++++++++++---- docs/contexto-hermes-quickstart.md | 2 +- packages/contexto-py/README.md | 2 +- packages/contexto-py/pyproject.toml | 2 +- .../src/contexto_hermes/__init__.py | 2 +- .../src/contexto_hermes/plugin.yaml | 2 +- .../contexto-py/src/contexto_hermes/types.py | 3 +- .../contexto-py/tests/test_plugin_yaml.py | 2 +- 8 files changed, 39 insertions(+), 16 deletions(-) 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/contexto-hermes-quickstart.md b/docs/contexto-hermes-quickstart.md index 5d23f07..cbb4ca9 100644 --- a/docs/contexto-hermes-quickstart.md +++ b/docs/contexto-hermes-quickstart.md @@ -2,7 +2,7 @@ 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** *(v0.2.0+)* | +| | **Remote** *(default)* | **Local** | |---|---|---| | Storage | Contexto cloud | On-disk JSON (`$HERMES_HOME/data/contexto/mindmap.json`) | | Embeddings + summarization | Contexto-hosted | Your OpenAI **or** OpenRouter key | diff --git a/packages/contexto-py/README.md b/packages/contexto-py/README.md index af07a38..68da994 100644 --- a/packages/contexto-py/README.md +++ b/packages/contexto-py/README.md @@ -5,7 +5,7 @@ Two interchangeable backends: - **Remote (default)** — ingestion + mindmap retrieval against `api.getcontexto.com`. -- **Local (v0.2.0+)** — pure-Python pipeline; embeddings + summarization call the user's own OpenAI/OpenRouter key; state lives on disk as a single JSON file. +- **Local** — pure-Python pipeline; embeddings + summarization call the user's own OpenAI/OpenRouter key; state lives on disk as a single JSON file. ## Install diff --git a/packages/contexto-py/pyproject.toml b/packages/contexto-py/pyproject.toml index ac70d30..ec32fc8 100644 --- a/packages/contexto-py/pyproject.toml +++ b/packages/contexto-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "contexto-hermes" -version = "0.2.0" +version = "0.1.0" description = "Contexto context engine plugin for hermes-agent — remote or local mindmap retrieval" readme = "README.md" license = { text = "MIT" } diff --git a/packages/contexto-py/src/contexto_hermes/__init__.py b/packages/contexto-py/src/contexto_hermes/__init__.py index 51450c0..1b306c2 100644 --- a/packages/contexto-py/src/contexto_hermes/__init__.py +++ b/packages/contexto-py/src/contexto_hermes/__init__.py @@ -20,7 +20,7 @@ from .engine import ContextoEngine __all__ = ["ContextoEngine", "register", "__compatible_contexto_api__"] -__version__ = "0.2.0" +__version__ = "0.1.0" # Pinned `api.getcontexto.com` schema version compatible with this release. # Bumped independently from `@ekai/contexto`'s semver. diff --git a/packages/contexto-py/src/contexto_hermes/plugin.yaml b/packages/contexto-py/src/contexto_hermes/plugin.yaml index 4bc6e4c..2882ca0 100644 --- a/packages/contexto-py/src/contexto_hermes/plugin.yaml +++ b/packages/contexto-py/src/contexto_hermes/plugin.yaml @@ -1,6 +1,6 @@ name: contexto description: Contexto context engine — remote (api.getcontexto.com) or local (on-disk mindmap) -version: 0.2.0 +version: 0.1.0 # Auth and tunables are env-var driven. No YAML config block. env_vars: # Backend selector diff --git a/packages/contexto-py/src/contexto_hermes/types.py b/packages/contexto-py/src/contexto_hermes/types.py index 13a5dac..8a52b93 100644 --- a/packages/contexto-py/src/contexto_hermes/types.py +++ b/packages/contexto-py/src/contexto_hermes/types.py @@ -156,8 +156,7 @@ class SearchResult: """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`. (Type changed in - v0.2.0 from `list[dict]` to `list[list[str]]` to match the local backend.) + node in beam search. Matches TS `ScoredQueryResult.paths`. """ items: list[dict[str, Any]] = field(default_factory=list) diff --git a/packages/contexto-py/tests/test_plugin_yaml.py b/packages/contexto-py/tests/test_plugin_yaml.py index a20cc92..6ff141a 100644 --- a/packages/contexto-py/tests/test_plugin_yaml.py +++ b/packages/contexto-py/tests/test_plugin_yaml.py @@ -33,7 +33,7 @@ def test_required_env_vars_present() -> None: def test_api_key_not_unconditionally_required() -> None: - # As of v0.2.0, CONTEXTO_API_KEY is only needed when CONTEXTO_BACKEND=remote; + # 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") From 03ba69700986881d47d9b9d6c51e8c7f5ae64ee0 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:39:06 +0000 Subject: [PATCH 09/11] fix(contexto-py): return empty SearchResult for no-hit searches, not None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None is now reserved for failures (embed error, unexpected exception), matching RemoteBackend — so contexto_search reports ok-with-empty-items instead of a misleading 'backend unavailable' degraded status when the store is empty or nothing clears min_score. Also dedupe the item-content construction in _build_item (identical to the embed input). --- ...23-contexto-hermes-local-backend-design.md | 10 +++++----- .../src/contexto_hermes/local/backend.py | 19 +++++++++---------- .../contexto-py/tests/local/test_backend.py | 14 +++++++++----- .../tests/local/test_round_trip.py | 9 ++++++--- 4 files changed, 29 insertions(+), 23 deletions(-) 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 index 771d558..4496f16 100644 --- a/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md +++ b/docs/specs/2026-05-23-contexto-hermes-local-backend-design.md @@ -97,7 +97,7 @@ Same duck-typed contract as `RemoteBackend`. Mirrors TS `LocalBackend` in `packa - `__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` on failure OR when no items survive filtering (matches TS). 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` @@ -307,9 +307,9 @@ Explicit `CONTEXTO_LOCAL_PROVIDER` wins and requires its matching key. Explicit 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`. Return `None` when the result list is empty. +4. Return a `SearchResult` with `items` and `paths` (possibly empty). -`search` returns `None` when the store is empty (no cluster tree yet), when no items survive filtering, or on any failure. The empty-store guard short-circuits before retrieval is invoked. +`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 @@ -323,7 +323,7 @@ Explicit `CONTEXTO_LOCAL_PROVIDER` wins and requires its matching key. Explicit | `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`: `None`. | (not logged) | +| 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. @@ -332,7 +332,7 @@ No `ApiError` / `on_error` / `on_success` callbacks in v1 of the local backend. 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 `None`. +- **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. diff --git a/packages/contexto-py/src/contexto_hermes/local/backend.py b/packages/contexto-py/src/contexto_hermes/local/backend.py index fda7576..b4a403f 100644 --- a/packages/contexto-py/src/contexto_hermes/local/backend.py +++ b/packages/contexto-py/src/contexto_hermes/local/backend.py @@ -1,7 +1,10 @@ """LocalBackend orchestrator. Same duck-typed contract as RemoteBackend. `ingest` + `search` **never raise.** All errors land here, converted to -`False` / `None`. +`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 @@ -162,11 +165,8 @@ def _build_item( summary: EpisodeSummary, embedding: list[float], ) -> ConversationItem: - content_parts = [summary.summary] - if summary.key_findings: - findings = "\n".join(f"- {f}" for f in summary.key_findings) - content_parts.append(f"\nKey findings:\n{findings}") - content = "\n".join(content_parts) + # Stored content is exactly what was embedded. + content = self._embed_input(summary) metadata: dict[str, Any] = { "source": "summary", @@ -198,7 +198,9 @@ def _search_inner( ) -> SearchResult | None: state = self._load_state() if state.root is None or state.stats.total_items == 0: - return None + # 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) @@ -216,9 +218,6 @@ def _search_inner( min_score=min_score, ) - if not result.scored: - return None - # 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` diff --git a/packages/contexto-py/tests/local/test_backend.py b/packages/contexto-py/tests/local/test_backend.py index 5fe2362..5ab054f 100644 --- a/packages/contexto-py/tests/local/test_backend.py +++ b/packages/contexto-py/tests/local/test_backend.py @@ -173,9 +173,12 @@ 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_None(self, base_config): + 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) - assert backend.search("anything", max_results=5) is None + 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. @@ -249,9 +252,10 @@ def test_items_are_wrapped_with_score(self, base_config): scores = [e["score"] for e in result.items] assert scores == sorted(scores, reverse=True) - def test_returns_none_when_no_results_pass_filter(self, base_config): + 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. + # 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 None + assert result is not None + assert result.items == [] diff --git a/packages/contexto-py/tests/local/test_round_trip.py b/packages/contexto-py/tests/local/test_round_trip.py index 2e74e7a..ad78299 100644 --- a/packages/contexto-py/tests/local/test_round_trip.py +++ b/packages/contexto-py/tests/local/test_round_trip.py @@ -138,9 +138,10 @@ def test_persisted_disk_schema(self, cfg): class TestEmptyStoreGuard: - def test_empty_store_returns_none(self, cfg): + 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. + # 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 @@ -150,6 +151,8 @@ def boom(*a, **kw): mod.beam_search = boom try: - assert backend.search("anything", max_results=5) is None + result = backend.search("anything", max_results=5) + assert result is not None + assert result.items == [] and result.paths == [] finally: mod.beam_search = original From b3698b8ecd9b2cb0cce828e6b033696f53ada318 Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:39:14 +0000 Subject: [PATCH 10/11] fix(contexto-py): lower local-mode CONTEXTO_MIN_SCORE default to 0.35 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local summaries cover an entire drop slice, so query-to-summary cosine for relevant hits lands in the 0.35-0.45 band — verified on a Hermes container against real OpenRouter where a relevant item scored 0.444 and was filtered by the remote default of 0.45. Remote default unchanged. --- packages/contexto-py/README.md | 2 +- packages/contexto-py/src/contexto_hermes/plugin.yaml | 1 + packages/contexto-py/src/contexto_hermes/types.py | 5 ++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/contexto-py/README.md b/packages/contexto-py/README.md index 68da994..3197169 100644 --- a/packages/contexto-py/README.md +++ b/packages/contexto-py/README.md @@ -45,7 +45,7 @@ All config is via env vars. Invalid, out-of-range, or NaN values fall back to th | `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 | diff --git a/packages/contexto-py/src/contexto_hermes/plugin.yaml b/packages/contexto-py/src/contexto_hermes/plugin.yaml index 2882ca0..43c38f6 100644 --- a/packages/contexto-py/src/contexto_hermes/plugin.yaml +++ b/packages/contexto-py/src/contexto_hermes/plugin.yaml @@ -15,6 +15,7 @@ env_vars: 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 diff --git a/packages/contexto-py/src/contexto_hermes/types.py b/packages/contexto-py/src/contexto_hermes/types.py index 8a52b93..d514d03 100644 --- a/packages/contexto-py/src/contexto_hermes/types.py +++ b/packages/contexto-py/src/contexto_hermes/types.py @@ -135,7 +135,10 @@ def local_mode_defaults(cls) -> "ContextoConfig": 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), - min_score=_env_float("CONTEXTO_MIN_SCORE", default=0.45, minimum=0.0, maximum=1.0), + # 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), From 72c0ac0ad29f086362669b9171cf4a99c70fe82d Mon Sep 17 00:00:00 2001 From: shashank <13179671+sm86@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:39:14 +0000 Subject: [PATCH 11/11] chore(contexto-py): clustering input guards + safer e2e compose default - Reject zero-vector embeddings before scipy linkage (cosine distance is NaN there, which the NaN->0 clamp would silently merge into one cluster); clamp tiny negative distances too and make the comment match the code. - Drop unused typing imports. - Default the hermes-local compose data dir to /tmp/hermes-contexto-e2e instead of the live ~/.hermes; opt in via CONTEXTO_E2E_HOME. Avoids two gateways sharing state.db/gateway.lock under host networking. --- packages/contexto-py/e2e/README.md | 14 ++++++++++++-- .../e2e/docker-compose.hermes-local.yml | 7 ++++++- .../src/contexto_hermes/local/clustering.py | 11 ++++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/contexto-py/e2e/README.md b/packages/contexto-py/e2e/README.md index 8bceb03..646bca3 100644 --- a/packages/contexto-py/e2e/README.md +++ b/packages/contexto-py/e2e/README.md @@ -70,6 +70,16 @@ 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 @@ -78,7 +88,7 @@ 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: ``` -~/.hermes/data/contexto/mindmap.json +${CONTEXTO_E2E_HOME:-/tmp/hermes-contexto-e2e}/data/contexto/mindmap.json ``` (Inside the container that resolves to `/opt/data/data/contexto/mindmap.json` @@ -87,7 +97,7 @@ via `$HERMES_HOME`.) Quick check: ```bash -jq '.version, .stats' ~/.hermes/data/contexto/mindmap.json +jq '.version, .stats' "${CONTEXTO_E2E_HOME:-/tmp/hermes-contexto-e2e}/data/contexto/mindmap.json" ``` ## Files in this directory diff --git a/packages/contexto-py/e2e/docker-compose.hermes-local.yml b/packages/contexto-py/e2e/docker-compose.hermes-local.yml index 5439988..9abdfaa 100644 --- a/packages/contexto-py/e2e/docker-compose.hermes-local.yml +++ b/packages/contexto-py/e2e/docker-compose.hermes-local.yml @@ -29,6 +29,11 @@ # 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 @@ -36,7 +41,7 @@ services: restart: "no" network_mode: host volumes: - - ${HOME}/.hermes:/opt/data + - ${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} diff --git a/packages/contexto-py/src/contexto_hermes/local/clustering.py b/packages/contexto-py/src/contexto_hermes/local/clustering.py index 575613f..b15e33c 100644 --- a/packages/contexto-py/src/contexto_hermes/local/clustering.py +++ b/packages/contexto-py/src/contexto_hermes/local/clustering.py @@ -10,7 +10,6 @@ import logging from dataclasses import dataclass -from typing import Any, Iterable import numpy as np from scipy.cluster.hierarchy import linkage @@ -234,9 +233,15 @@ def _build(self, items: list[ConversationItem]) -> ClusterNode: 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") - # scipy may produce tiny negatives for identical points; clamp to 0. - Z = np.where(np.isnan(Z), 0.0, Z) + # 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