From 94a0cb283e65e0de9605ca9464e46a8be1f0f098 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 22 Jun 2026 14:44:50 -0400 Subject: [PATCH 01/27] feat(memory): declare Honcho config schema, honcho-first registry Add the grouped Honcho provider schema (connection/identity/session/ dialectic/recall/...) with inline + full-config field split and the honcho_host_block storage backend. Keep Hindsight, mark its fields inline so the compact panel is unchanged. Registry lists Honcho before Hindsight. --- hermes_cli/memory_providers.py | 335 ++++++++++++++++++++++++++++++++- 1 file changed, 328 insertions(+), 7 deletions(-) diff --git a/hermes_cli/memory_providers.py b/hermes_cli/memory_providers.py index 9915a75f6a5f..2ec7d0f73f63 100644 --- a/hermes_cli/memory_providers.py +++ b/hermes_cli/memory_providers.py @@ -4,12 +4,11 @@ Each memory provider *declares* its configurable surface here — the fields, th 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 (mem0, honcho, ...) is pure declaration with zero -bespoke UI components or endpoints. +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 against config.yaml, the provider config file, and the env store. +declarations, dispatching on ``MemoryProvider.storage`` to the matching backend. """ from __future__ import annotations @@ -20,6 +19,15 @@ from dataclasses import dataclass, field as dataclass_field 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) @@ -37,13 +45,15 @@ class ProviderField: A field is stored in exactly one place, decided by ``kind``: - * ``text`` / ``select`` — persisted to the provider's JSON config file - (``//config.json``) under ``key``. + * 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. + 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 @@ -56,6 +66,11 @@ class ProviderField: 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: @@ -71,8 +86,308 @@ class MemoryProvider: 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) + + +# Reasoning effort levels shared by dialectic-related selects. +_REASONING_LEVELS = ( + ProviderFieldOption("minimal", "Minimal"), + ProviderFieldOption("low", "Low"), + ProviderFieldOption("medium", "Medium"), + ProviderFieldOption("high", "High"), + ProviderFieldOption("max", "Max"), +) + + +HONCHO = MemoryProvider( + name="honcho", + label="Honcho", + storage=STORAGE_HONCHO_HOST_BLOCK, + docs_url="https://docs.honcho.dev/v3/guides/integrations/hermes", + fields=( + # — Connection — + ProviderField( + key="apiKey", + label="API key", + kind=KIND_SECRET, + env_key="HONCHO_API_KEY", + description="Authenticate with Honcho Cloud. Not needed for a self-hosted base URL.", + placeholder="Enter Honcho API key", + inline=True, + group="Connection", + ), + ProviderField( + key="baseUrl", + label="Base URL", + kind=KIND_TEXT, + aliases=("base_url",), + env_fallbacks=("HONCHO_BASE_URL",), + description="Self-hosted Honcho URL. Overrides the environment when set.", + placeholder="https://… (self-hosted)", + inline=True, + group="Connection", + scope="root", + ), + ProviderField( + key="environment", + label="Environment", + kind=KIND_SELECT, + default="production", + env_fallbacks=("HONCHO_ENVIRONMENT",), + description="Honcho environment. Ignored when a base URL is set.", + options=( + ProviderFieldOption("production", "Cloud"), + ProviderFieldOption("local", "Local"), + ), + inline=True, + group="Connection", + ), + ProviderField( + key="workspace", + label="Workspace", + kind=KIND_TEXT, + description="Honcho workspace ID. Defaults to the profile host.", + inline=True, + group="Connection", + ), + # — Identity — + ProviderField( + key="peerName", + label="Peer name", + kind=KIND_TEXT, + description="Your stable user peer. Unifies memory across platforms for single-user setups.", + placeholder="e.g. eri", + inline=True, + group="Identity", + ), + ProviderField( + key="aiPeer", + label="AI peer", + kind=KIND_TEXT, + description="The AI-side peer name. Defaults to the profile host.", + inline=True, + group="Identity", + ), + # — Session — + ProviderField( + key="sessionStrategy", + label="Session strategy", + kind=KIND_SELECT, + default="per-directory", + description="How conversations map to Honcho sessions.", + options=( + ProviderFieldOption("per-directory", "Per directory"), + ProviderFieldOption("per-repo", "Per repo"), + ProviderFieldOption("per-session", "Per session"), + ProviderFieldOption("global", "Global"), + ), + inline=True, + group="Session", + ), + # —————— Full-config-only fields below (inline=False) —————— + # — Connection — + ProviderField( + key="timeout", + label="Request timeout", + kind=KIND_NUMBER, + aliases=("requestTimeout",), + env_fallbacks=("HONCHO_TIMEOUT",), + description="Request timeout in seconds for Honcho HTTP calls. Blank uses the default.", + placeholder="30", + group="Connection", + scope="root", + ), + # — Identity — + ProviderField( + key="pinUserPeer", + label="Pin user peer", + kind=KIND_BOOL, + default="false", + aliases=("pinPeerName",), + description="Pin the user peer to the peer name, ignoring gateway runtime identity. Unifies memory for single-user setups.", + group="Identity", + ), + ProviderField( + key="runtimePeerPrefix", + label="Runtime peer prefix", + kind=KIND_TEXT, + description="Prefix applied to unknown gateway runtime user IDs.", + placeholder="e.g. telegram_", + group="Identity", + ), + ProviderField( + key="userPeerAliases", + label="User peer aliases", + kind=KIND_JSON, + description="Map gateway runtime user IDs to stable Honcho peers.", + placeholder='{"telegram_123": "eri"}', + group="Identity", + ), + # — Session — + ProviderField( + key="sessionPeerPrefix", + label="Session peer prefix", + kind=KIND_BOOL, + default="false", + description="Prefix session peer names with the host.", + group="Session", + ), + ProviderField( + key="sessions", + label="Session overrides", + kind=KIND_JSON, + description="Explicit session ID overrides keyed by resolver.", + placeholder='{"key": "session-id"}', + group="Session", + scope="root", + ), + # — Message writing — + ProviderField( + key="saveMessages", + label="Save messages", + kind=KIND_BOOL, + default="true", + description="Persist conversation messages to Honcho.", + group="Message writing", + ), + ProviderField( + key="writeFrequency", + label="Write frequency", + kind=KIND_TEXT, + default="async", + description="When to flush messages: async, turn, session, or every N turns.", + placeholder="async | turn | session | N", + group="Message writing", + ), + # — Dialectic — + ProviderField( + key="dialecticReasoningLevel", + label="Reasoning level", + kind=KIND_SELECT, + default="low", + description="Reasoning effort for dialectic (peer.chat) calls.", + options=_REASONING_LEVELS, + group="Dialectic", + ), + ProviderField( + key="dialecticDynamic", + label="Dynamic reasoning", + kind=KIND_BOOL, + default="true", + description="Let the model override the reasoning level per call.", + group="Dialectic", + ), + ProviderField( + key="dialecticMaxChars", + label="Max result chars", + kind=KIND_NUMBER, + description="Max chars of dialectic result injected into the system prompt.", + placeholder="1200", + group="Dialectic", + ), + ProviderField( + key="dialecticDepth", + label="Depth", + kind=KIND_NUMBER, + description="Dialectic passes per cycle (1–3).", + placeholder="1", + group="Dialectic", + ), + ProviderField( + key="dialecticDepthLevels", + label="Per-pass levels", + kind=KIND_JSON, + description="Reasoning level per pass; array length matches depth.", + placeholder='["low", "medium"]', + group="Dialectic", + ), + ProviderField( + key="dialecticMaxInputChars", + label="Max input chars", + kind=KIND_NUMBER, + description="Max chars of query input sent to peer.chat().", + placeholder="10000", + group="Dialectic", + ), + # — Reasoning — + ProviderField( + key="reasoningHeuristic", + label="Reasoning heuristic", + kind=KIND_BOOL, + default="true", + description="Scale the reasoning level up on longer queries.", + group="Reasoning", + ), + ProviderField( + key="reasoningLevelCap", + label="Reasoning level cap", + kind=KIND_SELECT, + default="high", + description="Ceiling for the heuristic-selected reasoning level.", + options=_REASONING_LEVELS, + group="Reasoning", + ), + # — Recall — + ProviderField( + key="recallMode", + label="Recall mode", + kind=KIND_SELECT, + default="hybrid", + description="How memory retrieval works: hybrid, context-only, or tools-only.", + options=( + ProviderFieldOption("hybrid", "Hybrid"), + ProviderFieldOption("context", "Context only"), + ProviderFieldOption("tools", "Tools only"), + ), + group="Recall", + ), + ProviderField( + key="contextTokens", + label="Context token cap", + kind=KIND_NUMBER, + description="Cap on auto-injected context tokens. Blank leaves it uncapped.", + placeholder="(uncapped)", + group="Recall", + ), + ProviderField( + key="initOnSessionStart", + label="Eager init", + kind=KIND_BOOL, + default="false", + description="Initialize the session eagerly in tools mode instead of on first tool call.", + group="Recall", + ), + # — Limits — + ProviderField( + key="messageMaxChars", + label="Message max chars", + kind=KIND_NUMBER, + description="Max chars per message sent to Honcho.", + placeholder="25000", + group="Limits", + ), + # — Observation — + ProviderField( + key="observationMode", + label="Observation mode", + kind=KIND_SELECT, + default="directional", + description="Per-peer observation preset. Directional observes all directions; unified shares one view.", + options=( + ProviderFieldOption("directional", "Directional"), + ProviderFieldOption("unified", "Unified"), + ), + group="Observation", + ), + ), +) + HINDSIGHT = MemoryProvider( name="hindsight", @@ -96,6 +411,7 @@ HINDSIGHT = MemoryProvider( "Connect to an existing Hindsight instance", ), ), + inline=True, ), ProviderField( key="api_key", @@ -104,6 +420,7 @@ HINDSIGHT = MemoryProvider( env_key="HINDSIGHT_API_KEY", description="Used to authenticate with the Hindsight API.", placeholder="Enter Hindsight API key", + inline=True, ), ProviderField( key="api_url", @@ -112,6 +429,7 @@ HINDSIGHT = MemoryProvider( default="https://api.hindsight.vectorize.io", aliases=("apiUrl",), env_fallbacks=("HINDSIGHT_API_URL",), + inline=True, ), ProviderField( key="bank_id", @@ -119,6 +437,7 @@ HINDSIGHT = MemoryProvider( kind=KIND_TEXT, default="hermes", aliases=("bankId",), + inline=True, ), ProviderField( key="recall_budget", @@ -131,14 +450,16 @@ HINDSIGHT = MemoryProvider( 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. +# an entry here (e.g. ``builtin``) simply render no config panel. Honcho leads. MEMORY_PROVIDERS: dict[str, MemoryProvider] = { + HONCHO.name: HONCHO, HINDSIGHT.name: HINDSIGHT, } From 101b9f8dcdde8c01ff819c8bb5d82349baa054f2 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 22 Jun 2026 14:46:46 -0400 Subject: [PATCH 02/27] feat(memory): dispatch /config reads+writes on provider storage backend Generalize the memory-provider config endpoints to dispatch on provider.storage: flat-json for simple providers, honcho_host_block for Honcho's real profile-scoped honcho.json. Adds kind-aware coercion (bool/number/json) and partial-save semantics so the inline panel never clobbers full-config-only fields. --- hermes_cli/web_server.py | 353 ++++++++++++++++++++++++++++++--------- 1 file changed, 278 insertions(+), 75 deletions(-) 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 From 4ef0672cf9daf98baafee5b0d120342105cdcb37 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 22 Jun 2026 14:49:13 -0400 Subject: [PATCH 03/27] feat(desktop): inline memory config panel + full-config modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade ProviderConfigPanel to a compact inline view (inline fields only) with a Full config… modal that groups every field by section. Add the generic kind-dispatched FieldControl (bool/number/json/select/secret/text) and ProviderConfigModal. Extend MemoryProviderField with inline/group and MemoryProviderConfig with docs_url. Order the provider enum honcho-first. --- apps/desktop/src/app/settings/constants.ts | 2 +- .../src/app/settings/field-control.tsx | 98 ++++++++++++ .../app/settings/provider-config-modal.tsx | 147 +++++++++++++++++ .../app/settings/provider-config-panel.tsx | 148 +++++++----------- apps/desktop/src/types/hermes.ts | 5 +- 5 files changed, 304 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/app/settings/field-control.tsx create mode 100644 apps/desktop/src/app/settings/provider-config-modal.tsx diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 83a4f1c531c9..9d5fd89476d3 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -227,7 +227,7 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], - 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], + 'memory.provider': ['', 'builtin', 'honcho', 'hindsight'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/field-control.tsx b/apps/desktop/src/app/settings/field-control.tsx new file mode 100644 index 000000000000..88819e60c59c --- /dev/null +++ b/apps/desktop/src/app/settings/field-control.tsx @@ -0,0 +1,98 @@ +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Check } from '@/lib/icons' +import type { MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' + +// Fade the placeholder well below set values so example text never reads as data. +const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45` + +/** Generic control for one declared provider field, dispatching on its kind. + * Values are edited as strings; the backend coerces them to native types. */ +export function FieldControl({ + field, + value, + onChange +}: { + field: MemoryProviderField + value: string + onChange: (value: string) => void +}) { + if (field.kind === 'bool') { + return onChange(checked ? 'true' : 'false')} /> + } + + if (field.kind === 'number') { + return ( + onChange(event.target.value)} + placeholder={field.placeholder} + type="number" + value={value} + /> + ) + } + + if (field.kind === 'json') { + return ( +