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
5 changes: 5 additions & 0 deletions src/basic_memory/man/man3/bm-search(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ bm_search(query="launch", note_types=["plan"], compact=True, project="demo")
# → Results for 'launch' in project 'demo':
# - plans/launch-plan.md | launch-plan | score 1.212

bm_search(query="cache layer", valid_at="2026-07-28", project="demo")
# → Results for 'cache layer' in project 'demo':
# - decisions/cache-layer.md | decision: The cache layer will use Memcached.... | score 1.000
# effective valid time: [2026-07-27,) (date)

bm_search(query="launch", mode="text", projects=["demo", "team/research"])
bm_search(query="launch", mode="text", all_projects=True)

Expand Down
6 changes: 3 additions & 3 deletions src/basic_memory/mcp/note_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@


class ReadNoteJsonPayload(TypedDict):
"""Successful ``read_note(output_format="json")`` payload.
"""Successful ``bm_read(output_format="json")`` note payload.

The slice keys appear only on sliced reads (section/lines/max_tokens), whose
``content`` is the server-computed slice with document-absolute coordinates;
Expand All @@ -23,7 +23,7 @@ class ReadNoteJsonPayload(TypedDict):
file_path: str
content: str
frontmatter: dict[str, Any] | None
# The accepted revision's checksum: pass it as write_note's expected_checksum to
# The accepted revision's checksum: pass it as bm_write's expected_checksum to
# overwrite only the revision that was read. None for entities with no accepted
# Markdown (legacy or non-note entities).
checksum: str | None
Expand Down Expand Up @@ -94,7 +94,7 @@ def parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]
def format_note_metadata(metadata: dict[str, Any]) -> str:
"""Format a metadata-only read as plain key/value lines.

The checksum is printed in full: it is the token edit_note and write_note take
The checksum is printed in full: it is the token bm_edit and bm_write take
as expected_checksum, so a truncated one would be useless.
"""
lines = ["# Note metadata"]
Expand Down
9 changes: 4 additions & 5 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ def _workspace_identifier_discovery_available(
if _cloud_workspace_discovery_available(config):
return True

# Identifier detection serves read_note and search, which have no mount
# Identifier detection serves memory:// note resources, which have no mount
# table and no refusal rule, so only the unmistakable three-segment form may
# reach for the network here.
return _local_cloud_discovery_allowed(config) and _is_workspace_route_shaped(identifier)
Expand Down Expand Up @@ -422,9 +422,8 @@ async def invalidate_project_caches(context: Optional[Context] = None) -> None:
The session listing is the one that decides which first path segment names a
project (``addressable_projects``), so leaving it behind made a project
created mid-session unaddressable by name and a deleted one keep routing to
its dead external_id — for ``ls "/"`` and the posix resolver, and, once
identifier detection started reading the same mount table, for ``read_note``
and ``search_notes`` as well (#1432 review).
its dead external_id in identifier detection, which reads the same mount
table (#1432 review).

Deliberately NOT cleared: ``active_workspace`` and ``available_workspaces``
hold workspace metadata — tenant id, slug, type — which no project
Expand Down Expand Up @@ -1048,7 +1047,7 @@ async def detect_project_from_identifier_prefix(
for its reason: only one reading of '<name>/...' can win, and a name ``ls
/`` advertises must address that mount or we advertise an address that
resolves somewhere else. Reading it the other way here meant `ls team/docs`
and `read_note("team/docs/x")` disagreed about what 'team' named. It is also
and `bm_read("team/docs/x")` disagreed about what 'team' named. It is also
the cheaper order — a mount hit never builds the account-wide workspace
index at all.

Expand Down
6 changes: 3 additions & 3 deletions src/basic_memory/mcp/tool_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
DirectoryPath = Annotated[
str,
Field(
# list_directory's `dir_name`, and the word models most often reach for.
# `dir_name` from older callers, and the word models most often reach for.
validation_alias=AliasChoices("path", "dir_name", "folder"),
description=(
"A directory in the project, e.g. 'specs' or 'specs/active'. '/' is the "
Expand Down Expand Up @@ -232,8 +232,8 @@ def next_call(self, tool: str, arguments: Mapping[str, object]) -> str | None:
def _parse_item_kinds(value: Any) -> Any:
"""Split list-shaped input and read the API's word "entity" as "note".

Callers written against `search_notes(entity_types=...)` send "entity"; the alias
alone would carry that value into a field that no longer accepts it.
Callers using the `entity_types` alias often send the API's word "entity"; the
alias alone would carry that value into a field that accepts only "note".
"""
if value is None:
return None
Expand Down
3 changes: 0 additions & 3 deletions src/basic_memory/mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
all tools with the MCP server.
"""

# Shared implementations used by the CLI and the bm_* tools; these do not register MCP tools.
from basic_memory.mcp.tools.search import search_notes as search_notes

# Shared POSIX implementations used by the shell commands.
from basic_memory.mcp.tools.posix_tools import (
cat as cat,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/find_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async def bm_find(
# --- Frontmatter arm ---
if filters is not None:
# "note_type" is the entity column's name for the frontmatter `type`;
# search_notes and find accept both spellings, and so does bm_find.
# bm_find accepts both spellings, as bm_search's filters do.
metadata_filters = {
("type" if key == "note_type" else key): value for key, value in filters.items()
}
Expand Down
20 changes: 10 additions & 10 deletions src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Project listing, creation and deletion behind bm_project and bm_project_delete.

list_memory_projects also serves search across all projects and the Cloud server.
The project listing also serves bm_search across projects and the Cloud server.
"""

import os
Expand Down Expand Up @@ -459,9 +459,9 @@ async def create_memory_project(
Confirmation message with project details

Example:
create_memory_project(project_name="my-research", project_path="~/Documents/research")
create_memory_project(project_name="work-notes", project_path="/home/user/work", set_default=True)
create_memory_project(project_name="team-notes", project_path="/team/notes", workspace="team-paul")
bm_project(command="create", name="my-research", path="~/Documents/research")
bm_project(command="create", name="work-notes", path="/home/user/work", set_default=True)
bm_project(command="create", name="team-notes", path="/team/notes", workspace="team-paul")
"""
# Trigger: MCP server is constrained to a single project.
# Why: constrained sessions cannot create projects, and workspace selectors
Expand All @@ -480,7 +480,7 @@ async def create_memory_project(
workspace_id = await _resolve_workspace_routing(workspace, context, output_format)

# workspace targets a non-default cloud workspace at create time.
# Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces).
# Trigger: caller passed workspace (e.g. a slug from bm_project(command="list")).
# Why: there is no project_id yet for per-project routing — the project doesn't exist.
# Outcome: cloud factory routes the create request to the resolved workspace tenant id.
async with get_client(workspace=workspace_id) as client:
Expand Down Expand Up @@ -692,17 +692,17 @@ async def delete_project(
Slug is preferred for AI callers, but tenant_id and unique name are
also accepted. When omitted, the connection's default workspace is
used. A workspace selector implies cloud routing: without cloud
credentials the call fails fast, matching create_memory_project
behavior.
credentials the call fails fast, as bm_project(command="create")
does.

Returns:
Confirmation message describing what was deleted and whether note
files were removed or retained.

Example:
delete_project("old-project")
delete_project("old-project", delete_notes=True)
delete_project("team-project", workspace="team-paul")
bm_project_delete(name="old-project")
bm_project_delete(name="old-project", delete_notes=True)
bm_project_delete(name="team-project", workspace="team-paul")

Warning:
This action cannot be undone. For a local project with
Expand Down
26 changes: 13 additions & 13 deletions src/basic_memory/mcp/tools/ranked_search.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""bm_search: ranked search over notes, observations and relations.

The query pipeline is search_notes's (`_build_search_query`, the tag: shorthand,
the default search mode), plus three scopes and views:
The query pipeline lives in `search.py` (`_build_search_query`, the tag: shorthand,
the default search mode, the search across projects), plus three scopes and views:

- `lines`: matching-line windows, built by scanning each ranked hit's body for the
query's terms, in any search mode;
Expand Down Expand Up @@ -72,7 +72,7 @@


def _parse_mode(value: Any) -> Any:
"""Read "vector", search_notes' other name for semantic search, as "semantic"."""
"""Read "vector", the older name for semantic search, as "semantic"."""
return "semantic" if value == "vector" else value


Expand Down Expand Up @@ -358,7 +358,7 @@ async def bm_search(
)

# --- Shared query shape ---
query, tags = split_tag_shorthand(query, None)
query, tags = split_tag_shorthand(query)
normalized_note_types = [normalize_note_type(note_type) for note_type in note_types or []]
entity_types = [item.value for item in search_item_types(items) or []]

Expand All @@ -375,20 +375,16 @@ async def bm_search(
after_date=since,
metadata_filters=filters,
tags=tags,
status=None,
min_similarity=min_similarity,
valid_at=valid_at,
valid_overlaps=valid_overlaps,
time_kind=time_kind,
context=context,
compact=compact,
projects=projects,
# bm_search re-qualifies the rows and renders text itself, so it always
# asks for the rows.
output_format="json",
)
match searched:
case MultiProjectRan(rendered=dict() as rows):
case MultiProjectRan(payload=rows):
# Each hit carries its project, so its path can be routed back to it.
payload = {
**rows,
Expand All @@ -405,9 +401,6 @@ async def bm_search(
scope = f"projects {', '.join(projects)}" if projects else "all projects"
next_call = Pagination.of(payload).next_call("bm_search", call_arguments)
return _format_results(payload, query, scope, next_call=next_call)
case MultiProjectRan(rendered=str() as text): # pragma: no cover
# Only a text request renders text, and this call asks for JSON.
return text
case MultiProjectNotIndexed(guidance=guidance):
return (
{"results": [], "guidance": guidance} if output_format == "json" else guidance
Expand Down Expand Up @@ -476,7 +469,6 @@ async def bm_search(
after_date=since,
metadata_filters=filters,
tags=tags,
status=None,
min_similarity=min_similarity,
valid_at=valid_at,
valid_overlaps=valid_overlaps,
Expand Down Expand Up @@ -665,6 +657,14 @@ def _format_results(
if row.get("score") is not None:
header += f" | score {row['score']:.3f}"
lines.append(header)
# A valid-time hit says which authored time matched, and in which units: a
# bare date here would read as an edit date.
for assertion in row.get("temporal") or []:
valid_during = assertion["valid_during"]
lines.append(
f" {assertion['kind']} valid time: {valid_during['literal']} "
f"({valid_during['axis']})"
)
if "windows" in row:
if row["match_count"] == 0:
lines.append(" No matching lines.")
Expand Down
Loading
Loading