Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions agent/memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,47 @@ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
should all have ``env_var`` set and this method stays no-op).
"""

def read_config(self, hermes_home: str) -> Dict[str, Dict[str, Any]]:
"""Read current config values for the Desktop config panel.

Returns ``{field_key: {"value": str, "is_set": bool}}``. Secret fields
ALWAYS return ``value == ""`` (write-only) — only ``is_set`` is exposed.

The default reads the conventional ``<hermes_home>/<name>/config.json``
plus env vars for secrets, driven by ``get_config_schema()``. Providers
with non-conventional storage (host-keyed files, camelCase keys,
multi-file resolution — e.g. Honcho's ``honcho.json``) override this to
route through their own resolution logic.
"""
import json
import os
from pathlib import Path

path = Path(hermes_home) / self.name / "config.json"
data: Dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
data = loaded
except Exception:
logger.warning("Failed to read provider config from %s", path, exc_info=True)

state: Dict[str, Dict[str, Any]] = {}
for field in self.get_config_schema() or []:
key = field.get("key")
if not key:
continue
if field.get("secret"):
env_var = field.get("env_var")
state[key] = {"value": "", "is_set": bool(env_var and os.environ.get(env_var))}
continue
value = data.get(key)
if value in (None, ""):
value = field.get("default", "")
state[key] = {"value": str(value), "is_set": value not in (None, "")}
return state

def on_memory_write(
self,
action: str,
Expand Down
150 changes: 150 additions & 0 deletions hermes_cli/memory_provider_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Desktop config surface for memory providers.

A single module that turns a provider's declarative ``get_config_schema()`` into
the field vocabulary the Desktop settings panel renders from, and assembles the
GET payload. Providers already declare their fields (for ``hermes memory setup``)
and persist them (``save_config``) and read them back (``read_config``); this
layer is purely how Desktop *presents* that config — labels, field kinds, tiers,
options, and conditional (``when``) visibility.

It lives in ``hermes_cli/`` (next to its only consumer, ``web_server``) so the
presentation vocabulary stays out of the core runtime in ``agent/``.
"""

from __future__ import annotations

from typing import Any, Dict, List

# Field kinds understood by the generic Desktop renderer.
KIND_TEXT = "text"
KIND_SECRET = "secret"
KIND_SELECT = "select"
KIND_BOOL = "bool"
KIND_NUMBER = "number"

_VALID_KINDS = {KIND_TEXT, KIND_SECRET, KIND_SELECT, KIND_BOOL, KIND_NUMBER}

# Field tiers. Tier is a GROUPING, not a lock — ``advanced`` fields are still
# editable in Desktop (desktop users may not know the CLI wizard exists);
# they just render under an "Advanced" disclosure with confirm-on-change.
TIER_SAFE = "safe"
TIER_ADVANCED = "advanced"

_VALID_TIERS = {TIER_SAFE, TIER_ADVANCED}


def _prettify(key: str) -> str:
"""``api_key`` / ``baseUrl`` -> ``Api Key`` / ``Base Url`` for a label."""
spaced: List[str] = []
prev_lower = False
for ch in key.replace("_", " ").replace("-", " "):
if ch.isupper() and prev_lower:
spaced.append(" ")
spaced.append(ch)
prev_lower = ch.islower()
return " ".join(w.capitalize() for w in "".join(spaced).split())


def _derive_kind(field: Dict[str, Any]) -> str:
"""secret:true -> secret, choices -> select, explicit kind honored, else text."""
explicit = field.get("kind")
if isinstance(explicit, str) and explicit in _VALID_KINDS:
return explicit
if field.get("secret"):
return KIND_SECRET
if field.get("choices"):
return KIND_SELECT
return KIND_TEXT


def _derive_tier(field: Dict[str, Any]) -> str:
tier = field.get("tier")
return tier if isinstance(tier, str) and tier in _VALID_TIERS else TIER_SAFE


def field_visible(field: Dict[str, Any], values: Dict[str, str]) -> bool:
"""True if a field's ``when`` clause matches the given values.

No ``when`` -> always visible. Otherwise every key/value pair in ``when``
must equal the corresponding submitted value. Mirrors the CLI wizard's
gating so conditional fields (e.g. mode-gated Hindsight fields) behave the
same on Desktop. Accepts raw or enriched fields — both carry ``when``.
"""
when = field.get("when")
if not isinstance(when, dict) or not when:
return True
return all(str(values.get(k, "")) == str(v) for k, v in when.items())


def normalize_field(field: Dict[str, Any]) -> Dict[str, Any]:
"""Enrich one raw schema field into the Desktop field shape.

Returns ``key, label, kind, tier, description, placeholder, options,
required, env_key`` (+ ``when``/``default`` when present). ``options`` come
from an explicit ``options`` list or legacy ``choices``. Never a secret value.
"""
key = str(field.get("key", ""))
kind = _derive_kind(field)

options: List[Dict[str, str]] = []
raw_options = field.get("options")
if isinstance(raw_options, list) and raw_options:
for opt in raw_options:
if isinstance(opt, dict):
options.append({
"value": str(opt.get("value", "")),
"label": str(opt.get("label", opt.get("value", ""))),
"description": str(opt.get("description", "")),
})
else:
for choice in field.get("choices") or []:
options.append({"value": str(choice), "label": str(choice), "description": ""})

enriched: Dict[str, Any] = {
"key": key,
"label": str(field.get("label") or _prettify(key)),
"kind": kind,
"tier": _derive_tier(field),
"description": str(field.get("description", "")),
"placeholder": str(field.get("placeholder", "")),
"options": options,
"required": bool(field.get("required", False)),
# Where a secret lands on write; None for non-secret fields. Not a value.
"env_key": field.get("env_var") if kind == KIND_SECRET else None,
}

# Conditional visibility carried through verbatim; the renderer evaluates it
# live and the write path skips fields whose ``when`` doesn't match.
when = field.get("when")
if isinstance(when, dict) and when:
enriched["when"] = {str(k): str(v) for k, v in when.items()}
if "default" in field:
enriched["default"] = str(field.get("default", ""))
return enriched


def enrich_schema(schema: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Normalize a full ``get_config_schema()`` result for the Desktop renderer."""
return [normalize_field(f) for f in (schema or []) if f.get("key")]


def build_surface(provider, hermes_home: str) -> Dict[str, Any]:
"""Assemble the Desktop config payload: ``{name, label, fields}``.

Driven by the provider's ``get_config_schema()`` + ``read_config()``. Each
field carries display metadata and current state (value/is_set, secrets
masked). A provider with no config surface yields an empty ``fields`` list
and the panel renders nothing.
"""
fields = enrich_schema(provider.get_config_schema() or [])
if fields:
state = provider.read_config(hermes_home)
for field in fields:
fs = state.get(field["key"], {})
field["value"] = "" if field["kind"] == KIND_SECRET else str(fs.get("value", ""))
field["is_set"] = bool(fs.get("is_set", False))
return {
"name": provider.name,
"label": getattr(provider, "display_label", None) or provider.name.capitalize(),
"fields": fields,
}
120 changes: 105 additions & 15 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,21 +558,6 @@ class AudioTranscriptionRequest(BaseModel):
mime_type: Optional[str] = None


class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.

scope="main" → writes model.provider + model.default
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
scope="auxiliary" with task="" → applied to every auxiliary.* slot
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
"""

scope: str
provider: str
model: str
task: str = ""


_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
Expand All @@ -596,6 +581,12 @@ def _audio_extension_for_mime(mime_type: str) -> str:
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")


class MemoryProviderConfigUpdate(BaseModel):
"""Payload for PUT /api/memory/providers/{name}/config."""

values: Dict[str, str] = {}


class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.

Expand Down Expand Up @@ -1592,6 +1583,105 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
return config


@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
"""Return a provider's Desktop config surface (schema + current state).

Generic: delegates to the provider's ABC methods. Providers with no config
surface (e.g. builtin) return an empty ``fields`` list and the panel renders
nothing. Secrets are never returned, only an ``is_set`` flag.
"""
try:
from plugins.memory import load_memory_provider
from hermes_cli.memory_provider_surface import build_surface

provider = load_memory_provider(name)
if provider is None:
return {"name": name, "label": name, "fields": []}
return build_surface(provider, str(get_hermes_home()))
except Exception:
_log.exception("GET /api/memory/providers/%s/config failed", name)
raise HTTPException(status_code=500, detail="Internal server error")


@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
"""Persist a provider's config via its own ABC methods.

Validates submitted values against the provider's schema (select membership,
required), writes non-secret fields via ``save_config`` to the provider's
native location, and writes secrets to the env store under their declared
``env_var``. Does NOT change ``memory.provider`` — saving a provider's
settings is distinct from activating it.
"""
try:
from plugins.memory import load_memory_provider
from hermes_cli.memory_provider_surface import (
KIND_SECRET,
KIND_SELECT,
enrich_schema,
field_visible,
)

provider = load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")

fields = enrich_schema(provider.get_config_schema() or [])
if not fields:
raise HTTPException(status_code=404, detail=f"Provider {name} has no config surface")

values = body.values or {}
non_secret: Dict[str, str] = {}
secrets: Dict[str, str] = {}
errors: Dict[str, str] = {}

for field in fields:
key = field["key"]
# Conditional fields gated by ``when`` only apply when their clause
# matches the submitted values — mirrors the CLI wizard so e.g. the
# local_external api_url isn't persisted while mode==cloud.
if not field_visible(field, values):
continue

if field["kind"] == KIND_SECRET:
submitted = (values.get(key) or "").strip()
if submitted and field.get("env_key"):
secrets[field["env_key"]] = submitted
continue

if key not in values:
continue
raw = (values.get(key) or "").strip()

if field["kind"] == KIND_SELECT:
allowed = {opt["value"] for opt in field["options"]}
if raw and raw not in allowed:
errors[key] = f"Invalid value for '{key}'"
continue
if field.get("required") and not raw:
errors[key] = f"'{key}' is required"
continue
non_secret[key] = raw

if errors:
raise HTTPException(status_code=400, detail={"fields": errors})

hermes_home = str(get_hermes_home())
if non_secret and hasattr(provider, "save_config"):
provider.save_config(non_secret, hermes_home)

for env_key, secret in secrets.items():
save_env_value(env_key, secret)

return {"ok": True}
except HTTPException:
raise
except Exception:
_log.exception("PUT /api/memory/providers/%s/config failed", name)
raise HTTPException(status_code=500, detail="Internal server error")


@app.get("/api/config")
async def get_config():
config = _normalize_config_for_web(load_config())
Expand Down
Loading
Loading