diff --git a/CHANGELOG.md b/CHANGELOG.md index f775489..ff7992a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__init__.py b/__init__.py index b6890ac..4359195 100644 --- a/__init__.py +++ b/__init__.py @@ -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 # --------------------------------------------------------------------------- @@ -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: @@ -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]]: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 6dfef41..5eecc0b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -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= → 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):