From edff2fbe7efd7d1798b6f6116d2e4b55b3ce69f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 10 Apr 2026 17:13:47 +0200 Subject: [PATCH] feat(hindsight): optional bank_id_template for per-agent / per-user banks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional bank_id_template config that derives the bank name at initialize() time from runtime context. Existing users with a static bank_id keep the current behavior (template is empty by default). Supported placeholders: {profile} — active Hermes profile (agent_identity kwarg) {workspace} — Hermes workspace (agent_workspace kwarg) {platform} — cli, telegram, discord, etc. {user} — platform user id (gateway sessions) {session} — session id Unsafe characters in placeholder values are sanitized, and empty placeholders collapse cleanly (e.g. "hermes-{user}" with no user becomes "hermes"). If the template renders empty, the static bank_id is used as a fallback. Common uses: bank_id_template: hermes-{profile} # isolate per Hermes profile bank_id_template: {workspace}-{profile} # workspace + profile scoping bank_id_template: hermes-{user} # per-user banks for gateway --- plugins/memory/hindsight/README.md | 3 +- plugins/memory/hindsight/__init__.py | 76 ++++++++- .../plugins/memory/test_hindsight_provider.py | 148 +++++++++++++++++- 3 files changed, 223 insertions(+), 4 deletions(-) diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 3fbdc2aba..4c7e0f6be 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -59,7 +59,8 @@ Config file: `~/.hermes/hindsight/config.json` | Key | Default | Description | |-----|---------|-------------| -| `bank_id` | `hermes` | Memory bank name | +| `bank_id` | `hermes` | Memory bank name (static fallback used when `bank_id_template` is unset or resolves empty) | +| `bank_id_template` | — | Optional template to derive the bank name dynamically. Placeholders: `{profile}`, `{workspace}`, `{platform}`, `{user}`, `{session}`. Example: `hermes-{profile}` isolates memory per active Hermes profile. Empty placeholders collapse cleanly (e.g. `hermes-{user}` with no user becomes `hermes`). | | `bank_mission` | — | Reflect mission (identity/framing for reflect reasoning). Applied via Banks API. | | `bank_retain_mission` | — | Retain mission (steers what gets extracted). Applied via Banks API. | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 82b35c5b0..bc82bc40f 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -324,6 +324,60 @@ def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: st ) return profile_env +def _sanitize_bank_segment(value: str) -> str: + """Sanitize a bank_id_template placeholder value. + + Bank IDs should be safe for URL paths and filesystem use. Replaces any + character that isn't alphanumeric, dash, or underscore with a dash, and + collapses runs of dashes. + """ + if not value: + return "" + out = [] + prev_dash = False + for ch in str(value): + if ch.isalnum() or ch == "-" or ch == "_": + out.append(ch) + prev_dash = False + else: + if not prev_dash: + out.append("-") + prev_dash = True + return "".join(out).strip("-_") + + +def _resolve_bank_id_template(template: str, fallback: str, **placeholders: str) -> str: + """Resolve a bank_id template string with the given placeholders. + + Supported placeholders (each is sanitized before substitution): + {profile} — active Hermes profile name (from agent_identity) + {workspace} — Hermes workspace name (from agent_workspace) + {platform} — "cli", "telegram", "discord", etc. + {user} — platform user id (gateway sessions) + {session} — current session id + + Missing/empty placeholders are rendered as the empty string and then + collapsed — e.g. ``hermes-{user}`` with no user becomes ``hermes``. + + If the template is empty, resolution falls back to *fallback*. + Returns the sanitized bank id. + """ + if not template: + return fallback + sanitized = {k: _sanitize_bank_segment(v) for k, v in placeholders.items()} + try: + rendered = template.format(**sanitized) + except (KeyError, IndexError) as exc: + logger.warning("Invalid bank_id_template %r: %s — using fallback %r", + template, exc, fallback) + return fallback + while "--" in rendered: + rendered = rendered.replace("--", "-") + while "__" in rendered: + rendered = rendered.replace("__", "_") + rendered = rendered.strip("-_") + return rendered or fallback + # --------------------------------------------------------------------------- # MemoryProvider implementation @@ -354,6 +408,7 @@ class HindsightMemoryProvider(MemoryProvider): self._chat_type = "" self._thread_id = "" self._agent_identity = "" + self._agent_workspace = "" self._turn_index = 0 self._client = None self._timeout = _DEFAULT_TIMEOUT @@ -388,6 +443,7 @@ class HindsightMemoryProvider(MemoryProvider): # Bank self._bank_mission = "" self._bank_retain_mission: str | None = None + self._bank_id_template = "" @property def name(self) -> str: @@ -615,7 +671,8 @@ class HindsightMemoryProvider(MemoryProvider): {"key": "llm_base_url", "description": "Endpoint URL (e.g. http://192.168.1.10:8080/v1)", "default": "", "when": {"mode": "local_embedded", "llm_provider": "openai_compatible"}}, {"key": "llm_api_key", "description": "LLM API key (optional for openai_compatible)", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local_embedded"}}, {"key": "llm_model", "description": "LLM model", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local_embedded"}}, - {"key": "bank_id", "description": "Memory bank name", "default": "hermes"}, + {"key": "bank_id", "description": "Memory bank name (static fallback when bank_id_template is unset)", "default": "hermes"}, + {"key": "bank_id_template", "description": "Optional template to derive bank_id dynamically. Placeholders: {profile}, {workspace}, {platform}, {user}, {session}. Example: hermes-{profile}", "default": ""}, {"key": "bank_mission", "description": "Mission/purpose description for the memory bank"}, {"key": "bank_retain_mission", "description": "Custom extraction prompt for memory retention"}, {"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, @@ -728,6 +785,7 @@ class HindsightMemoryProvider(MemoryProvider): self._chat_type = str(kwargs.get("chat_type") or "").strip() self._thread_id = str(kwargs.get("thread_id") or "").strip() self._agent_identity = str(kwargs.get("agent_identity") or "").strip() + self._agent_workspace = str(kwargs.get("agent_workspace") or "").strip() self._turn_index = 0 self._session_turns = [] self._mode = self._config.get("mode", "cloud") @@ -751,7 +809,17 @@ class HindsightMemoryProvider(MemoryProvider): self._llm_base_url = self._config.get("llm_base_url", "") banks = self._config.get("banks", {}).get("hermes", {}) - self._bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes") + static_bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes") + self._bank_id_template = self._config.get("bank_id_template", "") or "" + self._bank_id = _resolve_bank_id_template( + self._bank_id_template, + fallback=static_bank_id, + profile=self._agent_identity, + workspace=self._agent_workspace, + platform=self._platform, + user=self._user_id, + session=self._session_id, + ) budget = self._config.get("recall_budget") or self._config.get("budget") or banks.get("budget", "mid") self._budget = budget if budget in _VALID_BUDGETS else "mid" @@ -804,6 +872,10 @@ class HindsightMemoryProvider(MemoryProvider): pass logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version) + if self._bank_id_template: + logger.debug("Hindsight bank resolved from template %r: profile=%s workspace=%s platform=%s user=%s -> bank=%s", + self._bank_id_template, self._agent_identity, self._agent_workspace, + self._platform, self._user_id, self._bank_id) logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, " "retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s", self._auto_retain, self._auto_recall, self._retain_every_n_turns, diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index ecadf987e..5f1290b2f 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -19,6 +19,8 @@ from plugins.memory.hindsight import ( RETAIN_SCHEMA, _load_config, _normalize_retain_tags, + _resolve_bank_id_template, + _sanitize_bank_segment, ) @@ -782,7 +784,7 @@ class TestConfigSchema: keys = {f["key"] for f in schema} expected_keys = { "mode", "api_url", "api_key", "llm_provider", "llm_api_key", - "llm_model", "bank_id", "bank_mission", "bank_retain_mission", + "llm_model", "bank_id", "bank_id_template", "bank_mission", "bank_retain_mission", "recall_budget", "memory_mode", "recall_prefetch_method", "retain_tags", "retain_source", "retain_user_prefix", "retain_assistant_prefix", @@ -795,6 +797,150 @@ class TestConfigSchema: assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}" +# --------------------------------------------------------------------------- +# bank_id_template tests +# --------------------------------------------------------------------------- + + +class TestBankIdTemplate: + def test_sanitize_bank_segment_passthrough(self): + assert _sanitize_bank_segment("hermes") == "hermes" + assert _sanitize_bank_segment("my-agent_1") == "my-agent_1" + + def test_sanitize_bank_segment_strips_unsafe(self): + assert _sanitize_bank_segment("josh@example.com") == "josh-example-com" + assert _sanitize_bank_segment("chat:#general") == "chat-general" + assert _sanitize_bank_segment(" spaces ") == "spaces" + + def test_sanitize_bank_segment_empty(self): + assert _sanitize_bank_segment("") == "" + assert _sanitize_bank_segment(None) == "" + + def test_resolve_empty_template_uses_fallback(self): + result = _resolve_bank_id_template( + "", fallback="hermes", profile="coder" + ) + assert result == "hermes" + + def test_resolve_with_profile(self): + result = _resolve_bank_id_template( + "hermes-{profile}", fallback="hermes", + profile="coder", workspace="", platform="", user="", session="", + ) + assert result == "hermes-coder" + + def test_resolve_with_multiple_placeholders(self): + result = _resolve_bank_id_template( + "{workspace}-{profile}-{platform}", + fallback="hermes", + profile="coder", workspace="myorg", platform="cli", + user="", session="", + ) + assert result == "myorg-coder-cli" + + def test_resolve_collapses_empty_placeholders(self): + # When user is empty, "hermes-{user}" becomes "hermes-" -> trimmed to "hermes" + result = _resolve_bank_id_template( + "hermes-{user}", fallback="default", + profile="", workspace="", platform="", user="", session="", + ) + assert result == "hermes" + + def test_resolve_collapses_double_dashes(self): + # Two empty placeholders with a dash between them should collapse + result = _resolve_bank_id_template( + "{workspace}-{profile}-{user}", fallback="fallback", + profile="coder", workspace="", platform="", user="", session="", + ) + assert result == "coder" + + def test_resolve_empty_rendered_falls_back(self): + result = _resolve_bank_id_template( + "{user}-{profile}", fallback="fallback", + profile="", workspace="", platform="", user="", session="", + ) + assert result == "fallback" + + def test_resolve_sanitizes_placeholder_values(self): + result = _resolve_bank_id_template( + "user-{user}", fallback="hermes", + profile="", workspace="", platform="", + user="josh@example.com", session="", + ) + assert result == "user-josh-example-com" + + def test_resolve_invalid_template_returns_fallback(self): + # Unknown placeholder should fall back without raising + result = _resolve_bank_id_template( + "hermes-{unknown}", fallback="hermes", + profile="", workspace="", platform="", user="", session="", + ) + assert result == "hermes" + + def test_provider_uses_bank_id_template_from_config(self, tmp_path, monkeypatch): + config = { + "mode": "cloud", + "apiKey": "k", + "api_url": "http://x", + "bank_id": "fallback-bank", + "bank_id_template": "hermes-{profile}", + } + config_path = tmp_path / "hindsight" / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path) + + p = HindsightMemoryProvider() + p.initialize( + session_id="s1", + hermes_home=str(tmp_path), + platform="cli", + agent_identity="coder", + agent_workspace="hermes", + ) + assert p._bank_id == "hermes-coder" + assert p._bank_id_template == "hermes-{profile}" + + def test_provider_without_template_uses_static_bank_id(self, tmp_path, monkeypatch): + config = { + "mode": "cloud", + "apiKey": "k", + "api_url": "http://x", + "bank_id": "my-static-bank", + } + config_path = tmp_path / "hindsight" / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path) + + p = HindsightMemoryProvider() + p.initialize( + session_id="s1", + hermes_home=str(tmp_path), + platform="cli", + agent_identity="coder", + ) + assert p._bank_id == "my-static-bank" + + def test_provider_template_with_missing_profile_falls_back(self, tmp_path, monkeypatch): + config = { + "mode": "cloud", + "apiKey": "k", + "api_url": "http://x", + "bank_id": "hermes-fallback", + "bank_id_template": "hermes-{profile}", + } + config_path = tmp_path / "hindsight" / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path) + + p = HindsightMemoryProvider() + # No agent_identity passed — template renders to "hermes-" which collapses to "hermes" + p.initialize(session_id="s1", hermes_home=str(tmp_path), platform="cli") + assert p._bank_id == "hermes" + + # --------------------------------------------------------------------------- # Availability tests # ---------------------------------------------------------------------------