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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
hydrated only from projects in scope. Every search result, on both routes, now
carries `project_id` and `project_external_id`.

- **#1558**: `search_notes(search_all_projects=True)` runs one scoped query per database
instead of one search per project, so a local vault with many projects is one
query, and each cloud workspace is one query, with results attributed to their
project by the server rather than by which request they came back on. A new
`projects` parameter searches a chosen subset by name or external id; an unknown
name is an error. Cross-database results are still merged by score, one failing
database is skipped with a warning and an inexact total, and a retryable outage
or a server too old to attribute its results fails the whole page.

- **#1512**: Word, PowerPoint, and CSV files get the same sidecar Markdown note a
PDF gets. `bm import document <path>` indexes the project, extracts the file,
and writes `<file>.<ext>.md` next to it plus a run note under
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/man/man3/search-notes(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ MCP:

```
search_notes(query=None, project=None, project_id=None,
search_all_projects=False, page=1, page_size=10,
search_all_projects=False, projects=None, page=1, page_size=10,
search_type=None, output_format="text", note_types=None,
entity_types=None, categories=None, after_date=None,
metadata_filters=None, tags=None, status=None,
Expand Down Expand Up @@ -88,6 +88,7 @@ It is not a hard token budget: titles and metadata can still be large.
- **project** (string | null, optional, default: None) — Project name to search in. Optional - server will resolve using hierarchy. If unknown, use list_memory_projects() to discover available projects.
- **project_id** (string | null, optional, default: None) — Project external_id (UUID). Prefer this over `project` when known — it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects().
- **search_all_projects** (boolean, optional, default: False) — Optional opt-in to search every accessible project. Ignored when `project` or `project_id` is supplied.
- **projects** (array | null, optional, default: None) — Optional list of project names or external ids to search together. Names are matched exactly as list_memory_projects() reports them (cloud projects by their workspace-qualified name). Ignored when `project` or `project_id` is supplied; an unknown name is an error rather than a silent skip.
- **page** (integer, optional, default: 1) — The page number of results to return (default 1). Aliases: page_number.
- **page_size** (integer, optional, default: 10) — The number of results to return per page (default 10). Aliases: limit, per_page.
- **search_type** (string | null, optional, default: None) — Type of search to perform, one of: "text", "title", "permalink", "vector", "semantic", "hybrid". Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text".
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/mcp/clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"""

from basic_memory.mcp.clients.knowledge import KnowledgeClient
from basic_memory.mcp.clients.search import SearchClient
from basic_memory.mcp.clients.search import ScopedSearchClient, SearchClient
from basic_memory.mcp.clients.memory import MemoryClient
from basic_memory.mcp.clients.directory import DirectoryClient
from basic_memory.mcp.clients.resource import ResourceClient
Expand All @@ -22,6 +22,7 @@

__all__ = [
"KnowledgeClient",
"ScopedSearchClient",
"SearchClient",
"MemoryClient",
"DirectoryClient",
Expand Down
89 changes: 70 additions & 19 deletions src/basic_memory/mcp/clients/search.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Typed client for search API operations.
"""Typed clients for search API operations.

Encapsulates all /v2/projects/{project_id}/search/* endpoints.
``SearchClient`` covers /v2/projects/{project_id}/search/*; ``ScopedSearchClient``
covers QUERY /v2/search/, one search over an explicit set of projects.
"""

from collections.abc import Mapping, Sequence
from typing import Any

from httpx import AsyncClient
Expand All @@ -19,6 +21,71 @@
_TEMPORAL_QUERY_FIELDS = ("valid_at", "valid_overlaps", "time_kind")


def _confirm_temporal_filter_applied(query: Mapping[str, Any], payload: Mapping[str, Any]) -> None:
"""Refuse a response that does not confirm a requested valid-time filter ran.

Trigger: this request carried a valid-time filter but the response does not
confirm the server ran it.
Why: SearchQuery ignores unknown fields, so a server predating SPEC-82 accepts
the request and returns results that look filtered. A valid-time query
excludes undated sources; unfiltered results include them, and the caller
would have no way to tell.
Outcome: fail loudly instead of returning a wrong answer that reads as right.
"""
if any(query.get(field) for field in _TEMPORAL_QUERY_FIELDS) and (
payload.get("temporal_applied") is not True
):
raise ValueError(
"The search API did not apply the requested valid-time filter "
"(no temporal_applied confirmation in the response). The server is "
"likely older than this client; upgrade it or drop valid_at / "
"valid_overlaps / time_kind from the query."
)


class ScopedSearchClient:
"""Typed client for ``QUERY /v2/search/``: one search over an explicit set of projects.

``project_ids`` are the internal ids the project list reports; every id must
belong to the database the HTTP client is routed to. An empty set answers no rows.
"""

def __init__(self, http_client: AsyncClient):
self.http_client = http_client

async def search(
self,
query: dict[str, Any],
*,
project_ids: Sequence[int],
page: int = 1,
page_size: int = 10,
) -> SearchResponse:
"""Search the named projects; results carry ``project_id`` and ``project_external_id``."""
from basic_memory.mcp.tools.utils import call_query

with logfire.span(
"mcp.client.search.search_scope",
client_name="search",
operation="search_scope",
project_count=len(project_ids),
page=page,
page_size=page_size,
):
response = await call_query(
self.http_client,
"/v2/search/",
json={**query, "project_ids": list(project_ids)},
params={"page": page, "page_size": page_size},
client_name="search",
operation="search_scope",
path_template="/v2/search/",
)
payload = response.json()
_confirm_temporal_filter_applied(query, payload)
return SearchResponse.model_validate(payload)


class SearchClient:
"""Typed client for search operations.

Expand Down Expand Up @@ -92,21 +159,5 @@ async def search(
retrieval_mode = query.get("retrieval_mode", SearchRetrievalMode.FTS)
payload["total_is_exact"] = retrieval_mode == SearchRetrievalMode.FTS

# Trigger: this request carried a valid-time filter but the response does not
# confirm the server ran it.
# Why: SearchQuery ignores unknown fields, so a server predating SPEC-82 accepts
# the request and returns results that look filtered. A valid-time query
# excludes undated sources; unfiltered results include them, and the caller
# would have no way to tell.
# Outcome: fail loudly instead of returning a wrong answer that reads as right.
if any(query.get(field) for field in _TEMPORAL_QUERY_FIELDS) and (
payload.get("temporal_applied") is not True
):
raise ValueError(
"The search API did not apply the requested valid-time filter "
"(no temporal_applied confirmation in the response). The server is "
"likely older than this client; upgrade it or drop valid_at / "
"valid_overlaps / time_kind from the query."
)

_confirm_temporal_filter_applied(query, payload)
return SearchResponse.model_validate(payload)
3 changes: 3 additions & 0 deletions src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def _merge_projects(
{
"name": name,
"external_id": external_id,
"id": proj.id if proj else None,
"path": path,
"local_path": local_path,
"cloud_path": cloud_path,
Expand Down Expand Up @@ -258,6 +259,7 @@ def _merge_workspace_projects(
{
"name": cloud_proj.name,
"external_id": cloud_proj.external_id,
"id": cloud_proj.id,
"path": local_path or cloud_path,
"local_path": local_path,
"cloud_path": cloud_path,
Expand All @@ -283,6 +285,7 @@ def _merge_workspace_projects(
{
"name": project.name,
"external_id": project.external_id,
"id": project.id,
"path": project.path,
"local_path": project.path,
"cloud_path": None,
Expand Down
Loading
Loading