{"repo":"NousResearch/hermes-agent","public_id":"46783","display_label":"memory approve command fails when store unavailable","summary":"Multiple reports and fixes around /memory approve and /skills approve failing with 'memory store unavailable' errors across CLI, Desktop, and TUI environments. Work includes fallback behavior using on-disk MemoryStore when no live agent is present.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51056},"resolution":{"state":"partially_resolved","by":"pr-51056"},"freshness":{"state":"verified","last_verified_at":"2026-06-22T21:40:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33866666666666667},"fix_edges":[{"issue_number":46783,"pr_number":51056,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":47363,"pr_number":51056,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":46783,"title":"/memory approve all can fail with \"memory store unavailable\" in Desktop/CLI approval path","url":null,"state":"closed"},{"kind":"issue","number":47363,"title":"[BUG] /memory approve fails with \"memory store unavailable\" in TUI/dashboard chat","url":null,"state":"closed"},{"kind":"issue","number":47941,"title":"write_approval: /memory approve and /skills approve fail with 'memory store unavailable'","url":null,"state":"closed"},{"kind":"pr","number":46926,"title":"fix(memory): apply /memory approve against a fresh on-disk store when no live agent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3147cbb13","number":0},{"kind":"pr","value":"#46783","number":46783}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47488,"title":"fix(cli): fall back to on-disk MemoryStore when agent store unavailable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3147cbb1363554a404e6941f1862981326348d1b","number":0},{"kind":"commit","value":"0e69cd4b37aa3f218ada018d5f0456660e0b726b","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":51056,"title":"fix(memory): apply /memory approve against a fresh on-disk store when no live agent","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/slash_commands.py","hermes_cli/cli_commands_mixin.py","tests/tools/test_write_approval.py","tools/memory_tool.py"],"fix_hunks":[{"file":"gateway/slash_commands.py","hunks":["@@ -2343,7 +2343,7 @@ async def _handle_memory_command(self, event: MessageEvent) -> str:\n         from gateway.run import _hermes_home\n         from hermes_cli.write_approval_commands import handle_pending_subcommand\n         from tools import write_approval as wa\n-        from tools.memory_tool import MemoryStore\n+        from tools.memory_tool import load_on_disk_store\n \n         raw_args = event.get_command_args().strip()\n         args = raw_args.split() if raw_args else []","@@ -2363,8 +2363,8 @@ def _set_approval(enabled: bool):\n \n         # Apply approved writes against a fresh on-disk store (the gateway has\n         # no long-lived agent; the store persists to the same MEMORY/USER.md).\n-        store = MemoryStore()\n-        store.load_from_disk()\n+        # load_on_disk_store() honors the user's configured char limits.\n+        store = load_on_disk_store()\n \n         out = handle_pending_subcommand(\n             wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,"]},{"file":"hermes_cli/cli_commands_mixin.py","hunks":["@@ -1361,6 +1361,17 @@ def _handle_memory_command(self, cmd: str):\n         parts = cmd.strip().split()\n         args = parts[1:] if len(parts) > 1 else []\n         store = getattr(self.agent, \"_memory_store\", None) if getattr(self, \"agent\", None) else None\n+        if store is None:\n+            # No live agent store (e.g. /memory approve invoked from the Desktop\n+            # GUI, or any context without an active agent). Apply against a freshly\n+            # loaded on-disk store, mirroring the gateway path\n+            # (gateway/slash_commands.py): it persists to the same MEMORY/USER.md\n+            # and creates MEMORY.md on the first approved write. Without this the\n+            # shared handler returns \"memory store unavailable\". See #46783.\n+            # load_on_disk_store() honors the user's configured char limits, so\n+            # an approval here enforces the same caps as the live agent would.\n+            from tools.memory_tool import load_on_disk_store\n+            store = load_on_disk_store()\n         out = handle_pending_subcommand(\n             wa.MEMORY, args,\n             memory_store=store,"]},{"file":"tests/tools/test_write_approval.py","hunks":["@@ -107,6 +107,63 @@ def test_memory_gate_on_then_apply(hermes_home):\n     assert \"approved entry\" in store.user_entries[0]\n \n \n+def test_cli_memory_approve_without_live_agent_uses_fresh_store(hermes_home, capsys):\n+    \"\"\"#46783: ``/memory approve`` from a context with no live agent (e.g. the\n+    Desktop GUI) passed ``memory_store=None`` into the shared handler, which\n+    returned \"memory store unavailable\" and applied nothing. The CLI handler must\n+    fall back to a freshly loaded on-disk store, like the gateway path does.\"\"\"\n+    import json\n+    from tools.memory_tool import memory_tool, MemoryStore\n+    from tools import write_approval as wa\n+    from hermes_cli.cli_commands_mixin import CLICommandsMixin\n+\n+    _set_approval(\"memory\", True)\n+    staging = MemoryStore(); staging.load_from_disk()\n+    r = json.loads(memory_tool(\"add\", \"memory\", \"remember the launch date\", store=staging))\n+    assert r.get(\"pending_id\"), r\n+    assert wa.pending_count(\"memory\") == 1\n+\n+    # Bare CLI handler with no live agent → store resolves to None pre-fix.\n+    handler = CLICommandsMixin.__new__(CLICommandsMixin)\n+    handler.agent = None\n+    handler._handle_memory_command(\"/memory approve all\")\n+\n+    out = capsys.readouterr().out\n+    assert \"memory store unavailable\" not in out, out\n+    assert \"Approved 1\" in out, out\n+    assert wa.pending_count(\"memory\") == 0\n+    # The approved write landed in a freshly loaded on-disk store (MEMORY.md).\n+    reloaded = MemoryStore(); reloaded.load_from_disk()\n+    assert any(\"remember the launch date\" in e for e in reloaded.memory_entries)\n+\n+\n+def test_load_on_disk_store_honors_configured_char_limits(hermes_home, monkeypatch):\n+    \"\"\"load_on_disk_store() must read memory.memory_char_limit /\n+    user_char_limit from config so approvals applied without a live agent\n+    enforce the SAME caps as the live agent (agent_init.py). Falls back to\n+    defaults when config can't be loaded.\n+    \"\"\"\n+    from tools.memory_tool import load_on_disk_store\n+\n+    # Config override path: helper picks up the configured limits.\n+    monkeypatch.setattr(\n+        \"hermes_cli.config.load_config\",\n+        lambda: {\"memory\": {\"memory_char_limit\": 999, \"user_char_limit\": 444}},\n+    )\n+    store = load_on_disk_store()\n+    assert store.memory_char_limit == 999\n+    assert store.user_char_limit == 444\n+\n+    # Failure path: config raises → defaults, never blows up.\n+    def _boom():\n+        raise RuntimeError(\"no config\")\n+\n+    monkeypatch.setattr(\"hermes_cli.config.load_config\", _boom)\n+    fallback = load_on_disk_store()\n+    assert fallback.memory_char_limit == 2200\n+    assert fallback.user_char_limit == 1375\n+\n+\n # ---------------------------------------------------------------------------\n # Skill gate\n # ---------------------------------------------------------------------------"]},{"file":"tools/memory_tool.py","hunks":["@@ -731,6 +731,38 @@ def _write_file(path: Path, entries: List[str]):\n             raise RuntimeError(f\"Failed to write memory file {path}: {e}\")\n \n \n+def load_on_disk_store() -> \"MemoryStore\":\n+    \"\"\"Build a fresh on-disk :class:`MemoryStore`, honoring configured char limits.\n+\n+    Use this from any context that has no live agent (the messaging gateway, the\n+    Desktop GUI, the bare CLI ``/memory`` handler) but still needs to read or\n+    apply approved memory writes. Mirrors how the live agent constructs its store\n+    in ``agent/agent_init.py`` — including the user's ``memory.memory_char_limit``\n+    / ``memory.user_char_limit`` overrides — so an approval applied without a live\n+    agent enforces the SAME caps as one applied with one.\n+\n+    Falls back to the built-in defaults if config can't be loaded, so this can\n+    never raise on a missing/unreadable config.\n+    \"\"\"\n+    memory_char_limit = 2200\n+    user_char_limit = 1375\n+    try:\n+        from hermes_cli.config import load_config\n+\n+        mem_cfg = (load_config() or {}).get(\"memory\", {}) or {}\n+        memory_char_limit = int(mem_cfg.get(\"memory_char_limit\", memory_char_limit))\n+        user_char_limit = int(mem_cfg.get(\"user_char_limit\", user_char_limit))\n+    except Exception:\n+        pass  # config optional — fall back to defaults rather than break /memory\n+\n+    store = MemoryStore(\n+        memory_char_limit=memory_char_limit,\n+        user_char_limit=user_char_limit,\n+    )\n+    store.load_from_disk()\n+    return store\n+\n+\n def _apply_write_gate(action: str, target: str, content: Optional[str],\n                       old_text: Optional[str]) -> Optional[str]:\n     \"\"\"Evaluate the memory write gate. Returns a JSON tool-result string when\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}