Skip to content
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
7 changes: 7 additions & 0 deletions src/basic_memory/man/man1/cat(1).md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ duplicate), or truncated to an approximate token budget (`--max-tokens`).
`--lines` and `--section` cannot be combined. A section response includes
start_line and end_line for follow-up `--lines` reads.

Projects in another cloud workspace are not among the session's own
projects. Address them as `<workspace-slug>/<project>/<path>`, or pass
`--project-id` (the MCP tool's `project_id`). A bare `<project>/<path>` never
reaches another workspace; when its first segment names a project there, the
refusal names the workspace and both spellings that work.
`list_memory_projects` (`bm project list`) lists every workspace's projects.

On a TTY the note renders as formatted Markdown; `--plain` writes the raw
content to stdout (slice details go to stderr); `--json`, or piped output,
emits the structured payload with slice metadata (start_line, end_line,
Expand Down
7 changes: 7 additions & 0 deletions src/basic_memory/man/man1/find(1).md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ directory named `" specs "` is addressed by that exact spelling.
routing prefix, a mount point rather than a subtree, and scopes to that
project's root.

Projects in another cloud workspace are not among the session's own
projects. Address them as `<workspace-slug>/<project>/<path>`, or pass
`--project-id` (the MCP tool's `project_id`). A bare `<project>/<path>` never
reaches another workspace; when its first segment names a project there, the
refusal names the workspace and both spellings that work.
`list_memory_projects` (`bm project list`) lists every workspace's projects.

`--fields` is the SELECT to the predicates' WHERE: each hit comes back as the
note's identity — title, permalink, file path, external id, last-updated — plus
a `fields` object carrying the named frontmatter values, so a filtered set can
Expand Down
11 changes: 11 additions & 0 deletions src/basic_memory/man/man1/ls(1).md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ pagination and totals.
`bm ls` lists files inside one project; the unrelated `bm project ls` lists
projects.

The MCP `ls` tool lists the session's projects as mount points at `/`. A cloud
session that can reach several workspaces also gets their slugs and a note on
reaching their projects.

Projects in another cloud workspace are not among the session's own
projects. Address them as `<workspace-slug>/<project>/<path>`, or pass
`--project-id` (the MCP tool's `project_id`). A bare `<project>/<path>` never
reaches another workspace; when its first segment names a project there, the
refusal names the workspace and both spellings that work.
`list_memory_projects` (`bm project list`) lists every workspace's projects.

## OPTIONS

- **--page** (default: 1) — Page number (1-indexed)
Expand Down
138 changes: 135 additions & 3 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1628,6 +1628,138 @@ def _agreed_route_project(
return None


OTHER_WORKSPACES_HINT = (
"projects in other workspaces are addressed as '<workspace-slug>/<project>/<path>' "
"or by project_id; list_memory_projects lists them"
)


async def other_workspace_slugs(context: Optional[Context] = None) -> tuple[str, ...]:
"""Slugs of the accessible workspaces, for a cloud session that can see several.

The ``ls /`` mount view lists this session's own projects only, so without this
an agent reading the root takes it for the whole world (#1778). Every slug
returned is a working '<workspace-slug>/<project>/' prefix, the session's own
included. Empty for a session that does not route to the cloud, so a local
root listing never calls the control plane, and for a single-workspace account.
"""
if not _cloud_workspace_discovery_available(ConfigManager().config):
return ()
# Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886).
from fastmcp.exceptions import ToolError

try:
workspaces = await get_available_workspaces(context=context)
except ToolError as exc:
# Trigger: the control plane timed out or failed while the session's own
# project listing already succeeded.
# Why: the slugs only annotate the root listing; losing them must not turn a
# working `ls /` into an error.
# Outcome: the root lists its mounts without the workspace note.
logger.warning(f"Workspace discovery failed; listing mounts without it: {exc}")
return ()
if len(workspaces) < 2:
return ()
return tuple(sorted(workspace.slug for workspace in workspaces))


def _hint_for_other_workspace_project(
entry: WorkspaceProjectEntry,
path: str,
addressable: tuple[AddressableProject, ...],
) -> str:
"""Name the spellings that reach one project in another workspace.

The qualified spelling is offered only when routing would actually honor it.
Mounts claim leading segments first, so when a mount's permalink equals the
workspace slug, 'acme/docs/foo' reads 'docs/foo' inside the mount 'acme' instead
of project 'docs' in workspace 'acme'. The project_id cannot be shadowed, so it
is always offered.
"""
qualified = f"{entry.workspace.slug}/{entry.project.permalink}/{path}"
try:
shadowed = _claim_mount_prefix(qualified, addressable) is not None
except AmbiguousMountError:
shadowed = True
by_id = f"project_id='{entry.project.external_id}'"
where = f"project '{entry.project.name}' is in workspace '{entry.workspace.slug}'"
if shadowed:
return f"{where} — use {by_id} with path '{path}'"
return f"{where} — use '{qualified}' or {by_id}"


async def _other_workspace_hint(
candidate: str,
addressable: tuple[AddressableProject, ...],
config: BasicMemoryConfig,
context: Optional[Context] = None,
) -> str | None:
"""Teach the spelling that reaches a project the refusal could not route to (#1778).

The refusal lists this session's mounts, and a project in another accessible
workspace is never one of them, so an agent retrying from the message alone
could not find the spelling that works. This names it instead.

It only reads the workspace index; it never routes. An unqualified first segment
must not reach another workspace on its own, or an ordinary project-relative
path reads another workspace's same-named project (#1421). The caller has to
choose the qualified spelling or the project_id this hint gives.

Returns None for a session that does not route to the cloud, or for an account
with a single workspace, so a local refusal reads as it always has. A local
session holding cloud credentials is left out on purpose: the hint would add a
control-plane call to its refusal path, and an unreachable control plane would
then replace the refusal with a network error.
"""
if not _cloud_workspace_discovery_available(config):
return None
# Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886).
from fastmcp.exceptions import ToolError

try:
index = await _ensure_workspace_project_index(context=context)
Comment thread
phernandez marked this conversation as resolved.
except ValueError as exc:
Comment thread
phernandez marked this conversation as resolved.
if any(error in str(exc).lower() for error in _WORKSPACE_DISCOVERY_FALLBACK_ERRORS):
return None
raise
except ToolError as exc:
# Trigger: the control plane timed out or failed while the session's own
# project listing already succeeded.
# Why: the hint only enriches the refusal; losing it must not replace the
# refusal with an unrelated tool failure.
# Outcome: the refusal still lists this session's mounts, without the hint.
logger.warning(f"Workspace discovery failed; refusing without a hint: {exc}")
return None

# Trigger: the refused path's leading segments spell a project in another
# accessible workspace.
# Why: that project is reachable, only not by this spelling. The match uses the
# same longest-prefix splitter as routing, so 'research/2026/foo' names
# 'Research/2026', not a shorter 'Research' beside it. The session's own mounts
# are excluded because the mount table already declined these segments.
# Outcome: name each match with both spellings that reach it.
own_project_ids = {project.external_id for project in addressable if project.external_id}
other_entries = {
permalink: tuple(
entry for entry in entries if entry.project.external_id not in own_project_ids
)
for permalink, entries in index.entries_by_permalink.items()
}
claimed = _split_project_permalink_prefix(
candidate, [permalink for permalink, entries in other_entries.items() if entries]
)
Comment thread
phernandez marked this conversation as resolved.
if claimed is not None:
permalink, remainder = claimed
path = remainder or "<path>"
return "; ".join(
_hint_for_other_workspace_project(entry, path, addressable)
for entry in other_entries[permalink]
)
if len(index.workspaces) > 1:
return OTHER_WORKSPACES_HINT
return None


async def resolve_project_path_route(
path: str,
*,
Expand Down Expand Up @@ -1797,9 +1929,9 @@ async def resolve_project_path_route(
if len(addressable) > 1:
first_segment = candidate.split("/", 1)[0] if candidate else ""
subject = f"no project '{first_segment}'" if first_segment else "no project specified"
raise UnqualifiedPathRefusedError(
f"{subject} — active projects: {_addressable_project_prefixes(addressable)}"
)
message = f"{subject} — active projects: {_addressable_project_prefixes(addressable)}"
hint = await _other_workspace_hint(candidate, addressable, config, context=context)
raise UnqualifiedPathRefusedError(f"{message}. {hint}" if hint else message)

# A session that addresses at most one project has no ambiguity to protect
# against, so unqualified input keeps today's default resolution.
Expand Down
18 changes: 16 additions & 2 deletions src/basic_memory/mcp/tools/posix_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
from basic_memory.mcp.async_client import is_factory_mode
from basic_memory.mcp.note_reads import read_note_json_by_external_id
from basic_memory.mcp.project_context import (
OTHER_WORKSPACES_HINT,
ProjectPathRoute,
addressable_projects,
get_project_client,
other_workspace_slugs,
resolve_project_path_route,
)
from basic_memory.mcp.server import POSIX_TOOLS_TAG, mcp, set_posix_tools_visibility
Expand Down Expand Up @@ -472,6 +474,10 @@ async def _project_mount_listing(
— so every mount advertised here is reachable as '<project>/path' (#1421).
Each row's ``directory_path`` is the copyable '/<project>' prefix form, and
the set already arrives sorted by project name.

A cloud session that can see several workspaces also gets their slugs and a
note on reaching them (#1778). They are not listed as rows: a bare slug is not
a routable path, and every row here must be one.
"""
rows = [
DirectoryNode(
Expand All @@ -489,8 +495,16 @@ async def _project_mount_listing(
page_size=page_size,
total=len(rows),
has_more=start + page_size < len(rows),
)
return listing.model_dump(mode="json")
).model_dump(mode="json")
workspaces = await other_workspace_slugs(context=context)
if workspaces:
listing["workspaces"] = list(workspaces)
listing["note"] = (
"the projects above are this session's workspace; "
+ OTHER_WORKSPACES_HINT
+ f" (workspace slugs: {', '.join(workspaces)})"
)
return listing


@mcp.tool(
Expand Down
Loading
Loading