Skip to content
Merged
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
25 changes: 24 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,30 @@ async def _token_auth_seam(request: Request, call_next):
# ---------------------------------------------------------------------------

# Manual overrides for fields that need select options or custom types
def _memory_provider_options() -> List[str]:
"""Discovered memory providers for the ``memory.provider`` select.

Directory-scan only (no provider imports), so it's safe at module import
time. ``""`` (built-in) is always first; discovery failures degrade to the
bundled defaults rather than dropping the field.
"""
options = ["", "builtin"]
try:
from plugins.memory import list_memory_provider_names

options.extend(list_memory_provider_names())
except Exception:
options.extend(["honcho"])
# Dedupe, preserve order
return list(dict.fromkeys(options))


_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"memory.provider": {
"type": "select",
"description": "Memory provider plugin",
"options": _memory_provider_options(),
},
"model": {
"type": "string",
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
Expand Down Expand Up @@ -780,7 +803,7 @@ def _build_schema_from_config(
full_key = f"{prefix}.{key}" if prefix else key

# Skip internal / version keys
if full_key in {"_config_version", "memory.provider"}:
if full_key in {"_config_version"}:
continue

# Category is the first path component for nested keys, or "general"
Expand Down
11 changes: 11 additions & 0 deletions plugins/memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ def find_provider_dir(name: str) -> Optional[Path]:
# Public API
# ---------------------------------------------------------------------------

def list_memory_provider_names() -> List[str]:
"""Cheap name-only listing of discoverable memory providers.

Unlike :func:`discover_memory_providers`, this does NOT import provider
modules or run availability checks — it's a directory scan only, safe to
call at module-import time (e.g. when building the dashboard config
schema).
"""
return sorted({name for name, _ in _iter_provider_dirs()})


def discover_memory_providers() -> List[Tuple[str, str, bool]]:
"""Scan bundled and user-installed directories for available providers.

Expand Down
28 changes: 28 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3645,6 +3645,34 @@ def test_overrides_applied(self):
assert "options" in entry
assert "local" in entry["options"]

def test_memory_provider_field_present_as_select(self):
"""memory.provider must stay in the config schema.

Desktop's settings page builds its field list from /api/config/schema —
a key excluded here silently vanishes from Desktop's Memory section
(regression: the dashboard's dedicated memory-provider UI excluded the
key server-side, breaking Desktop's dropdown). The dashboard hides the
field client-side instead.
"""
from hermes_cli.web_server import CONFIG_SCHEMA
entry = CONFIG_SCHEMA["memory.provider"]
assert entry["type"] == "select"
assert entry["category"] == "memory"
options = entry["options"]
# Built-in sentinel first, plus at least one discovered provider.
assert options[0] == ""
assert "builtin" in options
assert len(options) >= 3

def test_memory_provider_options_cover_discovered_providers(self):
"""Every provider the /api/memory endpoint can activate is selectable."""
from hermes_cli.web_server import CONFIG_SCHEMA
from plugins.memory import list_memory_provider_names

options = set(CONFIG_SCHEMA["memory.provider"]["options"])
missing = set(list_memory_provider_names()) - options
assert missing == set(), f"discovered providers missing from schema options: {missing}"

def test_approvals_mode_options_match_config_values(self):
"""approvals.mode select options must match the values accepted by config.py.

Expand Down
11 changes: 10 additions & 1 deletion web/src/pages/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,16 @@ export default function ConfigPage() {
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
// memory.provider has a dedicated management UI on the Plugins page
// (provider cards + guided setup/switch flow). Hide it from the
// generic config form so the two surfaces don't fight; the schema
// keeps the field for other consumers (Desktop settings).
const fields = { ...resp.fields } as Record<
string,
Record<string, unknown>
>;
delete fields["memory.provider"];
setSchema(fields);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
Expand Down
Loading