Skip to content
This repository was archived by the owner on May 31, 2026. It is now read-only.
Merged
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
38 changes: 37 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand All @@ -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":
Expand Down
48 changes: 48 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
Loading