From a63c52fb1a09829a824c472f6428d104bab1175c Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Sat, 23 May 2026 14:15:41 -0500 Subject: [PATCH] fix: let qualified Basic Memory routes self-resolve --- __init__.py | 38 +++++++++++++++++++++++++++++++++- tests/test_helpers.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 2be6a2a..83af375 100644 --- a/__init__.py +++ b/__init__.py @@ -640,6 +640,42 @@ def shutdown(self, timeout: float = 5.0) -> None: # Argument translation: Hermes-side tool args → BM MCP tool args # --------------------------------------------------------------------------- +_WORKSPACE_HASH_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*-[0-9a-f]{32}$") + + +def _strip_memory_url_prefix(value: str) -> str: + if value.startswith("memory://"): + return value[len("memory://") :] + return value + + +def _looks_workspace_qualified(value: str) -> bool: + """Return True for BM Cloud workspace-qualified identifiers. + + Hermes normally injects its configured default project so calls operate on + the provider's project instead of Basic Memory's process default. But BM + Cloud routes fully-qualified identifiers itself; adding a default local + project makes `personal/main/...` resolve under that local project instead. + """ + path = _strip_memory_url_prefix(value).strip("/") + parts = [part for part in path.split("/") if part] + if len(parts) < 3: + return False + + workspace_slug = parts[0] + return workspace_slug == "personal" or bool(_WORKSPACE_HASH_SLUG_RE.match(workspace_slug)) + + +def _should_omit_default_project(hermes_tool: str, args: Dict[str, Any]) -> bool: + if hermes_tool in {"bm_read", "bm_edit", "bm_delete", "bm_move"}: + identifier = args.get("identifier") + return isinstance(identifier, str) and _looks_workspace_qualified(identifier) + if hermes_tool == "bm_context": + url = args.get("url") + return isinstance(url, str) and _looks_workspace_qualified(url) + return False + + def _translate_args( hermes_tool: str, args: Dict[str, Any], default_project: str ) -> Tuple[str, Dict[str, Any]]: @@ -662,7 +698,7 @@ def _translate_args( out["project_id"] = str(project_id_override) elif project_name_override: out["project"] = str(project_name_override) - else: + elif not _should_omit_default_project(hermes_tool, args): out["project"] = default_project if hermes_tool == "bm_search": diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f4bff58..b5a4ba9 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -174,6 +174,28 @@ def test_translate_read(bm): assert args == {"project": "proj", "identifier": "x/y"} +def test_translate_read_workspace_qualified_identifier_self_routes(bm): + tool, args = bm._translate_args( + "bm_read", + {"identifier": "personal/main/scratch/note"}, + "hermes-memory", + ) + assert tool == "read_note" + assert args == {"identifier": "personal/main/scratch/note"} + + +def test_translate_read_org_workspace_qualified_identifier_self_routes(bm): + tool, args = bm._translate_args( + "bm_read", + {"identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note"}, + "hermes-memory", + ) + assert tool == "read_note" + assert args == { + "identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note" + } + + def test_translate_write(bm): tool, args = bm._translate_args( "bm_write", @@ -252,6 +274,32 @@ def test_translate_context(bm): assert args == {"project": "proj", "url": "memory://x", "depth": 2} +def test_translate_context_workspace_qualified_url_self_routes(bm): + tool, args = bm._translate_args( + "bm_context", + {"url": "memory://personal/main/scratch/note", "depth": 1}, + "hermes-memory", + ) + assert tool == "build_context" + assert args == {"url": "memory://personal/main/scratch/note", "depth": 1} + + +def test_translate_context_org_workspace_qualified_url_self_routes(bm): + tool, args = bm._translate_args( + "bm_context", + { + "url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note", + "depth": 1, + }, + "hermes-memory", + ) + assert tool == "build_context" + assert args == { + "url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note", + "depth": 1, + } + + def test_translate_delete(bm): tool, args = bm._translate_args("bm_delete", {"identifier": "x"}, "proj") assert tool == "delete_note"