From a1a8b9628d6a4f50d6310d49245834b128a665aa Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 17 Jun 2026 00:37:33 -0500 Subject: [PATCH 1/2] feat(openclaw): declare vouch-context engine contract --- CHANGELOG.md | 7 + adapters/openclaw/vouch-context-engine.mjs | 115 ++++++++ openclaw.plugin.json | 6 +- src/vouch/capabilities.py | 2 + src/vouch/cli.py | 8 + src/vouch/models.py | 4 + src/vouch/openclaw/__init__.py | 21 ++ src/vouch/openclaw/context_engine.py | 323 +++++++++++++++++++++ src/vouch/openclaw/rpc.py | 92 ++++++ src/vouch/openclaw/synthesis.py | 228 +++++++++++++++ src/vouch/openclaw/types.py | 158 ++++++++++ tests/test_openclaw_context_engine.py | 265 +++++++++++++++++ tests/test_openclaw_plugin_manifest.py | 59 ++++ 13 files changed, 1287 insertions(+), 1 deletion(-) create mode 100644 adapters/openclaw/vouch-context-engine.mjs create mode 100644 src/vouch/openclaw/__init__.py create mode 100644 src/vouch/openclaw/context_engine.py create mode 100644 src/vouch/openclaw/rpc.py create mode 100644 src/vouch/openclaw/synthesis.py create mode 100644 src/vouch/openclaw/types.py create mode 100644 tests/test_openclaw_context_engine.py create mode 100644 tests/test_openclaw_plugin_manifest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4446c3b..e45adc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ All notable changes to vouch are documented here. Format follows from the cited claims' lifecycle status. Deterministic in v1 (no LLM in the loop). Exposed across the CLI (`vouch synthesize`), MCP (`kb_synthesize`), and JSONL (`kb.synthesize`) surfaces (#222). +- `vouch-context` OpenClaw context engine (#228): `src/vouch/openclaw/context_engine.py` + wraps `kb.context` retrieval, the entity-salience reflex, and session hot + memory into a cited `systemPromptAddition` on every `assemble()`. The plugin + manifest declares `contracts.contextEngines: ["vouch-context"]` and registers + `adapters/openclaw/vouch-context-engine.mjs`; engine identity is advertised + on `kb.capabilities.context_engines`. Compaction stays delegated to the + legacy OpenClaw runtime (`ownsCompaction: false`). - Entity-salience retrieval reflex: a per-session, in-memory ring buffer of recent caller queries drives a zero-LLM substring/FTS entity pass that attaches top-K matched claim candidates as `_meta.vouch_salience` on diff --git a/adapters/openclaw/vouch-context-engine.mjs b/adapters/openclaw/vouch-context-engine.mjs new file mode 100644 index 0000000..81c15ea --- /dev/null +++ b/adapters/openclaw/vouch-context-engine.mjs @@ -0,0 +1,115 @@ +/** + * OpenClaw plugin entry for the vouch-context engine (#228). + * + * The host loads this module via openclaw.plugin.json → openclaw.extensions. + * Runtime assembly delegates to the Python engine through `vouch openclaw-rpc` + * so the cited synthesis path stays identical to unit tests and kb.context. + * + * Enable in openclaw.json: + * plugins.slots.contextEngine: "vouch-context" + */ + +import { spawnSync } from 'node:child_process'; + +export const ENGINE_ID = 'vouch-context'; +export const ENGINE_NAME = 'Vouch Context Engine'; + +/** @typedef {import('node:child_process').SpawnSyncReturns} SpawnResult */ + +/** + * @param {string} method + * @param {Record} params + * @returns {Record} + */ +function callPythonEngine(method, params) { + const envelope = JSON.stringify({ + id: 'openclaw', + method, + params, + }); + /** @type {SpawnResult} */ + const proc = spawnSync('vouch', ['openclaw-rpc'], { + input: envelope, + encoding: 'utf8', + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }); + if (proc.error) { + throw proc.error; + } + if (proc.status !== 0) { + const detail = (proc.stderr || proc.stdout || '').trim(); + throw new Error( + `vouch openclaw-rpc exited ${proc.status}${detail ? `: ${detail}` : ''}`, + ); + } + let parsed; + try { + parsed = JSON.parse(String(proc.stdout || '{}')); + } catch (err) { + throw new Error(`vouch openclaw-rpc returned invalid json: ${err}`); + } + if (!parsed.ok) { + const msg = parsed.error?.message || 'engine rpc failed'; + throw new Error(msg); + } + return parsed.result; +} + +/** @type {{ id: string; name: string; description: string; register: (api: any) => void }} */ +const entry = { + id: 'vouch-context-engine', + name: 'Vouch Context Engine', + description: + 'Review-gated KB context: cited retrieval + salience reflex + hot memory on every assemble()', + + register(api) { + api.registerContextEngine(ENGINE_ID, (ctx) => { + const workspaceDir = ctx.workspaceDir; + const kbPath = ctx.kbPath ?? ctx.kb_path; + const agent = ctx.agent; + const project = ctx.project; + + const baseParams = () => ({ + workspaceDir, + kbPath, + agent, + project, + }); + + return { + info: { + id: ENGINE_ID, + name: ENGINE_NAME, + version: '0.1.0', + ownsCompaction: false, + }, + + async ingest({ sessionId, message, isHeartbeat }) { + return callPythonEngine('ingest', { + ...baseParams(), + sessionId, + message, + isHeartbeat, + }); + }, + + async assemble(params) { + return callPythonEngine('assemble', { + ...baseParams(), + ...params, + }); + }, + + async compact(params) { + return callPythonEngine('compact', { + ...baseParams(), + ...params, + }); + }, + }; + }); + }, +}; + +export default entry; diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 4db9462..97322fb 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -49,6 +49,9 @@ "compat": { "pluginApi": ">=2026.4.0" }, + "extensions": [ + "./adapters/openclaw/vouch-context-engine.mjs" + ], "trust_boundary": { "remote_callers_filesystem": "confined", "write_tools_review_gated": true, @@ -57,6 +60,7 @@ }, "contracts": { "reviewGatedKB": ["vouch-kb-0.1.0"], - "mcpMethods": ["kb.capabilities", "kb.status", "kb.search", "kb.context"] + "mcpMethods": ["kb.capabilities", "kb.status", "kb.search", "kb.context"], + "contextEngines": ["vouch-context"] } } diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index b2e93d5..4b58267 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -9,6 +9,7 @@ from . import __version__ from .models import Capabilities +from .openclaw.context_engine import describe_engine # The full method surface this implementation exposes. Keep this list in # sync with the MCP server + JSONL server registrations — `test_capabilities` @@ -91,4 +92,5 @@ def capabilities() -> Capabilities: "env_vars": ["VOUCH_PROJECT", "VOUCH_AGENT"], "config_path": "retrieval.scope", }, + context_engines=[describe_engine()], ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5fcf71b..5b598cd 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2444,5 +2444,13 @@ def review_ui( uvicorn.run(app, host=host, port=port, log_level="info") +@cli.command("openclaw-rpc") +def openclaw_rpc() -> None: + """OpenClaw bridge: one JSON envelope on stdin, one on stdout (context engine).""" + from .openclaw import rpc as openclaw_rpc_mod + + raise SystemExit(openclaw_rpc_mod.run_stdio()) + + if __name__ == "__main__": cli() diff --git a/src/vouch/models.py b/src/vouch/models.py index c779e59..a2c7be3 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -433,3 +433,7 @@ class Capabilities(BaseModel): "config_path": "retrieval.scope", } ) + context_engines: list[dict[str, Any]] = Field( + default_factory=list, + description="OpenClaw context engines exposed (see openclaw.plugin.json)", + ) diff --git a/src/vouch/openclaw/__init__.py b/src/vouch/openclaw/__init__.py new file mode 100644 index 0000000..0b0d91b --- /dev/null +++ b/src/vouch/openclaw/__init__.py @@ -0,0 +1,21 @@ +"""OpenClaw integration for vouch.""" + +from .context_engine import ( + ENGINE_API_VERSION, + ENGINE_ID, + ENGINE_NAME, + VouchContextEngine, + create_vouch_context_engine, + describe_engine, + engine_info, +) + +__all__ = [ + "ENGINE_API_VERSION", + "ENGINE_ID", + "ENGINE_NAME", + "VouchContextEngine", + "create_vouch_context_engine", + "describe_engine", + "engine_info", +] diff --git a/src/vouch/openclaw/context_engine.py b/src/vouch/openclaw/context_engine.py new file mode 100644 index 0000000..6647cd1 --- /dev/null +++ b/src/vouch/openclaw/context_engine.py @@ -0,0 +1,323 @@ +"""Vouch OpenClaw context engine — cited KB synthesis on every assemble(). + +Wraps ``kb.context`` retrieval, the entity-salience reflex, and per-session hot +memory into a single ``systemPromptAddition`` block. Compaction stays with the +legacy OpenClaw runtime (``ownsCompaction: false``), matching gbrain-context's +delegation posture. + +Enable in ``openclaw.json``:: + + plugins.slots.contextEngine: "vouch-context" +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any + +import yaml + +from .. import hot_memory +from .. import salience as salience_mod +from ..context import build_context_pack +from ..storage import KBNotFoundError, KBStore, discover_root +from .synthesis import estimate_tokens, synthesize_context_block +from .types import ( + AgentMessage, + AssembleParams, + AssembleResult, + CompactParams, + CompactResult, + ContextEngineInfo, + IngestParams, + IngestResult, +) + +logger = logging.getLogger(__name__) + +ENGINE_ID = "vouch-context" +ENGINE_NAME = "Vouch Context Engine" +ENGINE_API_VERSION = "0.1.0" + +DEFAULT_ITEM_LIMIT = 12 +DEFAULT_CHARS_PER_TOKEN = 4 +DEFAULT_RESERVE_TOKENS = 512 + + +def engine_info() -> ContextEngineInfo: + return ContextEngineInfo( + id=ENGINE_ID, + name=ENGINE_NAME, + version=ENGINE_API_VERSION, + owns_compaction=False, + ) + + +def message_text(content: str | list[dict[str, Any]] | Any) -> str: + """Coerce structured message content to plain text (OpenClaw block arrays).""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + parts.append(block["text"]) + elif block is not None: + parts.append(str(block)) + return " ".join(parts) + return str(content or "") + + +def last_user_text(messages: list[AgentMessage]) -> str: + for msg in reversed(messages): + if msg.role == "user": + return message_text(msg.content).strip() + return "" + + +def resolve_task_query(params: AssembleParams) -> str: + explicit = (params.prompt or "").strip() + if explicit: + return explicit + from_messages = last_user_text(params.messages) + if from_messages: + return from_messages + mem = hot_memory.get(params.session_id) if params.session_id else None + if mem and mem.query.strip(): + return mem.query.strip() + return "" + + +def resolve_kb_root(*, workspace_dir: Path | None, kb_path: str | None) -> Path: + if kb_path: + return Path(kb_path).expanduser().resolve() + start = workspace_dir or Path.cwd() + return discover_root(start) + + +def load_cfg(store: KBStore) -> dict[str, Any]: + try: + loaded = yaml.safe_load(store.config_path.read_text()) + except Exception: + return {} + return loaded if isinstance(loaded, dict) else {} + + +def token_budget_to_max_chars( + token_budget: int | None, + *, + reserve: int = DEFAULT_RESERVE_TOKENS, +) -> int | None: + if token_budget is None or token_budget <= 0: + return None + usable = max(256, token_budget - reserve) + return usable * DEFAULT_CHARS_PER_TOKEN + + +def hot_memory_snapshot(session_id: str | None) -> dict[str, Any] | None: + if not session_id: + return None + mem = hot_memory.get(session_id) + if mem is None: + return None + return { + "session_id": mem.session_id, + "query": mem.query, + "agent": mem.agent, + "project": mem.project, + "push_count": mem.push_count, + "volunteered": sorted(mem.volunteered), + "last_scores": dict(mem.last_snapshot.scores), + "active": mem.active, + } + + +def entity_name_map(store: KBStore, salience: list[dict[str, Any]]) -> dict[str, str]: + names: dict[str, str] = {} + for rec in salience: + eid = str(rec.get("entity_id") or "") + if not eid: + continue + try: + ent = store.get_entity(eid) + names[eid] = ent.name or eid + except Exception: + names[eid] = eid + return names + + +class VouchContextEngine: + """OpenClaw-shaped context engine backed by vouch retrieval.""" + + def __init__( + self, + *, + kb_root: Path | None = None, + workspace_dir: Path | None = None, + agent: str | None = None, + project: str | None = None, + item_limit: int = DEFAULT_ITEM_LIMIT, + ) -> None: + self._kb_root = kb_root + self._workspace_dir = workspace_dir + self._default_agent = agent or os.environ.get("VOUCH_AGENT", "openclaw") + self._default_project = project + self._item_limit = max(1, item_limit) + self.info = engine_info() + + def _store(self) -> KBStore: + root = self._kb_root + if root is None: + root = resolve_kb_root(workspace_dir=self._workspace_dir, kb_path=None) + return KBStore(root) + + def ingest(self, params: IngestParams | dict[str, Any]) -> IngestResult: + if isinstance(params, dict): + msg_raw = params.get("message") or {} + message = ( + AgentMessage.from_wire(msg_raw) + if isinstance(msg_raw, dict) + else AgentMessage(role="user", content=str(msg_raw)) + ) + params = IngestParams( + session_id=str(params.get("sessionId") or params.get("session_id") or ""), + message=message, + is_heartbeat=bool(params.get("isHeartbeat") or params.get("is_heartbeat")), + ) + if params.is_heartbeat: + return IngestResult(ingested=False) + text = message_text(params.message.content).strip() + if not params.session_id or not text: + return IngestResult(ingested=False) + try: + store = self._store() + cfg = load_cfg(store) + enabled, window, _top_k = salience_mod.reflex_cfg(cfg) + if enabled: + salience_mod.record_query(params.session_id, text, window=window) + except KBNotFoundError: + logger.debug("ingest skipped — no KB at workspace") + return IngestResult(ingested=False) + except Exception: + logger.exception("salience ingest failed for session %s", params.session_id) + return IngestResult(ingested=False) + return IngestResult(ingested=True) + + def assemble(self, params: AssembleParams | dict[str, Any]) -> AssembleResult: + if isinstance(params, dict): + params = AssembleParams.from_wire(params) + task = resolve_task_query(params) + agent = params.agent or self._default_agent + project = params.project or self._default_project + max_chars = token_budget_to_max_chars(params.token_budget) + + try: + store = self._store() + except KBNotFoundError: + addition = ( + "## Vouch knowledge context\n\n" + "_No `.vouch/` knowledge base found near the workspace. " + "Run `vouch init` in the project root._\n" + ) + est = estimate_tokens(*params.messages, extra_text=addition) + return AssembleResult( + messages=params.messages, + estimated_tokens=est, + system_prompt_addition=addition, + meta={"engine": ENGINE_ID, "kb_found": False}, + ) + + cfg = load_cfg(store) + session_id = params.session_id or None + if session_id and task: + enabled, window, _ = salience_mod.reflex_cfg(cfg) + if enabled: + salience_mod.record_query(session_id, task, window=window) + + pack: dict[str, Any] = build_context_pack( # type: ignore[assignment] + store, + query=task or "(no task query)", + limit=self._item_limit, + max_chars=max_chars, + require_citations=False, + project=project, + agent=agent, + ) + salience_mod.attach_salience(pack, store, session_id, cfg) + salience = (pack.get("_meta") or {}).get("vouch_salience") or [] + hot = hot_memory_snapshot(session_id) + names = entity_name_map(store, list(salience)) + + addition = synthesize_context_block( + pack=pack, + salience=list(salience), + hot_memory=hot, + entity_names=names, + citations_mode=params.citations_mode, + ) + est = estimate_tokens(*params.messages, extra_text=addition) + meta: dict[str, Any] = { + "engine": ENGINE_ID, + "engine_version": ENGINE_API_VERSION, + "kb_found": True, + "backend": pack.get("backend"), + "item_count": len(pack.get("items") or []), + } + if hot: + meta["vouch_hot_memory"] = hot + if salience: + meta["vouch_salience"] = salience + + return AssembleResult( + messages=params.messages, + estimated_tokens=est, + system_prompt_addition=addition, + context_pack=pack, + meta=meta, + ) + + def compact(self, params: CompactParams | dict[str, Any]) -> CompactResult: + if isinstance(params, dict): + params = CompactParams.from_wire(params) + # Compaction stays with the legacy runtime — same contract as gbrain-context. + return CompactResult(ok=True, compacted=False, reason="delegated") + + +def create_vouch_context_engine( + *, + kb_root: Path | str | None = None, + workspace_dir: Path | str | None = None, + agent: str | None = None, + project: str | None = None, + item_limit: int = DEFAULT_ITEM_LIMIT, +) -> VouchContextEngine: + """Factory used by the OpenClaw plugin entry and tests.""" + kb = Path(kb_root).expanduser().resolve() if kb_root else None + ws = Path(workspace_dir).expanduser().resolve() if workspace_dir else None + return VouchContextEngine( + kb_root=kb, + workspace_dir=ws, + agent=agent, + project=project, + item_limit=item_limit, + ) + + +def describe_engine() -> dict[str, Any]: + """Capability-facing engine descriptor for ``kb.capabilities``.""" + info = engine_info() + return { + "id": info.id, + "name": info.name, + "version": info.version, + "owns_compaction": info.owns_compaction, + "contract": "openclaw-context-engine", + "features": [ + "cited-context-pack", + "entity-salience-reflex", + "hot-memory-sidebar", + "review-gated-sources", + ], + } diff --git a/src/vouch/openclaw/rpc.py b/src/vouch/openclaw/rpc.py new file mode 100644 index 0000000..11e6ee2 --- /dev/null +++ b/src/vouch/openclaw/rpc.py @@ -0,0 +1,92 @@ +"""JSONL-style RPC bridge for the OpenClaw JS plugin entry.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from .context_engine import create_vouch_context_engine +from .types import CompactParams + +_METHODS = frozenset({"ingest", "assemble", "compact", "info"}) + + +def _parse_envelope(raw: str) -> dict[str, Any]: + try: + loaded = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid json: {exc}") from exc + if not isinstance(loaded, dict): + raise ValueError("envelope must be a json object") + return loaded + + +def _engine_from_envelope(env: dict[str, Any]): + params = env.get("params") + if not isinstance(params, dict): + params = {} + workspace = params.get("workspaceDir") or params.get("workspace_dir") + kb_path = params.get("kbPath") or params.get("kb_path") + agent = params.get("agent") + project = params.get("project") + return create_vouch_context_engine( + kb_root=Path(kb_path) if kb_path else None, + workspace_dir=Path(workspace) if workspace else None, + agent=str(agent) if agent else None, + project=str(project) if project else None, + ) + + +def handle_request(envelope: dict[str, Any]) -> dict[str, Any]: + req_id = envelope.get("id") + method = envelope.get("method") + if method not in _METHODS: + return { + "id": req_id, + "ok": False, + "error": {"code": "unknown_method", "message": f"unknown method: {method!r}"}, + } + try: + engine = _engine_from_envelope(envelope) + params = envelope.get("params") + if not isinstance(params, dict): + params = {} + if method == "info": + result = { + "info": { + "id": engine.info.id, + "name": engine.info.name, + "version": engine.info.version, + "ownsCompaction": engine.info.owns_compaction, + } + } + elif method == "ingest": + result = engine.ingest(params).to_wire() + elif method == "assemble": + result = engine.assemble(params).to_wire() + else: + result = engine.compact(CompactParams.from_wire(params)).to_wire() + return {"id": req_id, "ok": True, "result": result} + except Exception as exc: + return { + "id": req_id, + "ok": False, + "error": {"code": "engine_error", "message": str(exc)}, + } + + +def run_stdio() -> int: + """Read one JSON envelope from stdin, write one response envelope to stdout.""" + raw = sys.stdin.read() + if not raw.strip(): + sys.stdout.write(json.dumps({"ok": False, "error": {"message": "empty stdin"}}) + "\n") + return 1 + try: + env = _parse_envelope(raw) + except ValueError as exc: + sys.stdout.write(json.dumps({"ok": False, "error": {"message": str(exc)}}) + "\n") + return 1 + sys.stdout.write(json.dumps(handle_request(env)) + "\n") + return 0 diff --git a/src/vouch/openclaw/synthesis.py b/src/vouch/openclaw/synthesis.py new file mode 100644 index 0000000..3dc3a38 --- /dev/null +++ b/src/vouch/openclaw/synthesis.py @@ -0,0 +1,228 @@ +"""Deterministic cited synthesis for the vouch-context engine. + +Turns a ``kb.context`` ContextPack plus salience reflex and hot-memory sidebars +into a single markdown block suitable for OpenClaw's ``systemPromptAddition`` +slot. Zero LLM calls — the same posture as gbrain-context's deterministic +temporal injection, but for review-gated KB retrieval (#228). +""" + +from __future__ import annotations + +import re +from typing import Any + +_HEADER = "## Vouch knowledge context" +_SALIENCE_HEADER = "### Entity salience (retrieval reflex)" +_HOT_HEADER = "### Session hot memory" +_QUALITY_HEADER = "### Retrieval quality" +_CITATIONS_HEADER = "### Cited retrieval hits" + +# Strip control chars / newlines so external KB text cannot forge prompt directives. +_CTRL_RE = re.compile(r"[\n\r\t\x00-\x1F\x7F]+") + + +def sanitize_for_prompt(text: str, *, max_len: int = 400) -> str: + """Flatten and clamp untrusted KB text before prompt injection.""" + cleaned = _CTRL_RE.sub(" ", text or "").strip() + if len(cleaned) <= max_len: + return cleaned + return cleaned[: max_len - 1].rstrip() + "…" + + +def _format_citations(cites: list[str]) -> str: + if not cites: + return "uncited" + return ", ".join(f"`{sanitize_for_prompt(c, max_len=80)}`" for c in cites[:6]) + + +def format_context_item(item: dict[str, Any]) -> str: + """Render one ContextPack item as a single markdown bullet.""" + kind = sanitize_for_prompt(str(item.get("type", "artifact")), max_len=32) + artifact_id = sanitize_for_prompt(str(item.get("id", "?")), max_len=120) + summary = sanitize_for_prompt(str(item.get("summary", "")), max_len=320) + score = item.get("score") + backend = sanitize_for_prompt(str(item.get("backend", "unknown")), max_len=32) + cites = item.get("citations") or [] + if isinstance(score, (int, float)): + score_bit = f" (score={score:.3f}, {backend})" + else: + score_bit = f" ({backend})" + cite_bit = f" — evidence: {_format_citations(list(cites))}" + return f"- **[{kind}] `{artifact_id}`**{score_bit}: {summary}{cite_bit}" + + +def format_salience_section( + salience: list[dict[str, Any]], + *, + store_names: dict[str, str] | None = None, +) -> str: + """Render ``_meta.vouch_salience`` records as compact entity pointers.""" + if not salience: + return "" + names = store_names or {} + lines = [_SALIENCE_HEADER, ""] + for rec in salience: + eid = sanitize_for_prompt(str(rec.get("entity_id", "?")), max_len=120) + label = names.get(eid, eid) + count = int(rec.get("claim_count") or 0) + top = rec.get("top_claim_id") + top_bit = f", top claim `{sanitize_for_prompt(str(top), max_len=80)}`" if top else "" + lines.append( + f"- entity `{eid}` ({sanitize_for_prompt(label, max_len=80)}): " + f"{count} linked claim(s){top_bit}" + ) + lines.append("") + return "\n".join(lines) + + +def format_hot_memory_section(mem: dict[str, Any]) -> str: + """Render active session hot-memory state for the model.""" + if not mem: + return "" + lines = [_HOT_HEADER, ""] + query = sanitize_for_prompt(str(mem.get("query", "")), max_len=200) + if query: + lines.append(f"- active task query: {query!r}") + agent = mem.get("agent") + if agent: + lines.append(f"- session agent: `{sanitize_for_prompt(str(agent), max_len=64)}`") + project = mem.get("project") + if project: + lines.append(f"- session project: `{sanitize_for_prompt(str(project), max_len=64)}`") + push_count = mem.get("push_count") + if isinstance(push_count, int) and push_count: + lines.append(f"- volunteer pushes this session: {push_count}") + volunteered = mem.get("volunteered") or [] + if volunteered: + ids = ", ".join( + f"`{sanitize_for_prompt(str(v), max_len=80)}`" for v in list(volunteered)[:8] + ) + lines.append(f"- already volunteered claims: {ids}") + scores = mem.get("last_scores") or {} + if scores: + top = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)[:5] + score_line = ", ".join( + f"`{sanitize_for_prompt(cid, max_len=80)}`={rel:.2f}" for cid, rel in top + ) + lines.append(f"- last salience snapshot: {score_line}") + lines.append("") + return "\n".join(lines) + + +def format_quality_section(pack: dict[str, Any]) -> str: + """Surface ContextPack quality metadata when warnings exist.""" + quality = pack.get("quality") or {} + warnings = pack.get("warnings") or [] + if not warnings and quality.get("ok", True): + return "" + lines = [_QUALITY_HEADER, ""] + if warnings: + for w in warnings: + lines.append(f"- warning: {sanitize_for_prompt(str(w), max_len=200)}") + failed = quality.get("failed") or [] + if failed: + lines.append(f"- failed gates: {', '.join(str(f) for f in failed)}") + if quality.get("budget_truncated"): + omitted = quality.get("budget_omitted_items", 0) + clipped = quality.get("budget_clipped_items", 0) + lines.append( + f"- budget truncated (omitted={omitted}, clipped={clipped})" + ) + uncited = quality.get("uncited_items") or [] + if uncited: + lines.append(f"- uncited claims: {len(uncited)}") + lines.append("") + return "\n".join(lines) + + +def synthesize_context_block( + *, + pack: dict[str, Any], + salience: list[dict[str, Any]] | None = None, + hot_memory: dict[str, Any] | None = None, + entity_names: dict[str, str] | None = None, + citations_mode: str | None = None, +) -> str: + """Weave retrieval, salience, and hot memory into one cited synthesis block.""" + query = sanitize_for_prompt(str(pack.get("query", "")), max_len=240) + items: list[dict[str, Any]] = list(pack.get("items") or []) + backend = sanitize_for_prompt(str(pack.get("backend", "none")), max_len=32) + viewer = pack.get("viewer") or {} + + sections: list[str] = [ + _HEADER, + "", + f"Task: {query!r}", + f"Retrieval backend: `{backend}`", + ] + if viewer: + proj = viewer.get("project") + agent = viewer.get("agent") + if proj or agent: + sections.append( + "Viewer scope: " + + ", ".join( + bit + for bit in ( + f"project={proj!r}" if proj else "", + f"agent={agent!r}" if agent else "", + ) + if bit + ) + ) + if citations_mode: + sections.append(f"Citations mode: `{sanitize_for_prompt(citations_mode, max_len=32)}`") + sections.append("") + + if items: + sections.append(_CITATIONS_HEADER) + sections.append("") + for item in items: + sections.append(format_context_item(item)) + sections.append("") + else: + sections.append("_No matching approved knowledge found for this task._") + sections.append("") + + salience_block = format_salience_section(salience or [], store_names=entity_names) + if salience_block: + sections.append(salience_block) + + hot_block = format_hot_memory_section(hot_memory or {}) + if hot_block: + sections.append(hot_block) + + quality_block = format_quality_section(pack) + if quality_block: + sections.append(quality_block) + + sections.append( + "_Sources are review-gated YAML claims under `.vouch/`. " + "Prefer citing claim ids when asserting facts._" + ) + return "\n".join(sections).strip() + "\n" + + +def estimate_tokens(*messages: Any, extra_text: str = "") -> int: + """Rough token estimate: chars/4 heuristic (matches gbrain-context tests).""" + from .types import AgentMessage + + total = len(extra_text) + for msg in messages: + if isinstance(msg, AgentMessage): + content = msg.content + elif isinstance(msg, dict): + content = msg.get("content", "") + else: + content = str(msg) + if isinstance(content, str): + total += len(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + total += len(block["text"]) + else: + total += len(str(block)) + else: + total += len(str(content)) + return max(1, total // 4) diff --git a/src/vouch/openclaw/types.py b/src/vouch/openclaw/types.py new file mode 100644 index 0000000..e939047 --- /dev/null +++ b/src/vouch/openclaw/types.py @@ -0,0 +1,158 @@ +"""OpenClaw context-engine wire types. + +These mirror the ContextEngine interface documented at +https://docs.openclaw.ai/concepts/context-engine. They are intentionally +SDK-free so the engine can be unit-tested without the OpenClaw host. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +@dataclass(frozen=True) +class ContextEngineInfo: + """Static identity returned by ``VouchContextEngine.info``.""" + + id: str + name: str + version: str + owns_compaction: bool = False + + +@dataclass +class AgentMessage: + """Minimal message shape OpenClaw passes into ingest/assemble.""" + + role: str + content: str | list[dict[str, Any]] | Any = "" + extra: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_wire(cls, raw: dict[str, Any]) -> AgentMessage: + role = str(raw.get("role", "")) + content = raw.get("content", "") + known = {"role", "content"} + extra = {k: v for k, v in raw.items() if k not in known} + return cls(role=role, content=content, extra=extra) + + +@dataclass +class IngestParams: + session_id: str + message: AgentMessage + is_heartbeat: bool = False + + +@dataclass +class IngestResult: + ingested: bool = True + + def to_wire(self) -> dict[str, Any]: + return {"ingested": self.ingested} + + +@dataclass +class AssembleParams: + session_id: str + messages: list[AgentMessage] = field(default_factory=list) + token_budget: int | None = None + session_key: str | None = None + available_tools: set[str] | None = None + citations_mode: str | None = None + model: str | None = None + prompt: str | None = None + project: str | None = None + agent: str | None = None + + @classmethod + def from_wire(cls, raw: dict[str, Any]) -> AssembleParams: + messages = [ + AgentMessage.from_wire(m) + if isinstance(m, dict) + else AgentMessage(role="", content=str(m)) + for m in raw.get("messages") or [] + ] + tools_raw = raw.get("availableTools") or raw.get("available_tools") + tools: set[str] | None + if tools_raw is None: + tools = None + elif isinstance(tools_raw, (list, set, tuple)): + tools = {str(t) for t in tools_raw} + else: + tools = None + budget = raw.get("tokenBudget", raw.get("token_budget")) + token_budget = int(budget) if budget is not None else None + return cls( + session_id=str(raw.get("sessionId") or raw.get("session_id") or ""), + messages=messages, + token_budget=token_budget, + session_key=raw.get("sessionKey") or raw.get("session_key"), + available_tools=tools, + citations_mode=raw.get("citationsMode") or raw.get("citations_mode"), + model=raw.get("model"), + prompt=raw.get("prompt"), + project=raw.get("project"), + agent=raw.get("agent"), + ) + + +@dataclass +class AssembleResult: + messages: list[AgentMessage] + estimated_tokens: int + system_prompt_addition: str | None = None + context_pack: dict[str, Any] | None = None + meta: dict[str, Any] = field(default_factory=dict) + + def to_wire(self) -> dict[str, Any]: + out: dict[str, Any] = { + "messages": [ + {"role": m.role, "content": m.content, **m.extra} + for m in self.messages + ], + "estimatedTokens": self.estimated_tokens, + } + if self.system_prompt_addition: + out["systemPromptAddition"] = self.system_prompt_addition + if self.context_pack is not None: + out["contextPack"] = self.context_pack + if self.meta: + out["_meta"] = self.meta + return out + + +@dataclass +class CompactParams: + session_id: str + session_file: str = "" + token_budget: int | None = None + force: bool = False + + @classmethod + def from_wire(cls, raw: dict[str, Any]) -> CompactParams: + budget = raw.get("tokenBudget", raw.get("token_budget")) + return cls( + session_id=str(raw.get("sessionId") or raw.get("session_id") or ""), + session_file=str(raw.get("sessionFile") or raw.get("session_file") or ""), + token_budget=int(budget) if budget is not None else None, + force=bool(raw.get("force", False)), + ) + + +CompactReason = Literal["delegated", "no-runtime", "disabled"] + + +@dataclass +class CompactResult: + ok: bool = True + compacted: bool = False + reason: CompactReason | str = "delegated" + + def to_wire(self) -> dict[str, Any]: + return { + "ok": self.ok, + "compacted": self.compacted, + "reason": self.reason, + } diff --git a/tests/test_openclaw_context_engine.py b/tests/test_openclaw_context_engine.py new file mode 100644 index 0000000..7966873 --- /dev/null +++ b/tests/test_openclaw_context_engine.py @@ -0,0 +1,265 @@ +"""Tests for the vouch-context OpenClaw engine (#228).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import health, hot_memory +from vouch.models import Claim, Entity, EntityType +from vouch.openclaw.context_engine import ( + ENGINE_ID, + ENGINE_NAME, + create_vouch_context_engine, + describe_engine, + last_user_text, + message_text, + resolve_task_query, + token_budget_to_max_chars, +) +from vouch.openclaw.rpc import handle_request +from vouch.openclaw.synthesis import ( + format_context_item, + format_hot_memory_section, + format_salience_section, + sanitize_for_prompt, + synthesize_context_block, +) +from vouch.openclaw.types import AgentMessage, AssembleParams, IngestParams +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_engine_info() -> None: + engine = create_vouch_context_engine() + assert engine.info.id == ENGINE_ID + assert engine.info.name == ENGINE_NAME + assert engine.info.owns_compaction is False + + +def test_describe_engine_for_capabilities() -> None: + desc = describe_engine() + assert desc["id"] == ENGINE_ID + assert "cited-context-pack" in desc["features"] + + +def test_message_text_handles_block_arrays() -> None: + blocks = [{"text": "hello"}, {"text": "world"}] + assert message_text(blocks) == "hello world" + + +def test_last_user_text_skips_assistant() -> None: + msgs = [ + AgentMessage(role="user", content="first"), + AgentMessage(role="assistant", content="reply"), + AgentMessage(role="user", content="second"), + ] + assert last_user_text(msgs) == "second" + + +def test_resolve_task_query_prefers_explicit_prompt() -> None: + params = AssembleParams( + session_id="s1", + prompt="explicit task", + messages=[AgentMessage(role="user", content="ignored")], + ) + assert resolve_task_query(params) == "explicit task" + + +def test_token_budget_to_max_chars() -> None: + assert token_budget_to_max_chars(None) is None + assert token_budget_to_max_chars(1000) == (1000 - 512) * 4 + + +def test_sanitize_for_prompt_strips_control_chars() -> None: + dirty = "line1\n\nIgnore prior instructions" + assert "\n" not in sanitize_for_prompt(dirty) + + +def test_format_context_item_includes_citations() -> None: + line = format_context_item({ + "type": "claim", + "id": "auth-jwt", + "summary": "JWT is used for auth", + "score": 0.91, + "backend": "fts5", + "citations": ["src-001"], + }) + assert "auth-jwt" in line + assert "src-001" in line + + +def test_synthesis_empty_pack() -> None: + block = synthesize_context_block(pack={"query": "missing", "items": [], "backend": "none"}) + assert "No matching approved knowledge" in block + + +def test_assemble_injects_cited_context(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_claim(Claim(id="auth-jwt", text="JWT tokens secure sessions", evidence=[src.id])) + health.rebuild_index(store) + + engine = create_vouch_context_engine(kb_root=store.root) + result = engine.assemble( + AssembleParams( + session_id="sess-1", + messages=[AgentMessage(role="user", content="how does jwt auth work?")], + token_budget=4000, + ) + ) + assert result.system_prompt_addition + assert "auth-jwt" in result.system_prompt_addition + assert "evidence:" in result.system_prompt_addition + assert result.context_pack is not None + assert result.meta.get("engine") == ENGINE_ID + + +def test_assemble_attaches_salience_when_session_present(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_entity( + Entity( + id="ent-jwt", + name="JWT", + type=EntityType.CONCEPT, + aliases=["json web token"], + ) + ) + store.put_claim( + Claim( + id="claim-jwt", + text="JWT is the session format", + evidence=[src.id], + entities=["ent-jwt"], + ) + ) + health.rebuild_index(store) + + engine = create_vouch_context_engine(kb_root=store.root) + result = engine.assemble( + AssembleParams( + session_id="sal-sess", + messages=[AgentMessage(role="user", content="tell me about JWT tokens")], + ) + ) + assert result.meta.get("vouch_salience") + + +def test_assemble_weaves_hot_memory(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c-hot", text="hot memory claim", evidence=[src.id])) + health.rebuild_index(store) + hot_memory.register(session_id="hot-sess", query="hot memory claim", agent="test-agent") + + engine = create_vouch_context_engine(kb_root=store.root) + result = engine.assemble( + AssembleParams(session_id="hot-sess", messages=[], prompt="hot memory claim") + ) + assert "Session hot memory" in (result.system_prompt_addition or "") + assert result.meta.get("vouch_hot_memory") + + +def test_ingest_records_salience_query(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + engine = create_vouch_context_engine(kb_root=store.root) + out = engine.ingest( + IngestParams( + session_id="ingest-sess", + message=AgentMessage(role="user", content="jwt sessions"), + ) + ) + assert out.ingested is True + + +def test_ingest_heartbeat_is_noop(store: KBStore) -> None: + engine = create_vouch_context_engine(kb_root=store.root) + out = engine.ingest( + IngestParams( + session_id="hb", + message=AgentMessage(role="user", content="ping"), + is_heartbeat=True, + ) + ) + assert out.ingested is False + + +def test_compact_delegates_to_legacy_runtime(store: KBStore) -> None: + engine = create_vouch_context_engine(kb_root=store.root) + result = engine.compact({"sessionId": "x", "sessionFile": "/tmp/x"}) + assert result.ok is True + assert result.compacted is False + assert result.reason == "delegated" + + +def test_assemble_passes_messages_through_unchanged(store: KBStore) -> None: + engine = create_vouch_context_engine(kb_root=store.root) + messages = [AgentMessage(role="user", content="hello")] + result = engine.assemble(AssembleParams(session_id="", messages=messages, prompt="hello")) + assert result.messages is messages + + +def test_assemble_missing_kb_is_fail_open(tmp_path: Path) -> None: + engine = create_vouch_context_engine(workspace_dir=tmp_path) + result = engine.assemble( + AssembleParams(session_id="", messages=[], prompt="anything") + ) + assert result.meta.get("kb_found") is False + assert "vouch init" in (result.system_prompt_addition or "") + + +def test_format_salience_section() -> None: + block = format_salience_section( + [{"entity_id": "ent-1", "claim_count": 2, "top_claim_id": "c-1"}], + store_names={"ent-1": "JWT"}, + ) + assert "ent-1" in block + assert "JWT" in block + + +def test_format_hot_memory_section() -> None: + block = format_hot_memory_section({ + "query": "demo task", + "agent": "alice", + "volunteered": ["c-1"], + "last_scores": {"c-1": 0.92}, + }) + assert "demo task" in block + assert "c-1" in block + + +def test_openclaw_rpc_assemble(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_claim(Claim(id="rpc-claim", text="rpc synthesis works", evidence=[src.id])) + health.rebuild_index(store) + + resp = handle_request({ + "id": "1", + "method": "assemble", + "params": { + "kbPath": str(store.root), + "sessionId": "rpc", + "prompt": "rpc synthesis", + "tokenBudget": 2000, + "messages": [{"role": "user", "content": "rpc synthesis"}], + }, + }) + assert resp["ok"] is True + assert "rpc-claim" in resp["result"]["systemPromptAddition"] + + +def test_openclaw_rpc_info() -> None: + resp = handle_request({"id": "i", "method": "info", "params": {}}) + assert resp["ok"] is True + assert resp["result"]["info"]["id"] == ENGINE_ID + + +def test_capabilities_advertises_context_engine() -> None: + from vouch.capabilities import capabilities + + caps = capabilities() + assert caps.context_engines + assert caps.context_engines[0]["id"] == ENGINE_ID diff --git a/tests/test_openclaw_plugin_manifest.py b/tests/test_openclaw_plugin_manifest.py new file mode 100644 index 0000000..182cc08 --- /dev/null +++ b/tests/test_openclaw_plugin_manifest.py @@ -0,0 +1,59 @@ +"""openclaw.plugin.json contract checks (#228).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch.openclaw.context_engine import ENGINE_ID + +REPO_ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = REPO_ROOT / "openclaw.plugin.json" +EXTENSION_PATH = REPO_ROOT / "adapters" / "openclaw" / "vouch-context-engine.mjs" + + +@pytest.fixture +def manifest() -> dict: + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def test_manifest_is_valid_json() -> None: + json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def test_manifest_declares_vouch_context_engine(manifest: dict) -> None: + engines = manifest.get("contracts", {}).get("contextEngines") or [] + assert ENGINE_ID in engines + + +def test_manifest_extension_entry_exists(manifest: dict) -> None: + extensions = manifest.get("openclaw", {}).get("extensions") or [] + assert extensions, "openclaw.extensions must list the context engine entry" + rel = extensions[0] + assert (REPO_ROOT / rel).is_file() + + +def test_extension_file_exports_engine_id() -> None: + text = EXTENSION_PATH.read_text(encoding="utf-8") + assert "vouch-context" in text + assert "registerContextEngine" in text + + +def test_manifest_mcp_and_context_contracts(manifest: dict) -> None: + contracts = manifest.get("contracts") or {} + assert "reviewGatedKB" in contracts + assert "mcpMethods" in contracts + assert "kb.context" in contracts["mcpMethods"] + + +def test_manifest_openclaw_compat_floor(manifest: dict) -> None: + compat = manifest.get("openclaw", {}).get("compat") or {} + assert compat.get("pluginApi") + + +def test_manifest_trust_boundary(manifest: dict) -> None: + tb = manifest.get("openclaw", {}).get("trust_boundary") or {} + assert tb.get("write_tools_review_gated") is True + assert tb.get("remote_callers_filesystem") == "confined" From 00812457b2c49343d319007c25182b491114cafa Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 17 Jun 2026 00:39:32 -0500 Subject: [PATCH 2/2] fix: update --- schemas/capabilities.schema.json | 9 +++++++++ schemas/relation.schema.json | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index 618a02d..b986604 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -3,6 +3,15 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Returned by the server in response to `kb.capabilities`.", "properties": { + "context_engines": { + "description": "OpenClaw context engines exposed (see openclaw.plugin.json)", + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Context Engines", + "type": "array" + }, "knowledge_capability": { "additionalProperties": true, "title": "Knowledge Capability", diff --git a/schemas/relation.schema.json b/schemas/relation.schema.json index 561a5be..0185d7f 100644 --- a/schemas/relation.schema.json +++ b/schemas/relation.schema.json @@ -13,7 +13,9 @@ "similar_to", "blocks", "implements", - "references" + "references", + "mentions", + "relates_to" ], "title": "RelationType", "type": "string"