feat(desktop): schema-driven memory-provider config surface

Make the desktop memory settings dynamic instead of hardcoded per
provider. The dropdown is now populated from discover_memory_providers()
(bundled + user-installed + pip) rather than a static enum, and each
provider's config panel is derived from its own get_config_schema() —
the same declaration `hermes memory setup` uses — so adding or porting a
provider is pure declaration with no bespoke UI, conditional, or
endpoint.

- memory_providers.py: reworked from a hand-written Hindsight registry
  into a pure adapter (describe_provider + coerce_value) that normalizes
  a provider's raw schema into typed fields — secret(+env), select,
  boolean, typed text — carrying `when` conditionals, url, and required.
- MemoryProvider ABC: add optional read_current_config() (default {}),
  the read-back mirror of save_config(). Ported mem0, hindsight, honcho,
  holographic; the rest fall back to schema defaults safely.
- web_server GET/PUT /api/memory/providers/{name}/config now load the
  live provider, derive its schema, write non-secrets via the provider's
  own save_config() (each keeps its native storage), persist secrets to
  the env store, and `when`-gate validation so hidden fields aren't
  required or written. Secrets stay write-only (is_set only).
- ProviderConfigPanel: `when`-conditional visibility (handles Hindsight's
  mode-gated duplicate keys), boolean toggle, and credential url links.
  Dropdown driven by getMemoryStatus(); hardcoded enum removed.

Tests assert the mapping contract and endpoint behavior (schema
derivation, save-via-save_config, secret-never-returned, when-gating,
select rejection) against real bundled providers rather than a snapshot
of a hardcoded list.
This commit is contained in:
Brooklyn Nicholson 2026-06-18 17:16:13 -05:00
parent 03d9a95a74
commit e48d18c8e2
16 changed files with 770 additions and 291 deletions

View file

@ -268,70 +268,83 @@ class TestWebServerEndpoints:
def _provider_field_map(payload):
return {field["key"]: field for field in payload["fields"]}
def test_get_memory_provider_config_returns_safe_defaults(self):
resp = self.client.get("/api/memory/providers/hindsight/config")
# The desktop memory-provider config surface is schema-driven: the endpoint
# derives fields from each provider's live get_config_schema(). These tests
# assert that contract against a real bundled provider (mem0 — small, stable
# schema) and Hindsight's when-gating, not a snapshot of a hardcoded list.
def test_get_memory_provider_config_derives_fields_from_schema(self):
resp = self.client.get("/api/memory/providers/mem0/config")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "hindsight"
assert data["label"] == "Hindsight"
assert data["name"] == "mem0"
fields = self._provider_field_map(data)
assert fields["mode"]["kind"] == "select"
assert fields["mode"]["value"] == "cloud"
assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"}
assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io"
assert fields["bank_id"]["value"] == "hermes"
assert fields["recall_budget"]["value"] == "mid"
# Declared fields surface generically with their mapped kinds.
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["is_set"] is False
assert fields["api_key"]["value"] == ""
assert fields["rerank"]["kind"] == "select"
assert {opt["value"] for opt in fields["rerank"]["options"]} == {"true", "false"}
assert "user_id" in fields and "agent_id" in fields
def test_put_memory_provider_config_writes_config_and_secret(self):
def test_put_memory_provider_config_writes_via_save_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config",
"/api/memory/providers/mem0/config",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-test-key",
"bank_id": "ben-bank",
"recall_budget": "high",
"user_id": "ben",
"agent_id": "agent-x",
"rerank": "false",
"api_key": "mem0-test-key",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_config()["memory"]["provider"] == "hindsight"
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
# Provider is activated and the secret lands in the env store.
assert load_config()["memory"]["provider"] == "mem0"
assert load_env()["MEM0_API_KEY"] == "mem0-test-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
# Non-secret values are persisted through the provider's own save_config()
# to its native location (mem0.json), not a path the endpoint hardcodes.
config_path = get_hermes_home() / "mem0.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config == {
"mode": "local_external",
"api_url": "http://localhost:8888",
"bank_id": "ben-bank",
"recall_budget": "high",
}
assert provider_config["user_id"] == "ben"
assert provider_config["agent_id"] == "agent-x"
assert provider_config["rerank"] == "false"
assert "api_key" not in provider_config # secret never written to the config file
def test_put_memory_provider_config_rejects_unsupported_select_value(self):
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_embedded",
"api_url": "http://localhost:8888",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
"/api/memory/providers/mem0/config",
json={"values": {"rerank": "maybe"}},
)
assert resp.status_code == 400
def test_put_skips_fields_gated_off_by_unmet_when_clause(self):
# Hindsight's llm_* fields are gated `when mode == local_embedded`.
# Saving in cloud mode must not persist them.
from hermes_constants import get_hermes_home
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={"values": {"mode": "cloud"}},
)
assert resp.status_code == 200
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "cloud"
assert "llm_model" not in provider_config
assert "llm_provider" not in provider_config
def test_put_unknown_memory_provider_returns_404(self):
resp = self.client.put(
"/api/memory/providers/nope/config", json={"values": {}}
@ -347,19 +360,11 @@ class TestWebServerEndpoints:
def test_get_memory_provider_config_does_not_return_secret(self):
self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "secret-value",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
"/api/memory/providers/mem0/config",
json={"values": {"api_key": "secret-value", "user_id": "ben"}},
)
resp = self.client.get("/api/memory/providers/hindsight/config")
resp = self.client.get("/api/memory/providers/mem0/config")
assert resp.status_code == 200
data = resp.json()