feat(memory): profile lifecycle and config-storage seams for memory providers

A catalog memory provider (Honcho today) has no way to own its per-profile setup or its
dashboard config file, so core carries that code for it: profile create/rename edit Honcho's
host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private
modules. These seams let a provider do that work itself; core's Honcho code is untouched here
and is removed in a follow-up once the plugin implements them.

- MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new,
  hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for
  the provider the profile's effective config selects, with that profile's home and its own
  .env secrets bound. A failing hook or unreadable config is logged and never fails the create
  or rename. No-ops by default.
- storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) /
  write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps
  value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on
  a refused write, and saves secrets to .env only after the provider accepted them.

_record_profile_rename moves to profile_identity.py (profiles.py line budget).
This commit is contained in:
kshitijk4poor authored and kshitij committed 2026-11-05 13:08:14 +05:30
1 parent d914e23344
commit e090092285
11 files changed
+448 -25

No files matched your search

+27
View File
@@ -223,3 +223,30 @@ class MemoryProvider(ABC):
"""Absolute paths of provider state OUTSIDE HERMES_HOME for ``hermes backup``/``import``
(paths outside the home dir are skipped). MUST work without ``initialize()`` or network."""
return []
# -- Profile lifecycle (uninitialized instance; that profile's home and secrets bound; errors are
# logged). Keep them fast: the dashboard and Desktop run them inside the create/rename request,
# so put network work (creating a remote peer) on a background thread. --
def on_profile_created(self, profile_name: str, hermes_home: str) -> None:
"""A profile whose config selects this provider was just created by ``create_profile`` (CLI,
dashboard, Desktop/TUI; a clone carries ``memory.provider``). Not called for profile import.
Set up per-profile provider state here (a peer, a host entry)."""
def on_profile_renamed(self, old_name: str, new_name: str, hermes_home: str) -> None:
"""A profile whose config selects this provider was renamed; ``hermes_home`` is its new
directory. Re-key anything the provider stores under the old profile name."""
# -- Dashboard config panel for a ``config_schema.py`` declaring ``storage=STORAGE_PROVIDER`` --
def read_config_fields(self, hermes_home: str) -> dict[str, Any]:
"""``{"values": {field key: stored native value}, "placeholders": {field key: text}}``. A key
absent from ``values`` reads as unset; a placeholder shows what a blank field resolves to.
A stored secret may be included: it only marks the field as set and is never sent out."""
return {"values": {}, "placeholders": {}}
def write_config_fields(self, values: dict[str, Any], hermes_home: str) -> None:
"""Persist the submitted fields (only those present): natives already coerced, ``None`` clears a
key, non-blank secrets included (core saves each to ``.env`` under its ``env_key`` once this
returns). Raise ``ValueError`` to refuse the save (the dashboard shows it as a 400)."""
raise NotImplementedError(f"memory provider {self.name} declares storage=provider but cannot write it")
+16
View File
@@ -237,3 +237,19 @@ def _migrate_profile_identity(old_canon: str, new_canon: str, live_mux: bool) ->
with contextlib.suppress(Exception):
release_or_close(db)
return migrated
def _record_profile_rename(new_dir: Path, old_canon: str) -> None:
"""Append ``old_canon`` to the renamed profile's ``previous_names`` history.
Best-effort: never raises, so a metadata write failure cannot fail the rename.
Only reached for a real slug change — ``rename_profile`` returns early for the
default profile (display-name only) and refuses ``old == new`` (target exists)."""
from hermes_cli import profiles as _profiles
try:
history = _profiles.read_profile_meta(new_dir).get("previous_names") or []
if old_canon not in history:
history = [*history, old_canon]
_profiles.write_profile_meta(new_dir, previous_names=history)
except Exception as exc: # unwritable / corrupt profile.yaml — history is advisory
_profiles.logger.debug("profile rename: could not record previous name %r in %s: %s", old_canon, new_dir, exc)
+39 -1
View File
@@ -10,11 +10,16 @@ tree, so a hook the plugin must implement could not fix the reported case.
"""
import contextlib
import logging
import os
import re
import shutil
from pathlib import Path
from typing import Optional
from typing import Callable, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
# A provider name is a bare directory/file stem; anything else (path separators, ``..``, spaces)
# would let a hand-edited config.yaml aim the copy outside the source profile.
@@ -74,6 +79,39 @@ def _load_config_dict(profile_dir: Path) -> Optional[dict]:
return data if isinstance(data, dict) else None
def notify_memory_provider(profile_dir: Path, call: Callable[[MemoryProvider], None]) -> None:
"""Run ``call`` on the memory provider *profile_dir*'s config selects, with that profile's home AND
its own secrets bound (a profile lifecycle hook): ``get_secret`` reads the new profile's ``.env``
first (outside multiplexing, a key it lacks falls back to the process env, as at runtime). No
provider selected or installed: nothing runs. Best-effort: a failing
provider, or an unreadable config, is logged and never fails the create/rename that called it
(the directory is already published)."""
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
from hermes_cli.config_backend import ConfigBackendUnavailable
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.memory import load_memory_provider
name = None
home_token = set_hermes_home_override(str(profile_dir))
secret_token = None
try:
# Secrets first: the effective config (managed scope, ``${VAR}`` refs) expands through them.
secret_token = set_secret_scope(build_profile_secret_scope(profile_dir), profile_home=str(profile_dir))
from hermes_cli.config import load_config_readonly
name = active_memory_provider(load_config_readonly())
if not name:
return
provider = load_memory_provider(name, register_skills=False)
if provider is not None:
call(provider)
except (Exception, ConfigBackendUnavailable): # the backend error is a SystemExit on purpose
logger.warning("memory provider %r: profile hook failed for %s", name, profile_dir, exc_info=True)
finally:
if secret_token is not None:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
"""Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
provider = active_memory_provider(_load_config_dict(profile_dir))
+6 -15
View File
@@ -1411,6 +1411,8 @@ def create_profile(
discard_unpublished_vault_key(clone_key)
shutil.rmtree(staging, ignore_errors=True)
raise
from hermes_cli.profile_memory_config import notify_memory_provider
notify_memory_provider(profile_dir, lambda p: p.on_profile_created(canon, str(profile_dir)))
# Inside a container under s6, register the gateway as a runtime s6 service so
# `hermes -p <profile> gateway start` supervises via `s6-svc -u` instead of a bare
@@ -2238,21 +2240,6 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
# Rename
def _record_profile_rename(new_dir: Path, old_canon: str) -> None:
"""Append ``old_canon`` to the renamed profile's ``previous_names`` history.
Best-effort: never raises, so a metadata write failure cannot fail the rename.
Only reached for a real slug change — ``rename_profile`` returns early for the
default profile (display-name only) and refuses ``old == new`` (target exists)."""
try:
history = read_profile_meta(new_dir).get("previous_names") or []
if old_canon not in history:
history = [*history, old_canon]
write_profile_meta(new_dir, previous_names=history)
except Exception as exc: # unwritable / corrupt profile.yaml — history is advisory
logger.debug("profile rename: could not record previous name %r in %s: %s", old_canon, new_dir, exc)
def _refuse_rename_without_file_tooling(old_canon: str) -> None:
"""A remote config backend keys each profile's settings by its canonical name, and the plane
has no rename: renaming the local home would leave the settings under the old name and read
@@ -2338,11 +2325,15 @@ def rename_profile(old_name: str, new_name: str) -> Path:
# 2b. Record the rename so Bot Mode group chats can re-link persisted
# member descriptors to the new slug (#110200). Best-effort: a metadata
# write failure must never fail the rename itself.
from hermes_cli.profile_identity import _record_profile_rename
_record_profile_rename(new_dir, old_canon)
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
from hermes_cli.profiles_honcho import migrate_honcho_profile_host
migrate_honcho_profile_host(old_canon, new_canon, new_dir)
# 3b. Let the profile's memory provider re-key what it stores under the old name.
from hermes_cli.profile_memory_config import notify_memory_provider
notify_memory_provider(new_dir, lambda p: p.on_profile_renamed(old_canon, new_canon, str(new_dir)))
# 4. Update wrapper script
remove_wrapper_script(old_canon)
+45 -6
View File
@@ -24,7 +24,7 @@ from hermes_cli.web_server_memory import (
from hermes_cli.web_models import MemoryProviderConfigUpdate, MemoryProviderSetupRequest
from hermes_cli.web_routers._common import _CONFIG_MUTATION_LOCK, scoped_to_thread
from plugins.memory.config_schema import (
STORAGE_HONCHO_HOST_BLOCK, ProviderConfigSchema, ProviderField, get_provider_config_schema,
STORAGE_HONCHO_HOST_BLOCK, STORAGE_PROVIDER, ProviderConfigSchema, ProviderField, get_provider_config_schema,
)
_log = logging.getLogger("hermes_cli.web_server")
@@ -131,6 +131,14 @@ def _honcho_resolvers(name: str):
return client.resolve_active_host, client.resolve_config_path, client._host_block
def _provider_instance(provider: ProviderConfigSchema):
"""The loaded provider behind a ``STORAGE_PROVIDER`` schema (it owns its config layout)."""
instance = _load_memory_provider(provider.name, register_skills=False) # a panel read never registers
if instance is None:
raise _unknown_provider(provider.name)
return instance
def _save_submitted_secrets(provider: ProviderConfigSchema, values: dict[str, str]) -> list:
"""Persist each non-blank secret submission to the env store (when the field has an
``env_key``); return the ``(field, submitted)`` pairs for backend-specific handling."""
@@ -152,11 +160,12 @@ def _apply_field_values(provider: ProviderConfigSchema, values: dict[str, str],
clobbers fields owned by another surface. ``_UNSET`` clears the key (and
its aliases) so it falls back to the host/default mapping.
"""
natives = _submitted_natives(provider, values)
for field in provider.fields:
if field.is_secret or field.key not in values:
if field.key not in natives:
continue
target = target_for(field)
coerced = _coerce_field_value(field, values[field.key])
coerced = natives[field.key]
if coerced is _UNSET:
for key in (field.key, *field.aliases):
target.pop(key, None)
@@ -171,6 +180,23 @@ def _write_json_0600(path: Path, data: dict[str, Any]) -> None:
atomic_json_write(path, data, mode=0o600)
def _submitted_natives(provider: ProviderConfigSchema, values: dict[str, str]) -> dict[str, Any]:
"""The submitted non-secret fields as natives, ``_UNSET`` for a cleared one (only keys present in
``values``, so a partial save never clobbers fields owned by another surface)."""
return {field.key: _coerce_field_value(field, values[field.key])
for field in provider.fields if not field.is_secret and field.key in values}
def _write_provider_own(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
"""Hand the submitted fields to the provider's ``write_config_fields`` (natives, ``None`` = clear,
non-blank secrets included); secrets reach ``.env`` only once the provider accepted the write."""
secrets = {field.key: (values.get(field.key) or "").strip() for field in provider.fields if field.is_secret}
fields = {k: v for k, v in secrets.items() if v}
fields.update({k: None if v is _UNSET else v for k, v in _submitted_natives(provider, values).items()})
_provider_instance(provider).write_config_fields(fields, str(get_hermes_home()))
_save_submitted_secrets(provider, values)
def _write_provider_flat(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
existing = _read_flat_json(provider)
_save_submitted_secrets(provider, values)
@@ -248,7 +274,17 @@ def _declared_field_is_set(field: ProviderField, sources: tuple, env: dict[str,
def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]:
env = load_env()
is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK
if is_honcho:
# A provider-owned layout reports presence (a stored False/0 is set), like the host block.
own = provider.storage == STORAGE_PROVIDER
placeholders: dict[str, Any] = {}
if own:
stored = _provider_instance(provider).read_config_fields(str(get_hermes_home())) or {}
host, data = "", dict(stored.get("values") or {})
placeholders = dict(stored.get("placeholders") or {})
def sources_for(field: ProviderField) -> tuple:
return (data,)
elif is_honcho:
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers(provider.name)
host = resolve_active_host()
raw = _read_json_dict(resolve_config_path(), "Honcho config")
@@ -267,6 +303,8 @@ def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]
entry = {k: getattr(field, k) for k in ("key", "label", "kind", "description", "info", "placeholder", "inline", "group")}
entry["options"] = [{"value": o.value, "label": o.label, "description": o.description} for o in field.options]
sources = sources_for(field)
if not field.placeholder and placeholders.get(field.key):
entry["placeholder"] = str(placeholders[field.key])
if field.is_secret:
entry["value"] = "" # secrets are write-only over the API
entry["is_set"] = _declared_field_is_set(field, sources, env)
@@ -281,7 +319,7 @@ def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]
value = field.default
entry["value"] = value
# Presence, not truthiness — a stored False/0 is still "set".
entry["is_set"] = native is not None if is_honcho else bool(value)
entry["is_set"] = native is not None if (is_honcho or own) else bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
@@ -306,7 +344,8 @@ def _memory_section(config: dict[str, Any]) -> dict[str, Any]:
def _update_memory_provider_config(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
writer = _write_provider_honcho if provider.storage == STORAGE_HONCHO_HOST_BLOCK else _write_provider_flat
writer = {STORAGE_PROVIDER: _write_provider_own,
STORAGE_HONCHO_HOST_BLOCK: _write_provider_honcho}.get(provider.storage, _write_provider_flat)
writer(provider, values)
with _CONFIG_MUTATION_LOCK: # RMW span vs. the dashboard's config autosave
config = load_config()
+2 -2
View File
@@ -19,11 +19,11 @@ def _normalize_memory_provider_name(name: Any) -> str:
return "" if is_core_memory_provider(provider) else provider
def _load_memory_provider(name: str):
def _load_memory_provider(name: str, *, register_skills: Optional[bool] = None):
try:
from plugins.memory import load_memory_provider
return load_memory_provider(name)
return load_memory_provider(name, register_skills=register_skills)
except Exception:
_log.debug("Failed to load memory provider %s", name, exc_info=True)
return None
+4 -1
View File
@@ -22,7 +22,10 @@ command. A hook with no concrete consumer is speculative infrastructure and is r
`hermes_cli/memory_provider_migration.py` for homes still configured for them. Host-side code a
catalog provider still relies on (the `honcho_host_block` config storage kind, profile clone /
rename / update-sync hooks) resolves the provider's modules through
`plugins.memory.import_provider_module`, never a `plugins.memory.<name>` import. New
`plugins.memory.import_provider_module`, never a `plugins.memory.<name>` import. Generic seams
supersede that code (it is removed once the catalog provider implements them): a provider owns its profile setup through `MemoryProvider.on_profile_created` /
`on_profile_renamed` and its dashboard config file through `storage=STORAGE_PROVIDER` +
`read_config_fields` / `write_config_fields`; new provider-specific host code is refused. New
backends ship as standalone repos implementing the same `MemoryProvider` ABC, discovered through
the same path, integrated via `hermes memory setup` / `post_setup()`.
- **Other features that leave core** (Home Assistant platform + toolset, Oct 2026 →
+4
View File
@@ -25,6 +25,10 @@ KIND_JSON = "json"
# Storage backends understood by web_server (see its read/write dispatch).
STORAGE_FLAT_JSON = "flat_json"
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
# The provider's read_config_fields / write_config_fields own the file layout
# (agent.memory_provider.MemoryProvider); loading them loads the provider itself (in the plugin
# host when ``plugins.isolation`` is ``host``), and its writer also receives submitted secrets.
STORAGE_PROVIDER = "provider"
@dataclass(frozen=True)
@@ -0,0 +1,132 @@
"""Profile create/rename reach the memory provider the profile selects (agent.memory_provider's
on_profile_created / on_profile_renamed), best-effort, with that profile's home and secrets bound."""
from pathlib import Path
from unittest.mock import patch
import pytest
from agent.memory_provider import MemoryProvider
@pytest.fixture
def profile_env(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
default_home = tmp_path / ".hermes"
default_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(default_home))
return default_home
class _Stub(MemoryProvider):
def is_available(self):
return True
def initialize(self, session_id, **kwargs):
pass
def get_tool_schemas(self):
return []
def _select(home: Path, name: str) -> None:
(home / "config.yaml").write_text(f"memory:\n provider: {name}\n", encoding="utf-8")
def test_create_and_rename_call_the_selected_providers_hooks_with_its_home_bound(profile_env, monkeypatch):
import plugins.memory as memory_pkg
from hermes_cli.profiles import create_profile, rename_profile
from hermes_constants import get_hermes_home
calls = []
class Recorder(_Stub):
name = "recorder"
def on_profile_created(self, profile_name, hermes_home):
calls.append(("created", profile_name, hermes_home, str(get_hermes_home())))
def on_profile_renamed(self, old_name, new_name, hermes_home):
calls.append(("renamed", old_name, new_name, hermes_home, str(get_hermes_home())))
monkeypatch.setattr(memory_pkg, "load_memory_provider",
lambda name, **kw: Recorder() if name == "recorder" else None)
_select(profile_env, "recorder")
create_profile("plain", no_alias=True) # fresh: no memory.provider, so no hook
created = create_profile("ssi_health", clone_config=True, no_alias=True) # the clone carries it
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
renamed = rename_profile("ssi_health", "heimdall")
assert calls == [
("created", "ssi_health", str(created), str(created)),
("renamed", "ssi_health", "heimdall", str(renamed), str(renamed)),
]
def test_the_hook_reads_the_new_profiles_secrets_not_the_callers(profile_env, monkeypatch):
import plugins.memory as memory_pkg
from agent.secret_scope import get_secret
from hermes_cli.profiles import create_profile
seen = []
class Reader(_Stub):
name = "reader"
def on_profile_created(self, profile_name, hermes_home):
seen.append(get_secret("PROBE_PROFILE_KEY", "unset"))
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: Reader())
monkeypatch.setenv("PROBE_PROFILE_KEY", "caller-value")
_select(profile_env, "reader")
(profile_env / ".env").write_text("PROBE_PROFILE_KEY=clone-value\n", encoding="utf-8")
create_profile("work", clone_config=True, no_alias=True) # --clone copies the .env
assert seen == ["clone-value"]
def test_the_hook_follows_the_effective_selection_including_env_refs(profile_env, monkeypatch):
"""``memory.provider: ${VAR}`` resolves through the new profile's own .env, like at runtime."""
import plugins.memory as memory_pkg
from hermes_cli.profiles import create_profile
loaded = []
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: loaded.append(name))
(profile_env / "config.yaml").write_text("memory:\n provider: ${PROBE_MEMORY_PROVIDER}\n", encoding="utf-8")
(profile_env / ".env").write_text("PROBE_MEMORY_PROVIDER=recorder\n", encoding="utf-8")
create_profile("work", clone_config=True, no_alias=True)
assert loaded == ["recorder"]
def test_an_unreadable_config_backend_never_fails_create(profile_env, monkeypatch, caplog):
from hermes_cli.config_backend import ConfigBackendUnavailable
from hermes_cli.profiles import create_profile
def boom(*_a, **_k):
raise ConfigBackendUnavailable("backend down")
import hermes_cli.config as config_mod
monkeypatch.setattr(config_mod, "load_config_readonly", boom)
with caplog.at_level("WARNING", logger="hermes_cli.profile_memory_config"):
assert create_profile("work", no_alias=True).is_dir()
assert "profile hook failed" in caplog.text
def test_a_failing_provider_hook_never_fails_create_or_rename(profile_env, monkeypatch):
import plugins.memory as memory_pkg
from hermes_cli.profiles import create_profile, rename_profile
class Broken(_Stub):
name = "broken"
def on_profile_created(self, profile_name, hermes_home):
raise RuntimeError("boom")
def on_profile_renamed(self, old_name, new_name, hermes_home):
raise RuntimeError("boom")
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: Broken())
_select(profile_env, "broken")
create_profile("work", clone_config=True, no_alias=True)
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
assert rename_profile("work", "play").is_dir()
@@ -0,0 +1,148 @@
"""Dashboard config panel for ``storage=STORAGE_PROVIDER``: the provider's read_config_fields /
write_config_fields own the file layout (here a host-block file like a catalog provider keeps);
core coerces values, keeps secrets write-only, and never opens the file itself."""
import json
import pytest
_INIT = """
import json
from pathlib import Path
from agent.memory_provider import MemoryProvider
_ROOT_KEYS = {"baseUrl"}
def _read(home):
path = Path(home) / "ownprov.json"
return path, (json.loads(path.read_text(encoding="utf-8")) if path.exists() else {})
class OwnProv(MemoryProvider):
name = "ownprov"
def is_available(self):
return True
def initialize(self, session_id, **kwargs):
pass
def get_tool_schemas(self):
return []
def read_config_fields(self, hermes_home):
_path, cfg = _read(hermes_home)
block = (cfg.get("hosts") or {}).get("hermes") or {}
values = {**{k: v for k, v in cfg.items() if k in _ROOT_KEYS}, **block}
return {"values": values, "placeholders": {"workspace": "hermes", "aiPeer": "hermes"}}
def write_config_fields(self, values, hermes_home):
path, cfg = _read(hermes_home) # a file that does not parse raises ValueError: no write
block = cfg.setdefault("hosts", {}).setdefault("hermes", {})
for key, value in values.items():
target = cfg if key in _ROOT_KEYS else block
if value is None:
target.pop(key, None)
else:
target[key] = value
path.write_text(json.dumps(cfg), encoding="utf-8")
def register(ctx):
ctx.register_memory_provider(OwnProv())
"""
_SCHEMA = """
from plugins.memory.config_schema import (
KIND_BOOL, KIND_SECRET, KIND_TEXT, STORAGE_PROVIDER, ProviderConfigSchema, ProviderField,
)
CONFIG_SCHEMA = ProviderConfigSchema(
name="ownprov",
label="Provider-owned storage",
storage=STORAGE_PROVIDER,
fields=(
ProviderField(key="apiKey", label="API key", kind=KIND_SECRET, description="", env_key="OWNPROV_API_KEY"),
ProviderField(key="baseUrl", label="Base URL", kind=KIND_TEXT, description=""),
ProviderField(key="workspace", label="Workspace", kind=KIND_TEXT, description=""),
ProviderField(key="saveMessages", label="Save messages", kind=KIND_BOOL, description="", default="true"),
),
)
"""
_URL = "/api/memory/providers/ownprov/config?surface=declared"
@pytest.fixture
def client(monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
plugin_dir = get_hermes_home() / "plugins" / "ownprov"
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text(_INIT, encoding="utf-8")
(plugin_dir / "config_schema.py").write_text(_SCHEMA, encoding="utf-8")
test_client = TestClient(app)
test_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return test_client
def _config_path():
from hermes_constants import get_hermes_home
return get_hermes_home() / "ownprov.json"
def test_put_writes_through_the_provider_with_natives_and_saves_the_secret(client):
from hermes_cli.config import load_config, load_env
resp = client.put(_URL, json={"values": {
"apiKey": "own-test-key", "baseUrl": "https://own.example.dev", "workspace": "myws", "saveMessages": "false",
}})
assert resp.status_code == 200
assert load_config()["memory"]["provider"] == "ownprov"
assert load_env()["OWNPROV_API_KEY"] == "own-test-key"
cfg = json.loads(_config_path().read_text(encoding="utf-8"))
assert cfg["baseUrl"] == "https://own.example.dev"
assert cfg["hosts"]["hermes"] == {"apiKey": "own-test-key", "workspace": "myws", "saveMessages": False}
def test_get_keeps_secrets_write_only_and_shows_placeholders_and_presence(client):
client.put(_URL, json={"values": {"apiKey": "secret-value", "saveMessages": "false"}})
resp = client.get(_URL)
assert resp.status_code == 200
assert "secret-value" not in json.dumps(resp.json())
fields = {field["key"]: field for field in resp.json()["fields"]}
assert fields["apiKey"]["is_set"] is True and fields["apiKey"]["value"] == ""
assert fields["workspace"]["placeholder"] == "hermes" and fields["workspace"]["is_set"] is False
assert fields["saveMessages"]["is_set"] is True # a stored False is still set
assert fields["saveMessages"]["value"] == "false"
def test_a_blank_field_clears_the_key(client):
client.put(_URL, json={"values": {"workspace": "myws"}})
client.put(_URL, json={"values": {"workspace": ""}})
assert "workspace" not in json.loads(_config_path().read_text(encoding="utf-8"))["hosts"]["hermes"]
def test_a_refused_write_is_a_400_with_no_secret_saved_and_no_switch(client):
from hermes_cli.config import load_config, load_env
_config_path().write_text("{not json", encoding="utf-8")
resp = client.put(_URL, json={"values": {"workspace": "myws", "apiKey": "own-new-key"}})
assert resp.status_code == 400
assert _config_path().read_text(encoding="utf-8") == "{not json"
assert (load_config().get("memory") or {}).get("provider") != "ownprov"
assert "OWNPROV_API_KEY" not in load_env()
@@ -178,6 +178,12 @@ workspace; absent or empty cwd remains unpinned.
| `on_pre_compress(messages)` | Before context compression | Save insights before discard |
| `on_memory_write(action, target, content, metadata=None)` | Built-in memory writes | Mirror to your backend |
| `shutdown()` | Process exit | Clean up connections |
| `on_profile_created(profile_name, hermes_home)` | A profile selecting your provider was created (a clone carries `memory.provider`), from the CLI, dashboard or Desktop | Per-profile state: a peer, a host entry |
| `on_profile_renamed(old_name, new_name, hermes_home)` | That profile was renamed (`hermes_home` is the new directory) | Re-key state stored under the old name |
The two profile hooks run on an uninitialized instance with that profile's home and its own `.env`
secrets bound (a key that `.env` lacks falls back to the process environment, as at runtime). An exception is logged and never fails the create or rename. They run inside the
create/rename request on every surface, so do network work on a background thread.
For native `replace` and `remove`, `metadata["previous_content"]` contains the full
entry selected under the native-store lock. Notifications are emitted only after
@@ -339,6 +345,25 @@ Fields with `secret: True` and `env_var` go to `.env`. Non-secret fields are pas
Every field in `get_config_schema()` is prompted during `hermes memory setup`. Providers with many options should keep the schema minimal — only include fields the user **must** configure (API key, required credentials). Document optional settings in a config file reference (e.g. `$HERMES_HOME/myprovider.json`) rather than prompting for them all during setup. This keeps the setup wizard fast while still supporting advanced configuration. See the [Supermemory provider](https://github.com/supermemoryai/hermes-supermemory) (a plugin catalog entry) for an example — it only prompts for the API key; all other options live in `supermemory.json`.
:::
### Provider-owned storage
`config_schema.py` defaults to `storage=STORAGE_FLAT_JSON`: core reads and writes
`$HERMES_HOME/<name>/config.json` for the panel. A provider whose config has its own layout
(several profiles in one file, OAuth tokens beside settings) declares `storage=STORAGE_PROVIDER`
and implements two methods; core never opens the file:
```python
def read_config_fields(self, hermes_home: str) -> dict:
# Stored natives by field key (absent = unset), plus what a blank field resolves to.
return {"values": {"workspace": "ws1"}, "placeholders": {"aiPeer": "hermes_coder"}}
def write_config_fields(self, values: dict, hermes_home: str) -> None:
# Only submitted keys; None clears one. Non-blank secrets arrive here too; core saves them to
# .env under env_key only after this returns. Raise a plain ValueError to refuse the save
# (shown as a 400), e.g. when your file does not parse.
...
```
## Save Config
```python