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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] — 2026-05-12

### Added
- **Per-call project routing on every `bm_*` tool.** All eight tools now accept optional `project` (name) and `project_id` (UUID from `bm_projects`) parameters. The agent can write or read against a project other than the Hermes-configured one — useful when the user asks to write into a different cloud project (e.g. a personal `main` project) without reconfiguring the plugin. `project_id` takes precedence over `project`; both fall back to the configured default when omitted. Workspace routing is handled transparently by BM via `project_id` — no separate workspace parameter is needed.

### Notes
- This is the first of four planned improvements from a real-world friction note ("Hermes Basic Memory Cloud Task Experience"). Follow-ups will: promote `/bm-project` and `/bm-workspace` to agent-facing tools (`bm_projects`, `bm_workspaces`); add a `bm_import` tool for file-on-disk → BM-note in one call; and update SKILL.md with a worked cross-project workflow.

## [0.2.0] — 2026-05-11

### Added
Expand Down
56 changes: 53 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,35 @@
]


# Per-call project routing. Every bm_* tool accepts these — the agent overrides
# Hermes's configured project to read/write against a different Basic Memory
# project (e.g. a personal "main" project on BM Cloud). project_id is the
# UUID-based unambiguous form: required when the same project name exists in
# multiple cloud workspaces. _translate_args sends only one of the two to BM,
# with project_id winning when both are passed.
_PROJECT_ROUTING_PROPS: Dict[str, Dict[str, Any]] = {
"project": {
"type": "string",
"description": (
"Optional. Override the active Basic Memory project (e.g. 'main'). "
"If the same project name exists in multiple cloud workspaces, "
"use project_id instead for unambiguous routing."
),
},
"project_id": {
"type": "string",
"description": (
"Optional. Override by project UUID (external_id from bm_projects). "
"Disambiguates when a project name appears in multiple workspaces. "
"Takes precedence over `project` if both are supplied."
),
},
}

for _schema in TOOL_SCHEMAS:
_schema["parameters"]["properties"].update(_PROJECT_ROUTING_PROPS)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -578,10 +607,26 @@ def shutdown(self, timeout: float = 5.0) -> None:
# ---------------------------------------------------------------------------

def _translate_args(
hermes_tool: str, args: Dict[str, Any], project: str
hermes_tool: str, args: Dict[str, Any], default_project: str
) -> Tuple[str, Dict[str, Any]]:
bm_tool = _HERMES_TO_BM[hermes_tool]
out: Dict[str, Any] = {"project": project}
out: Dict[str, Any] = {}

# Project routing: project_id > project > configured default.
# The agent passes one of these to operate on a project other than the
# one Hermes is configured for. project_id (UUID from bm_projects) is
# the unambiguous form across cloud workspaces — preferred when project
# names might collide between workspaces. Only one of the two reaches
# BM so server-side precedence rules don't enter the picture.
project_id_override = args.get("project_id")
project_name_override = args.get("project")
if project_id_override:
out["project_id"] = str(project_id_override)
elif project_name_override:
out["project"] = str(project_name_override)
else:
out["project"] = default_project

if hermes_tool == "bm_search":
out["query"] = args["query"]
if "limit" in args and args["limit"] is not None:
Expand Down Expand Up @@ -840,7 +885,12 @@ def system_prompt_block(self) -> str:
"- `bm_delete(identifier)` / `bm_move(identifier, new_folder)` — "
"maintenance\n"
"- `bm_recent(timeframe)` — list notes updated within a window "
"(default 7d) when there's no specific query yet"
"(default 7d) when there's no specific query yet\n"
"\n"
"**Cross-project routing.** Every tool accepts optional `project` "
"(name) or `project_id` (UUID). Omit both to use the active "
f"project (`{self._project}`). Use `project_id` (from `bm_projects`) "
"when the same project name exists in multiple cloud workspaces."
)

def get_tool_schemas(self) -> List[Dict[str, Any]]:
Expand Down
95 changes: 95 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,101 @@ def test_translate_recent_full(bm):
}


# ---- Per-call project routing ----

def test_translate_uses_default_project_when_no_override(bm):
"""Existing behavior preserved: with no project override, the configured
default flows through."""
_, args = bm._translate_args("bm_search", {"query": "hi"}, "default-proj")
assert args["project"] == "default-proj"
assert "project_id" not in args


def test_translate_uses_project_name_override(bm):
"""Agent passes project="main" → that name reaches BM, not the default."""
_, args = bm._translate_args(
"bm_search", {"query": "hi", "project": "main"}, "default-proj"
)
assert args["project"] == "main"
assert "project_id" not in args


def test_translate_uses_project_id_override(bm):
"""Agent passes project_id=<uuid> → reaches BM as project_id, with no
project name in the call (would be redundant and risk server-side
precedence surprises)."""
uuid = "01HXYZ123ABC456DEF789GHI"
_, args = bm._translate_args(
"bm_search", {"query": "hi", "project_id": uuid}, "default-proj"
)
assert args["project_id"] == uuid
assert "project" not in args


def test_translate_project_id_wins_when_both_supplied(bm):
"""If the agent passes both, project_id is the more specific identifier
(UUID across workspaces) and takes precedence. Only project_id reaches BM."""
uuid = "01HXYZ123ABC456DEF789GHI"
_, args = bm._translate_args(
"bm_search",
{"query": "hi", "project": "main", "project_id": uuid},
"default-proj",
)
assert args["project_id"] == uuid
assert "project" not in args


def test_translate_routing_coerces_to_string(bm):
"""Defensive: if a model passes a non-string identifier (e.g. an int),
coerce rather than crash. BM accepts strings."""
_, args = bm._translate_args(
"bm_search", {"query": "hi", "project_id": 12345}, "default-proj"
)
assert args["project_id"] == "12345"


@pytest.mark.parametrize("tool,base_args", [
("bm_search", {"query": "x"}),
("bm_read", {"identifier": "x"}),
("bm_write", {"title": "t", "content": "c", "folder": "f"}),
("bm_edit", {"identifier": "x", "operation": "append", "content": "c"}),
("bm_context", {"url": "memory://x"}),
("bm_delete", {"identifier": "x"}),
("bm_move", {"identifier": "x", "new_folder": "f"}),
("bm_recent", {}),
])
def test_translate_routing_works_for_every_tool(bm, tool, base_args):
"""Routing applies uniformly across all eight tools."""
args_with = dict(base_args, project="main")
_, out = bm._translate_args(tool, args_with, "default-proj")
assert out["project"] == "main"

args_with_id = dict(base_args, project_id="uuid-1")
_, out = bm._translate_args(tool, args_with_id, "default-proj")
assert out["project_id"] == "uuid-1"
assert "project" not in out

_, out = bm._translate_args(tool, base_args, "default-proj")
assert out["project"] == "default-proj"


# ---- TOOL_SCHEMAS routing properties ----

def test_every_tool_schema_advertises_project_routing(bm):
"""Every bm_* tool must expose `project` and `project_id` so the agent
sees them in the tool surface. Regression: forgetting to add routing
props to a new tool would silently lock the agent into the active
project — exactly the friction Drew's note flagged."""
for schema in bm.TOOL_SCHEMAS:
props = schema["parameters"]["properties"]
assert "project" in props, f"{schema['name']} missing project prop"
assert "project_id" in props, f"{schema['name']} missing project_id prop"
# Routing is always optional — never in `required`.
required = schema["parameters"].get("required", [])
assert "project" not in required
assert "project_id" not in required


# ---- _default_project / _hostname ----

def test_default_project_format(bm):
Expand Down
Loading