diff --git a/CHANGELOG.md b/CHANGELOG.md index 8023df2d..5aef7764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Changed +- ``kb.list_*`` JSONL/MCP responses now use a dict envelope + ``{"items": [...], "_meta": {...}}`` instead of a bare list. A one-release + deprecation note lives at ``_meta.deprecation``; read ``result.items`` instead + of treating ``result`` as the list. When the KB has recent approved claims, + ``_meta.vouch_hot_memory`` carries the same recency sidebar as other read + tools (#225). +- ``kb.capabilities`` advertises the hot-memory contract under ``hot_memory`` + (sidebar key, list-envelope flag, covered method list). + ### Docs - example KBs now carry their own screenshots: `examples/README.md` and the `tiny/` + `decision-log/` READMEs embed terminal renders of `vouch status`, @@ -50,6 +60,12 @@ All notable changes to vouch are documented here. Format follows as reviewer; a PR opens only when the repo's own test gate is green and the reviewer signs off. A sibling tool — it never writes to the KB or the review gate. Paired with the `auto-pr` skill. +- ``_meta.vouch_hot_memory`` on every primary read-side ``kb.*`` response + (``kb.search``, ``kb.context``, ``kb.read_*``, ``kb.list_*``): a TTL-cached + sidebar of recently approved claims, query-biased where the tool has a + natural anchor (entity name/aliases, page title/tags, claim text, search + query). ``kb.list_pending`` uses recency only. Meta-tools, write paths, and + lifecycle ops are excluded by design (#225). - typed page kinds (#234): a KB can declare extra page kinds in `.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index b986604e..aad5f3f0 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -12,6 +12,12 @@ "title": "Context Engines", "type": "array" }, + "hot_memory": { + "additionalProperties": true, + "description": "Hot-memory sidebar contract on read-side kb.* responses", + "title": "Hot Memory", + "type": "object" + }, "knowledge_capability": { "additionalProperties": true, "title": "Knowledge Capability", diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 39872ade..b1f0755a 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -8,6 +8,7 @@ from __future__ import annotations from . import __version__ +from . import hot_memory as hot_mod from .models import Capabilities from .openclaw.context_engine import describe_engine @@ -94,4 +95,9 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + hot_memory={ + "sidebar_key": "vouch_hot_memory", + "list_envelope": True, + "covered_methods": sorted(hot_mod.HOT_MEMORY_COVERED), + }, ) diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py index 1af9c588..3e00f73e 100644 --- a/src/vouch/hot_memory.py +++ b/src/vouch/hot_memory.py @@ -1,14 +1,26 @@ -"""Per-session hot memory — task query and salience snapshots for push context. +"""Hot memory — session watch state and read-side response sidebars. -Tracks what the active session is working on and the last relevance scores -seen for approved claims. ``volunteer_context`` diffs snapshots to decide when -a claim newly crosses the confidence threshold. +Two concerns live here: + +1. **Session registry** — tracks what the active session is working on and the + last relevance scores seen for approved claims. ``volunteer_context`` diffs + snapshots to decide when a claim newly crosses the confidence threshold. + +2. **Response sidebar** — inspired by gbrain's ``_meta.brain_hot_memory`` + pattern: read-side ``kb.*`` responses carry a small sidebar of recently + approved claims so the agent doesn't re-query for "what just changed?" + between turns. Strictly read-side; TTL-cached in-process. """ from __future__ import annotations import threading +import time from dataclasses import dataclass, field +from typing import Any + +from .models import ClaimStatus, Entity, Page +from .storage import KBStore @dataclass @@ -87,3 +99,266 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non mem.volunteered.add(claim_id) mem.last_push_at = pushed_at mem.push_count += 1 + + +# === response sidebar (gbrain ``_meta.brain_hot_memory`` pattern) ========= + + +DEFAULT_TTL_SECONDS = 30.0 +DEFAULT_LIMIT = 5 +DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 3600 # 1 week +TEXT_PREVIEW_CHARS = 200 + +LIST_ENVELOPE_DEPRECATION: dict[str, str] = { + "message": ( + "kb.list_* responses now use a dict envelope with an items key; " + "reading the flat list at result directly is deprecated" + ), + "migration": "use result.items instead of result when result is a list", + "remove_in": "0.3.0", +} + +# kb.* methods that attach ``_meta.vouch_hot_memory`` on read responses. +HOT_MEMORY_COVERED: frozenset[str] = frozenset({ + "kb.search", + "kb.context", + "kb.read_page", + "kb.read_claim", + "kb.read_entity", + "kb.read_relation", + "kb.list_pages", + "kb.list_claims", + "kb.list_entities", + "kb.list_relations", + "kb.list_sources", + "kb.list_pending", +}) + +# Explicit exclusions for ``test_hot_memory_universal_coverage``. +HOT_MEMORY_EXCLUDED: dict[str, str] = { + "kb.capabilities": "meta-tool — no KB payload to decorate", + "kb.status": "meta-tool — health summary only", + "kb.stats": "aggregates — sidebar would duplicate counts", + "kb.neighbors": "graph slice — out of scope for recency sidebar", + "kb.synthesize": "answer-mode prose — sidebar adds noise", + "kb.register_source": "write path — review gate", + "kb.register_source_from_path": "write path — review gate", + "kb.propose_claim": "write path — review gate", + "kb.propose_page": "write path — review gate", + "kb.propose_entity": "write path — review gate", + "kb.propose_relation": "write path — review gate", + "kb.approve": "lifecycle — mutates durable state", + "kb.reject": "lifecycle — mutates durable state", + "kb.reject_extracted": "lifecycle — mutates durable state", + "kb.expire": "lifecycle — mutates durable state", + "kb.supersede": "lifecycle — mutates durable state", + "kb.contradict": "lifecycle — mutates durable state", + "kb.archive": "lifecycle — mutates durable state", + "kb.confirm": "lifecycle — mutates durable state", + "kb.cite": "lifecycle — mutates durable state", + "kb.source_verify": "write path — verification intake", + "kb.session_start": "session control — not a KB read", + "kb.session_end": "session control — not a KB read", + "kb.volunteer_context": "push channel — already surfaces hot claims", + "kb.crystallize": "write path — proposal intake", + "kb.index_rebuild": "maintenance — mutates derived index", + "kb.lint": "diagnostics — no claim payload", + "kb.doctor": "diagnostics — no claim payload", + "kb.export": "bundle write — not a read response", + "kb.export_check": "preflight — no claim sidebar needed", + "kb.import_check": "preflight — no claim sidebar needed", + "kb.import_apply": "mutation — applies bundle", + "kb.audit": "event log — different shape from claim reads", + "kb.reindex_embeddings": "maintenance — mutates derived index", + "kb.dedup_scan": "analysis — not a standard read", + "kb.eval_embeddings": "benchmark — not a standard read", + "kb.embeddings_stats": "index stats — no claim payload", + "kb.why": "provenance trace — self-contained", + "kb.trace": "provenance trace — self-contained", + "kb.impact": "graph impact — self-contained", + "kb.graph_export": "bulk export — sidebar too large", + "kb.provenance_rebuild": "maintenance — mutates derived state", +} + + +@dataclass(frozen=True) +class _CacheKey: + kb_dir: str + query_norm: str + limit: int + max_age_seconds: int + + +_SIDEBAR_CACHE: dict[_CacheKey, tuple[float, list[dict[str, Any]]]] = {} + + +def reset_sidebar_cache() -> None: + """Drop every sidebar cache entry. Tests call this between cases.""" + _SIDEBAR_CACHE.clear() + + +def reset_cache() -> None: + """Alias for tests that clear the sidebar TTL cache.""" + reset_sidebar_cache() + + +def _normalise_query(query: str | None) -> str: + if not query: + return "" + return " ".join(query.lower().split()) + + +def _preview(text: str) -> str: + flat = " ".join(text.strip().split()) + if len(flat) <= TEXT_PREVIEW_CHARS: + return flat + return flat[: TEXT_PREVIEW_CHARS - 1] + "…" + + +def _is_active(status: ClaimStatus) -> bool: + return status in {ClaimStatus.WORKING, ClaimStatus.STABLE, ClaimStatus.CONTESTED} + + +def query_bias_for_page(page: Page) -> str: + """Bias hot-memory toward page title and tags.""" + return " ".join([page.title, *page.tags]) + + +def query_bias_for_entity(entity: Entity) -> str: + """Bias hot-memory toward entity name and aliases.""" + return " ".join([entity.name, *entity.aliases]) + + +def compute_hot_memory( + store: KBStore, + *, + query: str | None = None, + limit: int = DEFAULT_LIMIT, + exclude_ids: list[str] | None = None, + max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS, + ttl_seconds: float = DEFAULT_TTL_SECONDS, + now: float | None = None, +) -> list[dict[str, Any]]: + """Return up to ``limit`` recently-approved claims relevant to ``query``.""" + if limit <= 0: + return [] + + now = time.monotonic() if now is None else now + query_norm = _normalise_query(query) + key = _CacheKey( + kb_dir=str(store.kb_dir), + query_norm=query_norm, + limit=limit, + max_age_seconds=max_age_seconds, + ) + cached = _SIDEBAR_CACHE.get(key) + if cached is not None and (now - cached[0]) < ttl_seconds: + rows = cached[1] + else: + rows = _compute_sidebar(store, query_norm, limit, max_age_seconds) + _SIDEBAR_CACHE[key] = (now, rows) + + if not exclude_ids: + return list(rows) + excluded = set(exclude_ids) + return [r for r in rows if r["id"] not in excluded] + + +def _matches_query(query_norm: str, text_lower: str) -> bool: + if not query_norm: + return False + if query_norm in text_lower: + return True + return any( + len(token) >= 3 and token in text_lower for token in query_norm.split() + ) + + +def _compute_sidebar( + store: KBStore, + query_norm: str, + limit: int, + max_age_seconds: int, +) -> list[dict[str, Any]]: + from datetime import UTC, datetime + + try: + claims = store.list_claims() + except Exception: + return [] + + cutoff = datetime.now(UTC).timestamp() - max_age_seconds + candidates: list[tuple[float, datetime, Any, str]] = [] + + for c in claims: + if not _is_active(c.status): + continue + ts_dt = c.last_confirmed_at or c.updated_at + ts = ts_dt.timestamp() + if ts < cutoff: + continue + text_lower = c.text.lower() + matches_query = _matches_query(query_norm, text_lower) + score = ts + (3600.0 if matches_query else 0.0) + why = "recent+match" if matches_query else "recent" + candidates.append((score, ts_dt, c, why)) + + candidates.sort(key=lambda row: row[0], reverse=True) + + out: list[dict[str, Any]] = [] + for _score, ts_dt, c, why in candidates[:limit]: + out.append({ + "id": c.id, + "text": _preview(c.text), + "type": c.type.value, + "status": c.status.value, + "citations": list(c.evidence), + "approved_by": c.approved_by, + "approved_at": ts_dt.isoformat(timespec="seconds"), + "why_hot": why, + }) + return out + + +def attach_hot_memory( + result: Any, + store: KBStore, + *, + query: str | None = None, + limit: int = DEFAULT_LIMIT, + exclude_ids: list[str] | None = None, + list_envelope: bool = False, +) -> Any: + """Attach ``_meta.vouch_hot_memory`` to *result* when the sidebar is non-empty. + + When *list_envelope* is true and *result* is a list, always wrap as + ``{"items": [...], "_meta": {...}}`` and include a one-release-cycle + deprecation note for JSONL clients that expected a flat list. + """ + sidebar = compute_hot_memory( + store, query=query, limit=limit, exclude_ids=exclude_ids, + ) + + if isinstance(result, list) and list_envelope: + meta: dict[str, Any] = {"deprecation": dict(LIST_ENVELOPE_DEPRECATION)} + if sidebar: + meta["vouch_hot_memory"] = sidebar + return {"items": result, "_meta": meta} + + if not sidebar: + return result + + meta = {"vouch_hot_memory": sidebar} + + if isinstance(result, dict): + existing_meta = result.get("_meta") + if isinstance(existing_meta, dict): + existing_meta.update(meta) + else: + result["_meta"] = meta + return result + + if isinstance(result, list): + return {"items": result, "_meta": meta} + + return result diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index ec738c6b..0fbfb437 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -29,6 +29,7 @@ import yaml from . import audit, bundle, health, volunteer_context +from . import hot_memory as hot_mod from . import lifecycle as life from . import salience as salience_mod from . import sessions as sess_mod @@ -152,14 +153,18 @@ def _h_search(p: dict) -> dict: used = "hybrid" scoped = filter_hits(s, hits, viewer, limit=limit) - return { + hits_list = [ + {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} + for k, i, sn, sc in scoped + ] + result: dict[str, Any] = { "backend": used, "viewer": {"project": viewer.project, "agent": viewer.agent}, - "hits": [ - {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} - for k, i, sn, sc in scoped - ], + "hits": hits_list, } + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, s, query=q, exclude_ids=[str(hit["id"]) for hit in hits_list], + ) def _load_cfg(store: KBStore) -> dict: @@ -206,7 +211,12 @@ def _h_context(p: dict) -> dict: graph_limit=int(p.get("graph_limit", 20)), graph_rel_types=p.get("graph_rel_types"), ) - return salience_mod.attach_salience(result, store, session_id, cfg) + result = salience_mod.attach_salience(result, store, session_id, cfg) + pack_items = result.get("items") if isinstance(result, dict) else None + exclude = [it.get("id") for it in pack_items] if isinstance(pack_items, list) else [] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=query, exclude_ids=[i for i in exclude if i], + ) def _h_synthesize(p: dict) -> dict: @@ -220,57 +230,100 @@ def _h_synthesize(p: dict) -> dict: def _h_read_page(p: dict) -> dict: - return _store().get_page(p["page_id"]).model_dump(mode="json") + store = _store() + page = store.get_page(p["page_id"]) + result = page.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=hot_mod.query_bias_for_page(page), + ) def _h_read_claim(p: dict) -> dict: - return _store().get_claim(p["claim_id"]).model_dump(mode="json") + store = _store() + claim = store.get_claim(p["claim_id"]) + result = claim.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=claim.text, exclude_ids=[claim.id], + ) def _h_read_entity(p: dict) -> dict: - return _store().get_entity(p["entity_id"]).model_dump(mode="json") + store = _store() + entity = store.get_entity(p["entity_id"]) + result = entity.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=hot_mod.query_bias_for_entity(entity), + ) def _h_read_relation(p: dict) -> dict: - return _store().get_relation(p["relation_id"]).model_dump(mode="json") + store = _store() + relation = store.get_relation(p["relation_id"]) + result = relation.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=None, + ) -def _h_list_pages(_: dict) -> list[dict]: - return [p.model_dump(mode="json") for p in _store().list_pages()] +def _h_list_pages(_: dict) -> dict: + store = _store() + items = [p.model_dump(mode="json") for p in store.list_pages()] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) -def _h_list_claims(p: dict) -> list[dict]: - cs = _store().list_claims() +def _h_list_claims(p: dict) -> dict: + store = _store() + cs = store.list_claims() if p.get("status"): cs = [c for c in cs if c.status.value == p["status"]] - return [c.model_dump(mode="json") for c in cs] + items = [c.model_dump(mode="json") for c in cs] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) -def _h_list_entities(p: dict) -> list[dict]: - es = _store().list_entities() +def _h_list_entities(p: dict) -> dict: + store = _store() + es = store.list_entities() if p.get("entity_type"): es = [e for e in es if e.type.value == p["entity_type"]] - return [e.model_dump(mode="json") for e in es] + items = [e.model_dump(mode="json") for e in es] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) -def _h_list_relations(p: dict) -> list[dict]: - s = _store() - rels = s.list_relations() +def _h_list_relations(p: dict) -> dict: + store = _store() + rels = store.list_relations() node = p.get("node_id") if node: rels = [r for r in rels if r.source == node or r.target == node] - return [r.model_dump(mode="json") for r in rels] + items = [r.model_dump(mode="json") for r in rels] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) -def _h_list_sources(_: dict) -> list[dict]: - return [s.model_dump(mode="json") for s in _store().list_sources()] +def _h_list_sources(_: dict) -> dict: + store = _store() + items = [s.model_dump(mode="json") for s in store.list_sources()] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) -def _h_list_pending(_: dict) -> list[dict]: - return [ +def _h_list_pending(_: dict) -> dict: + store = _store() + items = [ p.model_dump(mode="json") - for p in _store().list_proposals(ProposalStatus.PENDING) + for p in store.list_proposals(ProposalStatus.PENDING) ] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) def _h_register_source(p: dict) -> dict: diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa4..5d7ced55 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -456,3 +456,7 @@ class Capabilities(BaseModel): default_factory=list, description="OpenClaw context engines exposed (see openclaw.plugin.json)", ) + hot_memory: dict[str, Any] = Field( + default_factory=dict, + description="Hot-memory sidebar contract on read-side kb.* responses", + ) diff --git a/src/vouch/server.py b/src/vouch/server.py index 6443b975..69779e14 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -20,6 +20,7 @@ from mcp.server.fastmcp import FastMCP from . import audit, bundle, health, volunteer_context +from . import hot_memory as hot_mod from . import lifecycle as life from . import salience as salience_mod from . import sessions as sess_mod @@ -123,14 +124,19 @@ def kb_search( def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any]: scoped = filter_hits(store, h, viewer, limit=limit) - return { + hits_list: list[dict[str, Any]] = [ + {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} + for k, i, sn, sc in scoped + ] + result: dict[str, Any] = { "backend": used, "viewer": {"project": viewer.project, "agent": viewer.agent}, - "hits": [ - {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} - for k, i, sn, sc in scoped - ], + "hits": hits_list, } + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=query, + exclude_ids=[str(hit["id"]) for hit in hits_list], + ) if backend in ("auto", "embedding"): hits = index_db.search_semantic( @@ -231,7 +237,12 @@ def kb_context( project=project, agent=agent, expand_graph=expand_graph, graph_depth=graph_depth, graph_limit=graph_limit, ) - return salience_mod.attach_salience(result, store, session_id, cfg) + result = salience_mod.attach_salience(result, store, session_id, cfg) + pack_items = result.get("items") if isinstance(result, dict) else None + exclude = [it.get("id") for it in pack_items] if isinstance(pack_items, list) else [] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=task, exclude_ids=[i for i in exclude if i], + ) @mcp.tool() @@ -252,88 +263,131 @@ def kb_synthesize( @mcp.tool() def kb_read_page(page_id: str) -> dict[str, Any]: """Return a page (title, body, claim ids).""" + store = _store() try: - return _store().get_page(page_id).model_dump(mode="json") + page = store.get_page(page_id) except ArtifactNotFoundError as e: raise ValueError(str(e)) from e + result = page.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=hot_mod.query_bias_for_page(page), + ) @mcp.tool() def kb_read_claim(claim_id: str) -> dict[str, Any]: """Return a claim with its citation list.""" + store = _store() try: - return _store().get_claim(claim_id).model_dump(mode="json") + claim = store.get_claim(claim_id) except ArtifactNotFoundError as e: raise ValueError(str(e)) from e + result = claim.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=claim.text, exclude_ids=[claim.id], + ) @mcp.tool() def kb_read_entity(entity_id: str) -> dict[str, Any]: + store = _store() try: - return _store().get_entity(entity_id).model_dump(mode="json") + entity = store.get_entity(entity_id) except ArtifactNotFoundError as e: raise ValueError(str(e)) from e + result = entity.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=hot_mod.query_bias_for_entity(entity), + ) @mcp.tool() def kb_read_relation(relation_id: str) -> dict[str, Any]: + store = _store() try: - return _store().get_relation(relation_id).model_dump(mode="json") + relation = store.get_relation(relation_id) except ArtifactNotFoundError as e: raise ValueError(str(e)) from e + result = relation.model_dump(mode="json") + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + result, store, query=None, + ) @mcp.tool() -def kb_list_pages() -> list[dict[str, Any]]: - return [ +def kb_list_pages() -> dict[str, Any]: + store = _store() + items = [ {"id": p.id, "title": p.title, "type": p.type, "tags": p.tags} - for p in _store().list_pages() + for p in store.list_pages() ] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) @mcp.tool() -def kb_list_claims(status: str | None = None) -> list[dict[str, Any]]: +def kb_list_claims(status: str | None = None) -> dict[str, Any]: """List all claims, optionally filtered by status.""" - claims = _store().list_claims() + store = _store() + claims = store.list_claims() if status: claims = [c for c in claims if c.status.value == status] - return [c.model_dump(mode="json") for c in claims] + items = [c.model_dump(mode="json") for c in claims] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) @mcp.tool() -def kb_list_entities(entity_type: str | None = None) -> list[dict[str, Any]]: - entities = _store().list_entities() +def kb_list_entities(entity_type: str | None = None) -> dict[str, Any]: + store = _store() + entities = store.list_entities() if entity_type: entities = [e for e in entities if e.type.value == entity_type] - return [e.model_dump(mode="json") for e in entities] + items = [e.model_dump(mode="json") for e in entities] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) @mcp.tool() -def kb_list_relations(node_id: str | None = None) -> list[dict[str, Any]]: +def kb_list_relations(node_id: str | None = None) -> dict[str, Any]: """List all relations; if node_id is given, only edges touching it.""" store = _store() rels = store.list_relations() if node_id: rels = [r for r in rels if r.source == node_id or r.target == node_id] - return [r.model_dump(mode="json") for r in rels] + items = [r.model_dump(mode="json") for r in rels] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) @mcp.tool() -def kb_list_sources() -> list[dict[str, Any]]: - return [ +def kb_list_sources() -> dict[str, Any]: + store = _store() + items = [ {"id": s.id, "title": s.title, "type": s.type.value, "locator": s.locator, "byte_size": s.byte_size} - for s in _store().list_sources() + for s in store.list_sources() ] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) @mcp.tool() -def kb_list_pending() -> list[dict[str, Any]]: +def kb_list_pending() -> dict[str, Any]: """List proposals awaiting human review.""" - return [ + store = _store() + items = [ p.model_dump(mode="json") - for p in _store().list_proposals(ProposalStatus.PENDING) + for p in store.list_proposals(ProposalStatus.PENDING) ] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) # === write tools — gated (produce proposals) ============================= diff --git a/tests/test_hot_memory.py b/tests/test_hot_memory.py new file mode 100644 index 00000000..102444aa --- /dev/null +++ b/tests/test_hot_memory.py @@ -0,0 +1,346 @@ +"""Hot-memory sidebar — gbrain's ``_meta.brain_hot_memory`` pattern.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from vouch import hot_memory as hot_mod +from vouch.capabilities import METHODS +from vouch.jsonl_server import handle_request +from vouch.models import ClaimStatus, Entity, EntityType, Page, PageType +from vouch.proposals import approve, propose_claim +from vouch.storage import KBStore + + +@pytest.fixture(autouse=True) +def _clear_cache(): + hot_mod.reset_sidebar_cache() + yield + hot_mod.reset_sidebar_cache() + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _approved_claim( + store: KBStore, text: str, *, approver: str = "reviewer", +) -> str: + src = store.put_source(b"evidence-bytes") + pr = propose_claim(store, text=text, evidence=[src.id], proposed_by="agent-A") + artifact = approve(store, pr.id, approved_by=approver) + return artifact.id # type: ignore[union-attr] + + +# --- core sidebar shape ----------------------------------------------------- + + +def test_recently_approved_claim_appears(store: KBStore) -> None: + _approved_claim(store, "the sky is blue today") + rows = hot_mod.compute_hot_memory(store, limit=5) + assert len(rows) == 1 + row = rows[0] + assert row["text"] == "the sky is blue today" + assert row["status"] == ClaimStatus.WORKING.value + assert row["why_hot"] == "recent" + assert "approved_at" in row + + +def test_empty_kb_yields_empty_list(store: KBStore) -> None: + assert hot_mod.compute_hot_memory(store, limit=5) == [] + + +def test_limit_caps_result_size(store: KBStore) -> None: + for i in range(5): + _approved_claim(store, f"claim number {i}") + rows = hot_mod.compute_hot_memory(store, limit=2) + assert len(rows) == 2 + + +# --- query bias ------------------------------------------------------------- + + +def test_query_bias_boosts_matching_claim(store: KBStore) -> None: + id_off = _approved_claim(store, "the moon orbits earth") + id_on = _approved_claim(store, "kafka partitioning explained") + rows = hot_mod.compute_hot_memory(store, query="kafka", limit=5) + assert rows[0]["id"] == id_on + assert rows[0]["why_hot"] == "recent+match" + assert any(r["id"] == id_off for r in rows) + + +def test_query_bias_for_entity_name_and_aliases(store: KBStore) -> None: + _approved_claim(store, "acme corp uses kafka for events") + _approved_claim(store, "unrelated weather report") + entity = Entity(id="ent-acme", name="Acme Corp", type=EntityType.COMPANY, + aliases=["ACME", "Acme"]) + store.put_entity(entity) + bias = hot_mod.query_bias_for_entity(entity) + rows = hot_mod.compute_hot_memory(store, query=bias, limit=5) + assert rows[0]["why_hot"] == "recent+match" + + +def test_query_bias_for_page_title_and_tags(store: KBStore) -> None: + _approved_claim(store, "security review checklist for deploys") + _approved_claim(store, "unrelated lunch menu") + page = Page(id="pg-sec", title="Security Review", type=PageType.DECISION.value, + tags=["security", "deploy"]) + store.put_page(page) + bias = hot_mod.query_bias_for_page(page) + rows = hot_mod.compute_hot_memory(store, query=bias, limit=5) + assert any(r["why_hot"] == "recent+match" for r in rows) + + +# --- filtering: status + age ---------------------------------------------- + + +def test_archived_claim_excluded(store: KBStore) -> None: + cid = _approved_claim(store, "to be archived") + claim = store.get_claim(cid) + claim.status = ClaimStatus.ARCHIVED + store.update_claim(claim) + assert hot_mod.compute_hot_memory(store, limit=5) == [] + + +def test_superseded_claim_excluded(store: KBStore) -> None: + cid = _approved_claim(store, "to be superseded") + claim = store.get_claim(cid) + claim.status = ClaimStatus.SUPERSEDED + store.update_claim(claim) + assert hot_mod.compute_hot_memory(store, limit=5) == [] + + +def test_old_claim_filtered_by_max_age(store: KBStore) -> None: + cid = _approved_claim(store, "ancient history") + claim = store.get_claim(cid) + claim.updated_at = datetime.now(UTC) - timedelta(days=30) + store.update_claim(claim) + rows = hot_mod.compute_hot_memory(store, limit=5, max_age_seconds=24 * 3600) + assert rows == [] + + +# --- exclude_ids + cache -------------------------------------------------- + + +def test_exclude_ids_filters_caller_supplied(store: KBStore) -> None: + a = _approved_claim(store, "alpha") + b = _approved_claim(store, "beta") + rows = hot_mod.compute_hot_memory(store, limit=5, exclude_ids=[a]) + ids = [r["id"] for r in rows] + assert a not in ids + assert b in ids + + +def test_cache_returns_stale_within_ttl(store: KBStore) -> None: + _approved_claim(store, "first claim") + rows1 = hot_mod.compute_hot_memory(store, limit=5, now=1000.0) + _approved_claim(store, "second claim") + rows2 = hot_mod.compute_hot_memory(store, limit=5, now=1000.5) + assert len(rows1) == 1 + assert len(rows2) == 1 + + +def test_cache_expires_after_ttl(store: KBStore) -> None: + _approved_claim(store, "first claim") + hot_mod.compute_hot_memory(store, limit=5, now=1000.0) + _approved_claim(store, "second claim") + rows = hot_mod.compute_hot_memory(store, limit=5, now=1100.0) + assert len(rows) == 2 + + +# --- attach_hot_memory shape contracts -------------------------------------- + + +def test_attach_to_dict_merges_meta(store: KBStore) -> None: + _approved_claim(store, "sky is blue") + result: dict = {"foo": "bar"} + wrapped = hot_mod.attach_hot_memory(result, store, query=None) + assert wrapped is result + assert "vouch_hot_memory" in wrapped["_meta"] + assert wrapped["foo"] == "bar" + + +def test_attach_preserves_existing_meta(store: KBStore) -> None: + _approved_claim(store, "sky is blue") + result: dict = {"foo": "bar", "_meta": {"caller_meta": "keep me"}} + wrapped = hot_mod.attach_hot_memory(result, store, query=None) + assert wrapped["_meta"]["caller_meta"] == "keep me" + assert "vouch_hot_memory" in wrapped["_meta"] + + +def test_attach_to_list_wraps_in_envelope(store: KBStore) -> None: + _approved_claim(store, "sky is blue") + result_list = [{"hit": 1}, {"hit": 2}] + wrapped = hot_mod.attach_hot_memory(result_list, store, query=None) + assert isinstance(wrapped, dict) + assert wrapped["items"] == result_list + assert "vouch_hot_memory" in wrapped["_meta"] + + +def test_list_envelope_always_wraps_with_deprecation(store: KBStore) -> None: + result_list = [{"id": "x"}] + wrapped = hot_mod.attach_hot_memory( + result_list, store, query=None, list_envelope=True, + ) + assert wrapped["items"] == result_list + assert "deprecation" in wrapped["_meta"] + assert wrapped["_meta"]["deprecation"]["migration"] + + +def test_list_envelope_includes_sidebar_when_non_empty(store: KBStore) -> None: + _approved_claim(store, "fresh claim text") + wrapped = hot_mod.attach_hot_memory( + [], store, query=None, list_envelope=True, + ) + assert wrapped["items"] == [] + assert "vouch_hot_memory" in wrapped["_meta"] + assert "deprecation" in wrapped["_meta"] + + +def test_attach_empty_sidebar_is_noop(store: KBStore) -> None: + result: dict = {"foo": "bar"} + out = hot_mod.attach_hot_memory(result, store, query=None) + assert out is result + assert "_meta" not in out + + +def test_attach_scalar_unchanged(store: KBStore) -> None: + _approved_claim(store, "sky is blue") + assert hot_mod.attach_hot_memory("string", store, query=None) == "string" + + +def test_compute_returns_empty_on_broken_store(tmp_path: Path) -> None: + class BrokenStore: + def __init__(self) -> None: + self.kb_dir = tmp_path / ".vouch" + + def list_claims(self) -> list: + raise RuntimeError("simulated read failure") + + rows = hot_mod.compute_hot_memory(BrokenStore(), limit=5) # type: ignore[arg-type] + assert rows == [] + + +# --- JSONL integration ------------------------------------------------------ + + +def test_jsonl_read_claim_attaches_hot_memory( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + cid_target = _approved_claim(store, "target claim about jwt rotation") + _approved_claim(store, "adjacent claim about jwt expiry") + + monkeypatch.chdir(store.root) + result = handle_request( + {"id": "r1", "method": "kb.read_claim", "params": {"claim_id": cid_target}}, + ) + assert result["ok"] is True + payload = result["result"] + assert payload["id"] == cid_target + hot = payload["_meta"]["vouch_hot_memory"] + assert all(r["id"] != cid_target for r in hot) + assert any("jwt expiry" in r["text"] for r in hot) + + +def test_jsonl_list_claims_uses_envelope( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _approved_claim(store, "listed claim") + monkeypatch.chdir(store.root) + result = handle_request({"id": "r1", "method": "kb.list_claims", "params": {}}) + assert result["ok"] + payload = result["result"] + assert "items" in payload + assert "deprecation" in payload["_meta"] + assert len(payload["items"]) == 1 + + +def test_jsonl_list_pending_recency_only_no_query_bias( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + src = store.put_source(b"e") + propose_claim(store, text="pending jwt topic", evidence=[src.id], + proposed_by="agent") + _approved_claim(store, "approved unrelated") + monkeypatch.chdir(store.root) + result = handle_request({"id": "r1", "method": "kb.list_pending", "params": {}}) + assert result["ok"] + assert len(result["result"]["items"]) == 1 + hot = result["result"]["_meta"].get("vouch_hot_memory", []) + assert len(hot) >= 1 + assert all(r["why_hot"] == "recent" for r in hot) + + +# --- universal coverage (#225 acceptance) ----------------------------------- + + +def test_hot_memory_universal_coverage() -> None: + """Every kb.* method either attaches hot memory or is explicitly excluded.""" + covered = hot_mod.HOT_MEMORY_COVERED + excluded = set(hot_mod.HOT_MEMORY_EXCLUDED) + declared = set(METHODS) + + assert covered <= declared + assert excluded <= declared + assert not covered & excluded + + uncovered = declared - covered - excluded + assert not uncovered, ( + f"methods missing from HOT_MEMORY_COVERED or HOT_MEMORY_EXCLUDED: " + f"{sorted(uncovered)}" + ) + + +@pytest.mark.parametrize("method", sorted(hot_mod.HOT_MEMORY_COVERED)) +def test_covered_methods_attach_sidebar_when_kb_has_recent_claims( + store: KBStore, + monkeypatch: pytest.MonkeyPatch, + method: str, +) -> None: + """Smoke: each covered method returns vouch_hot_memory on a warm KB.""" + cid = _approved_claim(store, "kafka stream processing policy") + _approved_claim(store, "adjacent kafka consumer group notes") + monkeypatch.chdir(store.root) + + params: dict = {} + if method == "kb.search": + params = {"query": "xyzzy_no_index_hits", "limit": 3} + elif method == "kb.context": + params = {"task": "xyzzy_no_index_hits", "limit": 3} + elif method == "kb.read_page": + page = Page(id="pg-k", title="Kafka Guide", type=PageType.CONCEPT.value, + tags=["kafka"]) + store.put_page(page) + params = {"page_id": "pg-k"} + elif method == "kb.read_claim": + params = {"claim_id": cid} + elif method == "kb.read_entity": + ent = Entity(id="ent-k", name="Kafka", type=EntityType.CONCEPT, + aliases=["Apache Kafka"]) + store.put_entity(ent) + params = {"entity_id": "ent-k"} + elif method == "kb.read_relation": + ent = Entity(id="ent-a", name="A", type=EntityType.CONCEPT) + ent2 = Entity(id="ent-b", name="B", type=EntityType.CONCEPT) + store.put_entity(ent) + store.put_entity(ent2) + from vouch.models import Relation, RelationType + rel = Relation(id="rel-ab", source="ent-a", relation=RelationType.RELATES_TO, + target="ent-b") + store.put_relation(rel) + params = {"relation_id": "rel-ab"} + + resp = handle_request({"id": "cov", "method": method, "params": params}) + assert resp["ok"], resp + payload = resp["result"] + if method.startswith("kb.list_"): + assert "items" in payload + meta = payload["_meta"] + else: + meta = payload["_meta"] + assert "vouch_hot_memory" in meta + assert len(meta["vouch_hot_memory"]) >= 1 diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py index 65627b76..0819e777 100644 --- a/tests/test_jsonl_server.py +++ b/tests/test_jsonl_server.py @@ -88,7 +88,7 @@ def test_jsonl_dry_run_propose_then_real_propose(store: KBStore, monkeypatch) -> assert real["ok"] pending = handle_request({"id": "3", "method": "kb.list_pending", "params": {}}) - assert len(pending["result"]) == 1 + assert len(pending["result"]["items"]) == 1 def test_jsonl_full_flow(store: KBStore, monkeypatch) -> None: