diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5d69cb09c693..2fc695ef4a56 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -69,11 +69,11 @@ from hermes_cli.config import ( write_platform_config_field, _deep_merge, ) -from hermes_cli.memory_providers import ( - MemoryProvider, +from plugins.memory.config_schema import ( + ProviderConfigSchema, ProviderField, STORAGE_HONCHO_HOST_BLOCK, - get_memory_provider, + get_provider_config_schema, ) from gateway.status import ( derive_gateway_busy, @@ -3978,11 +3978,11 @@ def _serialize_field_value(field: ProviderField, value: Any) -> str: # — flat-json backend (default; reusable for simple providers) — -def _flat_json_path(provider: MemoryProvider) -> Path: +def _flat_json_path(provider: ProviderConfigSchema) -> Path: return get_hermes_home() / provider.name / "config.json" -def _read_flat_json(provider: MemoryProvider) -> Dict[str, Any]: +def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]: path = _flat_json_path(provider) if not path.exists(): return {} @@ -4076,7 +4076,7 @@ def _honcho_field_is_set(field: ProviderField, raw: Dict[str, Any], host_block: return any(source.get(k) for source in sources for k in (field.key, *field.aliases)) -def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: +def _memory_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]: fields: List[Dict[str, Any]] = [] if provider.storage == STORAGE_HONCHO_HOST_BLOCK: @@ -4118,7 +4118,7 @@ def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields} -def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> None: +def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: from utils import atomic_json_write existing = _read_flat_json(provider) @@ -4144,7 +4144,7 @@ def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> No atomic_json_write(path, existing, mode=0o600) -def _write_provider_honcho(provider: MemoryProvider, values: Dict[str, str]) -> None: +def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: """Persist submitted fields to Honcho's real config for the active host. Only keys present in ``values`` are touched, so a partial save (e.g. the @@ -4191,7 +4191,7 @@ def _write_provider_honcho(provider: MemoryProvider, values: Dict[str, str]) -> @app.get("/api/memory/providers/{name}/config") async def get_memory_provider_config(name: str): - provider = get_memory_provider(name) + provider = get_provider_config_schema(name) if provider is None: # Undeclared providers (e.g. builtin) have no config surface. Return an # empty schema so the generic panel simply renders nothing. @@ -4201,7 +4201,7 @@ async def get_memory_provider_config(name: str): @app.put("/api/memory/providers/{name}/config") async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): - provider = get_memory_provider(name) + provider = get_provider_config_schema(name) if provider is None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") diff --git a/plugins/memory/config_schema.py b/plugins/memory/config_schema.py new file mode 100644 index 000000000000..d0db037e90bb --- /dev/null +++ b/plugins/memory/config_schema.py @@ -0,0 +1,138 @@ +"""Declarative configuration schema for memory provider plugins. + +Each memory provider plugin *declares* its configurable surface in a +``config_schema.py`` next to its ``__init__.py`` — the fields, their types, +which values are secrets, and (for selects) the allowed options. A single +generic renderer in the desktop UI and a single generic ``GET/PUT +/api/memory/providers/{name}/config`` endpoint pair drive the whole +experience, so adding a provider config surface is pure declaration with no +bespoke UI components. + +Schema files are loaded by path (like the provider plugins themselves), never +via package import: plugin ``__init__.py`` files pull in the agent runtime, +which must not load into the web server. A ``config_schema.py`` may only +import from this module. + +This module is intentionally pure data: it imports nothing from the +config/env layer. ``web_server`` owns the generic read/write logic that +interprets these declarations, dispatching on ``ProviderConfigSchema.storage`` +to the matching backend. +""" + +from __future__ import annotations + +import importlib.util +import logging +from dataclasses import dataclass, field as dataclass_field + +_log = logging.getLogger(__name__) + +# Field kinds understood by the generic renderer. +KIND_TEXT = "text" +KIND_SELECT = "select" +KIND_SECRET = "secret" +KIND_BOOL = "bool" +KIND_NUMBER = "number" +KIND_JSON = "json" + +# Storage backends understood by web_server. ``flat_json`` persists non-secret +# fields to ``//config.json``; ``honcho_host_block`` +# targets Honcho's real config (honcho.json, scoped to the active profile host). +STORAGE_FLAT_JSON = "flat_json" +STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block" + + +@dataclass(frozen=True) +class ProviderFieldOption: + """A single choice for a ``select`` field.""" + + value: str + label: str + description: str = "" + + +@dataclass(frozen=True) +class ProviderField: + """One configurable field on a memory provider. + + A field is stored in exactly one place, decided by ``kind``: + + * non-secret kinds — persisted to the provider's config via its storage + backend under ``key``. + * ``secret`` — persisted to the env store under ``env_key`` and never read + back out over the API (only an ``is_set`` flag is surfaced). + + ``aliases`` and ``env_fallbacks`` let a field read legacy values written by + earlier CLI/env setup without re-introducing per-provider code. ``inline`` + marks the curated subset shown in the compact panel; the rest surface only + in the full-config modal. ``group`` buckets fields within that modal. + """ + + key: str + label: str + kind: str = KIND_TEXT + default: str = "" + description: str = "" + placeholder: str = "" + options: tuple[ProviderFieldOption, ...] = () + env_key: str | None = None + aliases: tuple[str, ...] = () + env_fallbacks: tuple[str, ...] = () + inline: bool = False + group: str = "" + # Where a host-block backend stores the field: "host" (per-profile host + # block) or "root" (config root). Ignored by the flat-json backend. + scope: str = "host" + + @property + def is_secret(self) -> bool: + return self.kind == KIND_SECRET + + def allowed_values(self) -> set[str]: + return {opt.value for opt in self.options} + + +@dataclass(frozen=True) +class ProviderConfigSchema: + """A provider plugin's declared config surface.""" + + name: str + label: str + storage: str = STORAGE_FLAT_JSON + # Optional link to the provider's config docs, shown in the full-config modal. + docs_url: str = "" + fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple) + + def inline_fields(self) -> tuple[ProviderField, ...]: + return tuple(f for f in self.fields if f.inline) + + +_SCHEMA_CACHE: dict[str, ProviderConfigSchema | None] = {} + + +def get_provider_config_schema(name: str) -> ProviderConfigSchema | None: + """Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``. + + Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None`` + and simply render no config panel. + """ + + if name in _SCHEMA_CACHE: + return _SCHEMA_CACHE[name] + + from plugins.memory import find_provider_dir + + schema: ProviderConfigSchema | None = None + provider_dir = find_provider_dir(name) + path = provider_dir / "config_schema.py" if provider_dir else None + if path is not None and path.is_file(): + try: + spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + schema = getattr(module, "CONFIG_SCHEMA", None) + except Exception: + _log.exception("failed to load config schema for memory provider %r", name) + + _SCHEMA_CACHE[name] = schema + return schema diff --git a/plugins/memory/hindsight/config_schema.py b/plugins/memory/hindsight/config_schema.py new file mode 100644 index 000000000000..a6e7ef9ee76e --- /dev/null +++ b/plugins/memory/hindsight/config_schema.py @@ -0,0 +1,76 @@ +"""Hindsight's declared config surface — rendered by the generic desktop panel.""" + +from plugins.memory.config_schema import ( + KIND_SECRET, + KIND_SELECT, + KIND_TEXT, + ProviderConfigSchema, + ProviderField, + ProviderFieldOption, +) + +CONFIG_SCHEMA = ProviderConfigSchema( + name="hindsight", + label="Hindsight", + fields=( + ProviderField( + key="mode", + label="Mode", + kind=KIND_SELECT, + default="cloud", + description="How Hermes connects to Hindsight.", + options=( + ProviderFieldOption( + "cloud", + "Cloud", + "Hindsight Cloud API (lightweight, just needs an API key)", + ), + ProviderFieldOption( + "local_external", + "Local External", + "Connect to an existing Hindsight instance", + ), + ), + inline=True, + ), + ProviderField( + key="api_key", + label="API key", + kind=KIND_SECRET, + env_key="HINDSIGHT_API_KEY", + description="Used to authenticate with the Hindsight API.", + placeholder="Enter Hindsight API key", + inline=True, + ), + ProviderField( + key="api_url", + label="API URL", + kind=KIND_TEXT, + default="https://api.hindsight.vectorize.io", + aliases=("apiUrl",), + env_fallbacks=("HINDSIGHT_API_URL",), + inline=True, + ), + ProviderField( + key="bank_id", + label="Bank ID", + kind=KIND_TEXT, + default="hermes", + aliases=("bankId",), + inline=True, + ), + ProviderField( + key="recall_budget", + label="Recall budget", + kind=KIND_SELECT, + default="mid", + aliases=("budget",), + options=( + ProviderFieldOption("low", "low"), + ProviderFieldOption("mid", "mid"), + ProviderFieldOption("high", "high"), + ), + inline=True, + ), + ), +) diff --git a/hermes_cli/memory_providers.py b/plugins/memory/honcho/config_schema.py similarity index 63% rename from hermes_cli/memory_providers.py rename to plugins/memory/honcho/config_schema.py index 2ec7d0f73f63..68aff1c6a720 100644 --- a/hermes_cli/memory_providers.py +++ b/plugins/memory/honcho/config_schema.py @@ -1,98 +1,17 @@ -"""Declarative configuration schema for desktop memory providers. +"""Honcho's declared config surface — rendered by the generic desktop panel.""" -Each memory provider *declares* its configurable surface here — the fields, their -types, which values are secrets, and (for selects) the allowed options. A single -generic renderer in the desktop UI and a single generic ``GET/PUT -/api/memory/providers/{name}/config`` endpoint pair drive the whole experience, -so adding a new provider is pure declaration with no bespoke UI components. - -This module is intentionally pure data: it imports nothing from the config/env -layer. ``web_server`` owns the generic read/write logic that interprets these -declarations, dispatching on ``MemoryProvider.storage`` to the matching backend. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field as dataclass_field - -# Field kinds understood by the generic renderer. -KIND_TEXT = "text" -KIND_SELECT = "select" -KIND_SECRET = "secret" -KIND_BOOL = "bool" -KIND_NUMBER = "number" -KIND_JSON = "json" - -# Storage backends understood by web_server. ``flat_json`` persists non-secret -# fields to ``//config.json``; ``honcho_host_block`` -# targets Honcho's real config (honcho.json, scoped to the active profile host). -STORAGE_FLAT_JSON = "flat_json" -STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block" - - -@dataclass(frozen=True) -class ProviderFieldOption: - """A single choice for a ``select`` field.""" - - value: str - label: str - description: str = "" - - -@dataclass(frozen=True) -class ProviderField: - """One configurable field on a memory provider. - - A field is stored in exactly one place, decided by ``kind``: - - * non-secret kinds — persisted to the provider's config via its storage - backend under ``key``. - * ``secret`` — persisted to the env store under ``env_key`` and never read - back out over the API (only an ``is_set`` flag is surfaced). - - ``aliases`` and ``env_fallbacks`` let a field read legacy values written by - earlier CLI/env setup without re-introducing per-provider code. ``inline`` - marks the curated subset shown in the compact panel; the rest surface only - in the full-config modal. ``group`` buckets fields within that modal. - """ - - key: str - label: str - kind: str = KIND_TEXT - default: str = "" - description: str = "" - placeholder: str = "" - options: tuple[ProviderFieldOption, ...] = () - env_key: str | None = None - aliases: tuple[str, ...] = () - env_fallbacks: tuple[str, ...] = () - inline: bool = False - group: str = "" - # Where a host-block backend stores the field: "host" (per-profile host - # block) or "root" (config root). Ignored by the flat-json backend. - scope: str = "host" - - @property - def is_secret(self) -> bool: - return self.kind == KIND_SECRET - - def allowed_values(self) -> set[str]: - return {opt.value for opt in self.options} - - -@dataclass(frozen=True) -class MemoryProvider: - """A declared memory provider and its configurable fields.""" - - name: str - label: str - storage: str = STORAGE_FLAT_JSON - # Optional link to the provider's config docs, shown in the full-config modal. - docs_url: str = "" - fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple) - - def inline_fields(self) -> tuple[ProviderField, ...]: - return tuple(f for f in self.fields if f.inline) +from plugins.memory.config_schema import ( + KIND_BOOL, + KIND_JSON, + KIND_NUMBER, + KIND_SECRET, + KIND_SELECT, + KIND_TEXT, + STORAGE_HONCHO_HOST_BLOCK, + ProviderConfigSchema, + ProviderField, + ProviderFieldOption, +) # Reasoning effort levels shared by dialectic-related selects. @@ -105,7 +24,7 @@ _REASONING_LEVELS = ( ) -HONCHO = MemoryProvider( +CONFIG_SCHEMA = ProviderConfigSchema( name="honcho", label="Honcho", storage=STORAGE_HONCHO_HOST_BLOCK, @@ -387,84 +306,3 @@ HONCHO = MemoryProvider( ), ), ) - - -HINDSIGHT = MemoryProvider( - name="hindsight", - label="Hindsight", - fields=( - ProviderField( - key="mode", - label="Mode", - kind=KIND_SELECT, - default="cloud", - description="How Hermes connects to Hindsight.", - options=( - ProviderFieldOption( - "cloud", - "Cloud", - "Hindsight Cloud API (lightweight, just needs an API key)", - ), - ProviderFieldOption( - "local_external", - "Local External", - "Connect to an existing Hindsight instance", - ), - ), - inline=True, - ), - ProviderField( - key="api_key", - label="API key", - kind=KIND_SECRET, - env_key="HINDSIGHT_API_KEY", - description="Used to authenticate with the Hindsight API.", - placeholder="Enter Hindsight API key", - inline=True, - ), - ProviderField( - key="api_url", - label="API URL", - kind=KIND_TEXT, - default="https://api.hindsight.vectorize.io", - aliases=("apiUrl",), - env_fallbacks=("HINDSIGHT_API_URL",), - inline=True, - ), - ProviderField( - key="bank_id", - label="Bank ID", - kind=KIND_TEXT, - default="hermes", - aliases=("bankId",), - inline=True, - ), - ProviderField( - key="recall_budget", - label="Recall budget", - kind=KIND_SELECT, - default="mid", - aliases=("budget",), - options=( - ProviderFieldOption("low", "low"), - ProviderFieldOption("mid", "mid"), - ProviderFieldOption("high", "high"), - ), - inline=True, - ), - ), -) - - -# Registry of providers that expose a desktop config surface. Providers without -# an entry here (e.g. ``builtin``) simply render no config panel. Honcho leads. -MEMORY_PROVIDERS: dict[str, MemoryProvider] = { - HONCHO.name: HONCHO, - HINDSIGHT.name: HINDSIGHT, -} - - -def get_memory_provider(name: str) -> MemoryProvider | None: - """Return the declared provider for ``name``, or ``None`` if undeclared.""" - - return MEMORY_PROVIDERS.get(name) diff --git a/tests/plugins/memory/test_config_schema.py b/tests/plugins/memory/test_config_schema.py new file mode 100644 index 000000000000..2d1c70db3478 --- /dev/null +++ b/tests/plugins/memory/test_config_schema.py @@ -0,0 +1,16 @@ +"""Tests for config-schema loading from memory provider plugin dirs.""" + +from plugins.memory.config_schema import get_provider_config_schema + + +def test_unknown_provider_is_none(): + assert get_provider_config_schema("builtin") is None + + +def test_plugin_without_schema_is_none(): + # mem0 is a real plugin dir that declares no config_schema.py. + assert get_provider_config_schema("mem0") is None + + +def test_schemas_are_cached_per_provider(): + assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho") diff --git a/tests/plugins/memory/test_hindsight_config_schema.py b/tests/plugins/memory/test_hindsight_config_schema.py new file mode 100644 index 000000000000..a521ee5d2d33 --- /dev/null +++ b/tests/plugins/memory/test_hindsight_config_schema.py @@ -0,0 +1,51 @@ +"""Tests for Hindsight's declared config surface.""" + +from plugins.memory.config_schema import ( + KIND_SECRET, + KIND_SELECT, + get_provider_config_schema, +) + + +def test_hindsight_is_declared(): + provider = get_provider_config_schema("hindsight") + + assert provider is not None + assert provider.label == "Hindsight" + assert {field.key for field in provider.fields} == { + "mode", + "api_key", + "api_url", + "bank_id", + "recall_budget", + } + + +def test_fields_are_all_inline(): + provider = get_provider_config_schema("hindsight") + assert provider is not None + + # Hindsight is simple enough to render fully in the compact panel, so it + # never grows a Full config… modal. + assert all(field.inline for field in provider.fields) + + +def test_mode_gating_is_expressed_as_select_options(): + provider = get_provider_config_schema("hindsight") + assert provider is not None + + mode = next(field for field in provider.fields if field.key == "mode") + assert mode.kind == KIND_SELECT + assert mode.allowed_values() == {"cloud", "local_external"} + # local_embedded is intentionally unsupported on desktop. + assert "local_embedded" not in mode.allowed_values() + + +def test_api_key_is_a_secret_bound_to_env(): + provider = get_provider_config_schema("hindsight") + assert provider is not None + + api_key = next(field for field in provider.fields if field.key == "api_key") + assert api_key.kind == KIND_SECRET + assert api_key.is_secret is True + assert api_key.env_key == "HINDSIGHT_API_KEY" diff --git a/tests/hermes_cli/test_memory_providers.py b/tests/plugins/memory/test_honcho_config_schema.py similarity index 51% rename from tests/hermes_cli/test_memory_providers.py rename to tests/plugins/memory/test_honcho_config_schema.py index 518f45b1595e..8ed43e8ac3e8 100644 --- a/tests/hermes_cli/test_memory_providers.py +++ b/tests/plugins/memory/test_honcho_config_schema.py @@ -1,14 +1,13 @@ -"""Tests for the declarative memory-provider registry.""" +"""Tests for Honcho's declared config surface.""" -from hermes_cli.memory_providers import ( +from plugins.memory.config_schema import ( KIND_BOOL, KIND_JSON, KIND_NUMBER, KIND_SECRET, KIND_SELECT, - MEMORY_PROVIDERS, STORAGE_HONCHO_HOST_BLOCK, - get_memory_provider, + get_provider_config_schema, ) # The curated set shown in the compact panel; everything else lives in the modal. @@ -23,12 +22,8 @@ INLINE_KEYS = { } -def test_registry_lists_honcho_before_hindsight(): - assert list(MEMORY_PROVIDERS) == ["honcho", "hindsight"] - - def test_honcho_is_declared(): - provider = get_memory_provider("honcho") + provider = get_provider_config_schema("honcho") assert provider is not None assert provider.label == "Honcho" @@ -39,8 +34,8 @@ def test_honcho_is_declared(): assert INLINE_KEYS <= set(keys) -def test_honcho_inline_fields_are_the_curated_subset(): - provider = get_memory_provider("honcho") +def test_inline_fields_are_the_curated_subset(): + provider = get_provider_config_schema("honcho") assert provider is not None assert {field.key for field in provider.inline_fields()} == INLINE_KEYS @@ -49,8 +44,8 @@ def test_honcho_inline_fields_are_the_curated_subset(): assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline -def test_honcho_declares_the_new_field_kinds(): - provider = get_memory_provider("honcho") +def test_declares_the_new_field_kinds(): + provider = get_provider_config_schema("honcho") assert provider is not None by_key = {f.key: f for f in provider.fields} @@ -61,8 +56,8 @@ def test_honcho_declares_the_new_field_kinds(): assert by_key["observationMode"].allowed_values() == {"directional", "unified"} -def test_honcho_selects_constrain_their_values(): - provider = get_memory_provider("honcho") +def test_selects_constrain_their_values(): + provider = get_provider_config_schema("honcho") assert provider is not None environment = next(f for f in provider.fields if f.key == "environment") @@ -74,8 +69,8 @@ def test_honcho_selects_constrain_their_values(): assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"} -def test_honcho_api_key_is_a_secret_bound_to_env(): - provider = get_memory_provider("honcho") +def test_api_key_is_a_secret_bound_to_env(): + provider = get_provider_config_schema("honcho") assert provider is not None api_key = next(f for f in provider.fields if f.key == "apiKey") @@ -84,8 +79,8 @@ def test_honcho_api_key_is_a_secret_bound_to_env(): assert api_key.env_key == "HONCHO_API_KEY" -def test_honcho_root_scoped_fields_are_exactly_the_global_ones(): - provider = get_memory_provider("honcho") +def test_root_scoped_fields_are_exactly_the_global_ones(): + provider = get_provider_config_schema("honcho") assert provider is not None scopes = {f.key: f.scope for f in provider.fields} @@ -93,51 +88,3 @@ def test_honcho_root_scoped_fields_are_exactly_the_global_ones(): # baseUrl, timeout and sessions live at the config root in Honcho's schema; # everything else is per-profile host-scoped. assert root_keys == {"baseUrl", "timeout", "sessions"} - - -def test_hindsight_is_declared(): - provider = get_memory_provider("hindsight") - - assert provider is not None - assert provider.label == "Hindsight" - assert {field.key for field in provider.fields} == { - "mode", - "api_key", - "api_url", - "bank_id", - "recall_budget", - } - - -def test_hindsight_fields_are_all_inline(): - provider = get_memory_provider("hindsight") - assert provider is not None - - # Hindsight is simple enough to render fully in the compact panel, so it - # never grows a Full config… modal. - assert all(field.inline for field in provider.fields) - - -def test_hindsight_mode_gating_is_expressed_as_select_options(): - provider = get_memory_provider("hindsight") - assert provider is not None - - mode = next(field for field in provider.fields if field.key == "mode") - assert mode.kind == KIND_SELECT - assert mode.allowed_values() == {"cloud", "local_external"} - # local_embedded is intentionally unsupported on desktop. - assert "local_embedded" not in mode.allowed_values() - - -def test_hindsight_api_key_is_a_secret_bound_to_env(): - provider = get_memory_provider("hindsight") - assert provider is not None - - api_key = next(field for field in provider.fields if field.key == "api_key") - assert api_key.kind == KIND_SECRET - assert api_key.is_secret is True - assert api_key.env_key == "HINDSIGHT_API_KEY" - - -def test_unknown_provider_is_none(): - assert get_memory_provider("builtin") is None