Skip to content
Closed
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
3 changes: 2 additions & 1 deletion plugins/memory/hindsight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
99 changes: 95 additions & 4 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,61 @@ def _utc_timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")


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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -262,13 +317,16 @@ def __init__(self):
self._chat_type = ""
self._thread_id = ""
self._agent_identity = ""
self._agent_workspace = ""
self._turn_index = 0
self._client = None
self._prefetch_result = ""
self._prefetch_lock = threading.Lock()
self._prefetch_thread = None
self._sync_thread = None
self._session_id = ""
self._parent_session_id = ""
self._document_id = ""

# Tags
self._tags: list[str] | None = None
Expand All @@ -293,6 +351,7 @@ def __init__(self):
# Bank
self._bank_mission = ""
self._bank_retain_mission: str | None = None
self._bank_id_template = ""

@property
def name(self) -> str:
Expand Down Expand Up @@ -485,7 +544,8 @@ def get_config_schema(self):
{"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"]},
Expand Down Expand Up @@ -539,6 +599,15 @@ def _get_client(self):

def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = str(session_id or "").strip()
self._parent_session_id = str(kwargs.get("parent_session_id", "") or "").strip()

# Each process lifecycle gets its own document_id. Reusing session_id
# alone caused overwrites on /resume — the reloaded session starts
# with an empty _session_turns, so the next retain would replace the
# previously stored content. session_id stays in tags so processes
# for the same session remain filterable together.
start_ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
self._document_id = f"{self._session_id}-{start_ts}"

# Check client version and auto-upgrade if needed
try:
Expand Down Expand Up @@ -575,6 +644,7 @@ def initialize(self, session_id: str, **kwargs) -> None:
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")
Expand All @@ -587,7 +657,17 @@ def initialize(self, session_id: str, **kwargs) -> None:
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"

Expand Down Expand Up @@ -640,6 +720,10 @@ def initialize(self, session_id: str, **kwargs) -> None:
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,
Expand Down Expand Up @@ -902,6 +986,12 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
len(self._session_turns), sum(len(t) for t in self._session_turns))
content = "[" + ",".join(self._session_turns) + "]"

lineage_tags: list[str] = []
if self._session_id:
lineage_tags.append(f"session:{self._session_id}")
if self._parent_session_id:
lineage_tags.append(f"parent:{self._parent_session_id}")

def _sync():
try:
client = self._get_client()
Expand All @@ -912,15 +1002,16 @@ def _sync():
message_count=len(self._session_turns) * 2,
turn_index=self._turn_index,
),
tags=lineage_tags or None,
)
item.pop("bank_id", None)
item.pop("retain_async", None)
logger.debug("Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d",
self._bank_id, self._session_id, self._retain_async, len(content), len(self._session_turns))
self._bank_id, self._document_id, self._retain_async, len(content), len(self._session_turns))
_run_sync(client.aretain_batch(
bank_id=self._bank_id,
items=[item],
document_id=self._session_id,
document_id=self._document_id,
retain_async=self._retain_async,
))
logger.debug("Hindsight retain succeeded")
Expand Down
Loading
Loading