diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6a6f026c749b..5d69cb09c693 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -72,6 +72,7 @@ from hermes_cli.config import ( from hermes_cli.memory_providers import ( MemoryProvider, ProviderField, + STORAGE_HONCHO_HOST_BLOCK, get_memory_provider, ) from gateway.status import ( @@ -3874,12 +3875,115 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: return config -def _memory_provider_config_path(provider: MemoryProvider) -> Path: +# ── Memory provider config ────────────────────────────────────────────────── +# One generic GET/PUT pair drives every declared provider. Reads/writes dispatch +# on ``provider.storage`` so a new simple provider is pure declaration, while +# Honcho persists to its real host-scoped config without bespoke endpoints. + + +def _provider_field_entry(field: ProviderField) -> Dict[str, Any]: + """Static, storage-independent shape of one field for the UI payload.""" + + return { + "key": field.key, + "label": field.label, + "kind": field.kind, + "description": field.description, + "placeholder": field.placeholder, + "inline": field.inline, + "group": field.group, + "options": [ + {"value": opt.value, "label": opt.label, "description": opt.description} + for opt in field.options + ], + } + + +# Sentinel: a coerced value of _UNSET means "remove this key" (blank text / +# number / json falls back to the host or built-in default). +_UNSET: Any = object() + +_TRUTHY = {"1", "true", "yes", "on"} + + +def _coerce_field_value(field: ProviderField, raw: str) -> Any: + """Coerce a submitted non-secret value to its native JSON type. + + Values arrive as strings over the API; this converts them to the type the + Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored + as a JSON ``false`` rather than the string ``"false"`` (which would read as + truthy). Returns ``_UNSET`` when the field should be removed. Raises + ``ValueError`` on malformed input. + """ + + value = (raw or "").strip() + kind = field.kind + + if kind == "select": + if not value: + value = field.default + if value not in field.allowed_values(): + raise ValueError(f"Invalid value for '{field.key}'") + return value + + if kind == "bool": + return value.lower() in _TRUTHY + + if kind == "number": + if not value: + return _UNSET + try: + number = float(value) + except ValueError as exc: + raise ValueError(f"Invalid number for '{field.key}'") from exc + return int(number) if number.is_integer() else number + + if kind == "json": + if not value: + return _UNSET + try: + parsed = json.loads(value) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid JSON for '{field.key}'") from exc + if not isinstance(parsed, (dict, list)): + raise ValueError(f"'{field.key}' must be a JSON object or array") + return parsed + + # text / secret — blank clears the key so it falls back to host/default. + return value if value else _UNSET + + +def _serialize_field_value(field: ProviderField, value: Any) -> str: + """Render a stored native value as the string the generic UI edits. + + ``None`` (key absent) yields the field's declared default. Bools become + ``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are + stringified — so the renderer's per-kind controls always get the shape they + expect regardless of how the value sits on disk. + """ + + if value is None: + return field.default + if field.kind == "bool": + if isinstance(value, str): + return "true" if value.strip().lower() in _TRUTHY else "false" + return "true" if value else "false" + if field.kind == "json": + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + return str(value) + + +# — flat-json backend (default; reusable for simple providers) — + + +def _flat_json_path(provider: MemoryProvider) -> Path: return get_hermes_home() / provider.name / "config.json" -def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: - path = _memory_provider_config_path(provider) +def _read_flat_json(provider: MemoryProvider) -> Dict[str, Any]: + path = _flat_json_path(provider) if not path.exists(): return {} try: @@ -3890,26 +3994,25 @@ def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: return data if isinstance(data, dict) else {} -def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str: - """Resolve the stored value for a non-secret field, honoring legacy reads.""" +def _read_flat_field(field: ProviderField, data: Dict[str, Any]) -> Any: + """Return the stored native value, or ``None`` when unset. + + Presence (``key in data``) decides, not truthiness, so a stored ``False`` or + ``0`` survives instead of being mistaken for "unset". + """ for source_key in (field.key, *field.aliases): - value = data.get(source_key) - if value: - return str(value) - + if source_key in data and data[source_key] is not None: + return data[source_key] env_on_disk = load_env() for env_key in field.env_fallbacks: value = env_on_disk.get(env_key) if value: - return str(value) - - return field.default + return value + return None -def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: - """Whether a secret field has a value anywhere it may have been written.""" - +def _flat_field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: env_on_disk = load_env() for env_key in (field.env_key, *field.env_fallbacks): if env_key and env_on_disk.get(env_key): @@ -3917,50 +4020,173 @@ def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: return any(data.get(source_key) for source_key in (field.key, *field.aliases)) +# — honcho host-block backend — + + +def _honcho_resolvers(): + """Lazily import the Honcho plugin's resolvers (optional plugin).""" + + from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path + + return resolve_active_host, resolve_config_path, _host_block + + +def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]: + """Return (root config, active host key, host block) for the current profile.""" + + resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers() + host = resolve_active_host() + path = resolve_config_path() + raw: Dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + raw = loaded if isinstance(loaded, dict) else {} + except Exception: + _log.warning("Failed to read Honcho config from %s", path, exc_info=True) + return raw, host, host_block_of(raw, host) + + +def _read_honcho_field(field: ProviderField, raw: Dict[str, Any], host_block: Dict[str, Any]) -> Any: + """Return the stored native value for a host-block field, or ``None``. + + Host scope checks the per-profile block before the config root; presence + wins over truthiness so ``False``/``0`` survive. + """ + + sources = (host_block, raw) if field.scope == "host" else (raw,) + for source in sources: + for source_key in (field.key, *field.aliases): + if source_key in source and source[source_key] is not None: + return source[source_key] + env_on_disk = load_env() + for env_key in field.env_fallbacks: + value = env_on_disk.get(env_key) + if value: + return value + return None + + +def _honcho_field_is_set(field: ProviderField, raw: Dict[str, Any], host_block: Dict[str, Any]) -> bool: + env_on_disk = load_env() + for env_key in (field.env_key, *field.env_fallbacks): + if env_key and env_on_disk.get(env_key): + return True + sources = (host_block, raw) if field.scope == "host" else (raw,) + 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]: - data = _read_memory_provider_file(provider) fields: List[Dict[str, Any]] = [] + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + raw, host, host_block = _honcho_read_sources() + else: + data = _read_flat_json(provider) + for field in provider.fields: - entry: Dict[str, Any] = { - "key": field.key, - "label": field.label, - "kind": field.kind, - "description": field.description, - "placeholder": field.placeholder, - "options": [ - {"value": opt.value, "label": opt.label, "description": opt.description} - for opt in field.options - ], - } + entry = _provider_field_entry(field) if field.is_secret: - # Secrets are write-only over the API; only expose whether one is set. - entry["value"] = "" - entry["is_set"] = _field_is_set(field, data) - else: - value = _read_field_value(field, data) - if field.kind == "select" and value not in field.allowed_values(): - value = field.default - entry["value"] = value - entry["is_set"] = bool(value) + entry["value"] = "" # secrets are write-only over the API + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + entry["is_set"] = _honcho_field_is_set(field, raw, host_block) + else: + entry["is_set"] = _flat_field_is_set(field, data) + fields.append(entry) + continue + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + native = _read_honcho_field(field, raw, host_block) + # Surface the resolved host so the user sees the peer mapping + # Honcho will actually use when these fields are left blank. + if not field.placeholder and field.key in {"workspace", "aiPeer"}: + entry["placeholder"] = host + else: + native = _read_flat_field(field, data) + + value = _serialize_field_value(field, native) + if field.kind == "select" and value not in field.allowed_values(): + value = field.default + entry["value"] = value + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + entry["is_set"] = _honcho_field_is_set(field, raw, host_block) + else: + entry["is_set"] = bool(value) fields.append(entry) - return {"name": provider.name, "label": provider.label, "fields": fields} + return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields} -def _coerce_field_value(field: ProviderField, raw: str) -> str: - """Validate and normalize a submitted non-secret value, or raise ValueError.""" +def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> None: + from utils import atomic_json_write - value = (raw or "").strip() - if field.kind == "select": - if not value: - value = field.default - if value not in field.allowed_values(): - raise ValueError(f"Invalid value for '{field.key}'") - return value - return value or field.default + existing = _read_flat_json(provider) + + for field in provider.fields: + if field.is_secret: + submitted = (values.get(field.key) or "").strip() + if submitted and field.env_key: + save_env_value(field.env_key, submitted) + continue + if field.key not in values: + continue + coerced = _coerce_field_value(field, values[field.key]) + if coerced is _UNSET: + existing.pop(field.key, None) + for alias in field.aliases: + existing.pop(alias, None) + else: + existing[field.key] = coerced + + path = _flat_json_path(provider) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, existing, mode=0o600) + + +def _write_provider_honcho(provider: MemoryProvider, 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 + inline panel) never clobbers fields owned by the full-config editor. Blank + text clears a key so it falls back to the host/default mapping. + """ + + from utils import atomic_json_write + + resolve_active_host, _resolve_config_path, _host_block = _honcho_resolvers() + host = resolve_active_host() + + path = get_hermes_home() / "honcho.json" + cfg: Dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + cfg = loaded if isinstance(loaded, dict) else {} + except Exception: + _log.warning("Failed to read Honcho config from %s", path, exc_info=True) + + host_block = cfg.setdefault("hosts", {}).setdefault(host, {}) + + for field in provider.fields: + if field.is_secret: + submitted = (values.get(field.key) or "").strip() + if submitted and field.env_key: + save_env_value(field.env_key, submitted) + continue + if field.key not in values: + continue + target = host_block if field.scope == "host" else cfg + coerced = _coerce_field_value(field, values[field.key]) + if coerced is _UNSET: + target.pop(field.key, None) + for alias in field.aliases: + target.pop(alias, None) + else: + target[field.key] = coerced + + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, cfg, mode=0o600) @app.get("/api/memory/providers/{name}/config") @@ -3969,7 +4195,7 @@ async def get_memory_provider_config(name: str): if provider is None: # Undeclared providers (e.g. builtin) have no config surface. Return an # empty schema so the generic panel simply renders nothing. - return {"name": name, "label": name, "fields": []} + return {"name": name, "label": name, "docs_url": "", "fields": []} return _memory_provider_payload(provider) @@ -3982,23 +4208,10 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd values = body.values or {} try: - existing = _read_memory_provider_file(provider) - json_values: Dict[str, Any] = {} - secrets: Dict[str, str] = {} - - for field in provider.fields: - if field.is_secret: - submitted = (values.get(field.key) or "").strip() - if submitted and field.env_key: - secrets[field.env_key] = submitted - continue - - raw = ( - values[field.key] - if field.key in values - else str(existing.get(field.key, field.default)) - ) - json_values[field.key] = _coerce_field_value(field, raw) + if provider.storage == STORAGE_HONCHO_HOST_BLOCK: + _write_provider_honcho(provider, values) + else: + _write_provider_flat(provider, values) config = load_config() memory_config = config.get("memory") @@ -4008,16 +4221,6 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd memory_config["provider"] = provider.name save_config(config) - path = _memory_provider_config_path(provider) - path.parent.mkdir(parents=True, exist_ok=True) - existing.update(json_values) - from utils import atomic_json_write - - atomic_json_write(path, existing, mode=0o600) - - for env_key, secret in secrets.items(): - save_env_value(env_key, secret) - return {"ok": True} except HTTPException: raise