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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### 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.
- **`bm_projects` and `bm_workspaces` agent tools.** Promotes the discovery logic previously available only as `/bm-project` and `/bm-workspace` slash commands to agent-facing tools. `bm_projects` returns JSON with `name` and `external_id` (UUID) per project so the agent can hand the UUID to `bm_write` / `bm_read` / etc. via `project_id` — the unambiguous form across cloud workspaces. `bm_workspaces` lists BM Cloud workspaces (name, type, role, default flag). Together with per-call routing, these unblock the workflow Drew's friction note flagged: agent picks the right project + workspace before writing, instead of silently operating against the active Hermes memory project.

### 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.
- Addresses the routing and discovery friction in the real-world note "Hermes Basic Memory Cloud Task Experience." A SKILL.md update with the worked discovery → route → write → verify workflow is the remaining follow-up. A proposed `bm_import` tool was evaluated and dropped — `read_file` + `bm_write` already composes the same operation with no new capability, at the cost of one more tool in the surface.
- The slash commands `/bm-project` and `/bm-workspace` still exist and behave identically — they continue to call `list_memory_projects` / `list_workspaces` directly via the actor. No behavior change for human use.

## [0.2.0] — 2026-05-11

Expand Down
65 changes: 56 additions & 9 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,15 @@
"bm_delete": "delete_note",
"bm_move": "move_note",
"bm_recent": "recent_activity",
"bm_projects": "list_memory_projects",
"bm_workspaces": "list_workspaces",
}

# Discovery tools that operate across all projects/workspaces. They don't
# accept project/project_id args (no per-call routing) and the user-facing
# schemas omit those properties.
_GLOBAL_TOOLS: frozenset = frozenset({"bm_projects", "bm_workspaces"})

TOOL_SCHEMAS: List[Dict[str, Any]] = [
{
"name": "bm_search",
Expand Down Expand Up @@ -198,6 +205,29 @@
},
},
},
{
"name": "bm_projects",
"description": (
"List all available Basic Memory projects (local + cloud). Returns "
"JSON with name and `external_id` (UUID) per project. Use the UUID "
"as `project_id` on other bm_* tools for unambiguous routing across "
"cloud workspaces. Call this when the user names a project that "
"isn't the active one, or when you need to disambiguate same-name "
"projects."
),
"parameters": {"type": "object", "properties": {}},
},
{
"name": "bm_workspaces",
"description": (
"List Basic Memory Cloud workspaces the user belongs to. Workspaces "
"are a BM Cloud concept; local mode returns just the personal "
"workspace. Returns JSON with name, type, role, and default flag. "
"Pair with bm_projects to disambiguate when the same project name "
"exists in multiple workspaces."
),
"parameters": {"type": "object", "properties": {}},
},
]


Expand Down Expand Up @@ -227,6 +257,10 @@
}

for _schema in TOOL_SCHEMAS:
if _schema["name"] in _GLOBAL_TOOLS:
# Discovery tools (bm_projects, bm_workspaces) list everything —
# they don't take per-call routing.
continue
_schema["parameters"]["properties"].update(_PROJECT_ROUTING_PROPS)


Expand Down Expand Up @@ -618,14 +652,18 @@ def _translate_args(
# 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
#
# Global discovery tools (bm_projects, bm_workspaces) list everything
# and don't take routing args at all — skip the block for them.
if hermes_tool not in _GLOBAL_TOOLS:
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"]
Expand Down Expand Up @@ -663,6 +701,10 @@ def _translate_args(
out["page_size"] = int(args["limit"])
if args.get("type"):
out["type"] = args["type"]
elif hermes_tool in _GLOBAL_TOOLS:
# The agent needs to parse identifiers (UUIDs, workspace slugs) out
# of the response, so request JSON regardless of BM's text default.
out["output_format"] = "json"
return bm_tool, out


Expand Down Expand Up @@ -886,8 +928,13 @@ def system_prompt_block(self) -> str:
"maintenance\n"
"- `bm_recent(timeframe)` — list notes updated within a window "
"(default 7d) when there's no specific query yet\n"
"- `bm_projects()` — list available projects (local + cloud) with "
"their UUIDs; call when the user names a project that isn't the "
"active one\n"
"- `bm_workspaces()` — list BM Cloud workspaces; pair with "
"`bm_projects` to disambiguate same-named projects\n"
"\n"
"**Cross-project routing.** Every tool accepts optional `project` "
"**Cross-project routing.** Read/write tools accept 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."
Expand Down
58 changes: 52 additions & 6 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,8 @@ def test_translate_routing_coerces_to_string(bm):
("bm_recent", {}),
])
def test_translate_routing_works_for_every_tool(bm, tool, base_args):
"""Routing applies uniformly across all eight tools."""
"""Routing applies uniformly across every per-project tool. Global
discovery tools (bm_projects, bm_workspaces) are tested separately."""
args_with = dict(base_args, project="main")
_, out = bm._translate_args(tool, args_with, "default-proj")
assert out["project"] == "main"
Expand All @@ -369,15 +370,59 @@ def test_translate_routing_works_for_every_tool(bm, tool, base_args):
assert out["project"] == "default-proj"


