Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [SemVer](https://semver.org/) starting from v3.1.2.

## [Unreleased]

### Fixed

- **hermes integration:** `hermes mnemosyne <stats|sleep|inspect|export>` are now
bank-aware under `profile_isolation` — they resolve the active profile bank (or an
explicit `--bank`) instead of always reading the default bank, which reported empty
state when the profile bank held the data. (#362, #363)

## [3.10.0] — 2026-06-18

### Added
Expand Down
75 changes: 73 additions & 2 deletions integrations/hermes/src/mnemosyne_hermes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,34 @@
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

_BANK_HELP = (
"Mnemosyne bank to operate on. Defaults to the active Hermes profile's bank "
"when profile_isolation is enabled, otherwise the shared default bank."
)


def register_cli(subparser):
"""Register CLI subcommands for ``hermes mnemosyne``."""
mn_cmds = subparser.add_subparsers(dest="mnemosyne_cmd")

stats_cmd = mn_cmds.add_parser("stats", help="Show memory statistics")
stats_cmd.add_argument("--global", "-g", action="store_true", help="Show global stats across all sessions")
stats_cmd.add_argument("--bank", type=str, help=_BANK_HELP)

sleep_cmd = mn_cmds.add_parser("sleep", help="Run consolidation cycle")
sleep_cmd.add_argument("--all-sessions", action="store_true", help="Consolidate eligible old working memories across all sessions")
sleep_cmd.add_argument("--dry-run", action="store_true", help="Report what would be consolidated without writing changes")
sleep_cmd.add_argument("--bank", type=str, help=_BANK_HELP)
mn_cmds.add_parser("version", help="Show Mnemosyne version")

inspect_cmd = mn_cmds.add_parser("inspect", help="Search memories")
inspect_cmd.add_argument("query", nargs="?", default="", help="Search query")
inspect_cmd.add_argument("--limit", type=int, default=10, help="Max results")
inspect_cmd.add_argument("--bank", type=str, help=_BANK_HELP)

mn_cmds.add_parser("clear", help="Clear scratchpad")

Expand All @@ -34,6 +43,7 @@ def register_cli(subparser):

export_cmd = mn_cmds.add_parser("export", help="Export all memories to a JSON file")
export_cmd.add_argument("--output", "-o", type=str, required=True, help="Output JSON file path")
export_cmd.add_argument("--bank", type=str, help=_BANK_HELP)

import_cmd = mn_cmds.add_parser("import", help="Import memories from a JSON file or another provider")
import_cmd.add_argument("--input", "-i", type=str, help="Input JSON file path (for file imports)")
Expand All @@ -58,6 +68,60 @@ def register_cli(subparser):
subparser.set_defaults(func=mnemosyne_command)


def _profile_isolation_enabled(hermes_home: str) -> bool:
"""True when ``memory.mnemosyne.profile_isolation`` is set in config.yaml."""
try:
import yaml
with open(os.path.join(hermes_home, "config.yaml")) as f:
cfg = yaml.safe_load(f) or {}
val = (cfg.get("memory", {}) or {}).get("mnemosyne", {}).get("profile_isolation", False)
except Exception:
return False
if isinstance(val, str):
return val.strip().lower() in ("true", "1", "yes", "on")
return bool(val)


def _resolve_cli_bank(args, cmd):
"""Resolve which Mnemosyne bank the CLI beam should bind to.

Under ``profile_isolation`` the provider writes to a per-profile bank
(``<data_dir>/banks/<profile>/mnemosyne.db``), but the CLI historically
always bound to the default/legacy bank — so ``stats`` and friends reported
empty state even when the profile bank held data. Resolve the same bank the
provider would, so the CLI operates on what the agent actually wrote.

Precedence:
1. explicit ``--bank`` (ignored for ``import``, whose ``--bank`` names the
*source* provider bank, not the Mnemosyne target)
2. the active Hermes profile bank, when ``profile_isolation`` is enabled
(mirrors the provider's HERMES_HOME-basename fallback)
3. ``None`` -> default/legacy bank (unchanged behavior)

Never raises: any failure falls back to ``None`` (the default bank).
"""
try:
from . import MnemosyneMemoryProvider
sanitize = MnemosyneMemoryProvider._sanitize_bank_name

if cmd != "import":
explicit = getattr(args, "bank", None)
if explicit:
bank = sanitize(explicit)
return bank if bank != "default" else None

hermes_home = os.environ.get("HERMES_HOME", "")
if not hermes_home or not _profile_isolation_enabled(hermes_home):
return None
basename = Path(hermes_home).name
if not basename or basename.lower() in (".hermes", "hermes", "default", ""):
return None
bank = sanitize(basename)
return bank if bank != "default" else None
except Exception:
return None


def mnemosyne_command(args):
"""Dispatch ``hermes mnemosyne <subcommand>``."""
cmd = getattr(args, "mnemosyne_cmd", None)
Expand All @@ -72,9 +136,16 @@ def mnemosyne_command(args):
except Exception:
pass

bank = _resolve_cli_bank(args, cmd)
try:
from mnemosyne.core.beam import BeamMemory
beam = BeamMemory(session_id="hermes_default")
if bank:
# Bank-aware beam (Mnemosyne routes the bank to its own SQLite DB),
# mirroring how the provider builds its beam under profile_isolation.
from mnemosyne.core.memory import Mnemosyne
beam = Mnemosyne(session_id="hermes_default", bank=bank).beam
else:
from mnemosyne.core.beam import BeamMemory
beam = BeamMemory(session_id="hermes_default")
except Exception as e:
print(f"Error: Mnemosyne not available: {e}")
return 1
Expand Down
64 changes: 64 additions & 0 deletions integrations/hermes/tests/test_cli_bank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tests for profile-isolation-aware bank resolution in the hermes CLI.

Regression coverage for #362: `hermes mnemosyne stats` (and friends) used to
always bind to the default/legacy bank, so under `profile_isolation` they
reported empty state while the profile bank held the real data.
"""

import types

from mnemosyne_hermes.cli import _resolve_cli_bank


def _args(**kw):
return types.SimpleNamespace(**kw)


def _write_config(home, isolation):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
f"memory:\n mnemosyne:\n profile_isolation: {isolation}\n"
)


def test_explicit_bank_takes_precedence_and_is_sanitized(monkeypatch):
monkeypatch.delenv("HERMES_HOME", raising=False)
assert _resolve_cli_bank(_args(bank="Work Stuff"), "stats") == "work_stuff"


def test_profile_bank_resolved_when_isolation_enabled(tmp_path, monkeypatch):
home = tmp_path / "profiles" / "zedd"
_write_config(home, "true")
monkeypatch.setenv("HERMES_HOME", str(home))
assert _resolve_cli_bank(_args(bank=None), "stats") == "zedd"


def test_default_bank_when_isolation_disabled(tmp_path, monkeypatch):
home = tmp_path / "profiles" / "zedd"
_write_config(home, "false")
monkeypatch.setenv("HERMES_HOME", str(home))
assert _resolve_cli_bank(_args(bank=None), "stats") is None


def test_default_bank_when_no_config(tmp_path, monkeypatch):
home = tmp_path / "profiles" / "zedd"
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
assert _resolve_cli_bank(_args(bank=None), "stats") is None


def test_root_hermes_home_is_treated_as_default(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
_write_config(home, "true")
monkeypatch.setenv("HERMES_HOME", str(home))
# The base profile's HERMES_HOME basename (.hermes) maps to the shared bank.
assert _resolve_cli_bank(_args(bank=None), "stats") is None


def test_import_bank_arg_does_not_redirect_target(tmp_path, monkeypatch):
# `import --bank` names the SOURCE provider bank (e.g. Hindsight), not the
# Mnemosyne destination, so it must not be used as the CLI's target bank.
home = tmp_path / "profiles" / "zedd"
_write_config(home, "true")
monkeypatch.setenv("HERMES_HOME", str(home))
assert _resolve_cli_bank(_args(bank="hindsight"), "import") == "zedd"
3 changes: 1 addition & 2 deletions mnemosyne/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
>>> results = recall("user preferences")
"""

__version__ = "3.9.0"
__version__ = "3.10.0"
__version__ = "3.10.2"
__author__ = "Abdias J"
__license__ = "MIT"

Expand Down