# ---- Global discovery tools (bm_projects, bm_workspaces) ----

def test_translate_bm_projects_no_routing(bm):
"""bm_projects is a global discovery tool — it lists across all projects
and workspaces. _translate_args must NOT inject a default project
(would make BM scope the listing) and MUST request JSON so the agent
can parse identifiers out of the response."""
tool, out = bm._translate_args("bm_projects", {}, "default-proj")
assert tool == "list_memory_projects"
assert "project" not in out
assert "project_id" not in out
assert out == {"output_format": "json"}


def test_translate_bm_workspaces_no_routing(bm):
tool, out = bm._translate_args("bm_workspaces", {}, "default-proj")
assert tool == "list_workspaces"
assert "project" not in out
assert "project_id" not in out
assert out == {"output_format": "json"}


def test_translate_global_tools_ignore_project_kwargs(bm):
"""Even if a confused caller passes project/project_id to a global tool,
those args are dropped — BM doesn't accept them and silently scoping
the listing would be worse than ignoring the args."""
_, out = bm._translate_args(
"bm_projects",
{"project": "main", "project_id": "uuid-1"},
"default-proj",
)
assert "project" not in out
assert "project_id" not in out


# ---- 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."""
"""Every per-project 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.

Global discovery tools (bm_projects, bm_workspaces) are excluded — they
list across projects/workspaces and don't take routing args."""
for schema in bm.TOOL_SCHEMAS:
props = schema["parameters"]["properties"]
if schema["name"] in bm._GLOBAL_TOOLS:
assert "project" not in props, \
f"{schema['name']} is a global tool; should not have project prop"
assert "project_id" not in props, \
f"{schema['name']} is a global tool; should not have project_id prop"
continue
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`.
Expand Down Expand Up @@ -405,7 +450,8 @@ def test_hostname_lowercased(bm, monkeypatch):
def test_tool_schemas_complete(bm):
names = {s["name"] for s in bm.TOOL_SCHEMAS}
expected = {"bm_search", "bm_read", "bm_write", "bm_edit",
"bm_context", "bm_delete", "bm_move", "bm_recent"}
"bm_context", "bm_delete", "bm_move", "bm_recent",
"bm_projects", "bm_workspaces"}
assert names == expected


Expand Down
13 changes: 7 additions & 6 deletions tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,27 @@ def test_get_tool_schemas_unconditional(bm):
subsequent bm_* invocation returns "Unknown tool: bm_*" forever.
Schemas are static — return them unconditionally.
"""
# Fresh provider, never initialized, should still expose all 8 schemas
# Fresh provider, never initialized, should still expose all 10 schemas
p = bm.BasicMemoryProvider()
assert p._initialized is False
schemas = p.get_tool_schemas()
assert len(schemas) == 8
assert len(schemas) == 10
names = {s["name"] for s in schemas}
assert names == {"bm_search", "bm_read", "bm_write", "bm_edit",
"bm_context", "bm_delete", "bm_move", "bm_recent"}
"bm_context", "bm_delete", "bm_move", "bm_recent",
"bm_projects", "bm_workspaces"}

# Initialized provider also returns 8 (idempotent)
# Initialized provider also returns 10 (idempotent)
p._initialized = True
assert len(p.get_tool_schemas()) == 8
assert len(p.get_tool_schemas()) == 10


def test_get_tool_schemas_returns_independent_copies(bm):
"""Mutating the returned list shouldn't affect the next call."""
p = bm.BasicMemoryProvider()
schemas = p.get_tool_schemas()
schemas.clear()
assert len(p.get_tool_schemas()) == 8
assert len(p.get_tool_schemas()) == 10


def test_handle_tool_call_uninitialized(bm):
Expand Down
Loading