diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index be372e8c4..e268e3698 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -141,8 +141,6 @@ async def _resolve_default_project_from_api() -> Optional[str]: async def resolve_project_parameter( project: Optional[str] = None, - allow_discovery: bool = False, - default_project: Optional[str] = None, context: Optional[Context] = None, ) -> Optional[str]: """Resolve project parameter using unified linear priority chain. @@ -155,22 +153,16 @@ async def resolve_project_parameter( 1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority) 2. EXPLICIT: project parameter passed directly 3. DEFAULT: default_project from config (if set) - 4. Fallback: discovery (if allowed) → NONE + 4. Fallback: NONE Args: project: Optional explicit project parameter - allow_discovery: If True, allows returning None for discovery mode - (used by tools like recent_activity that can operate across all projects) - default_project: Optional explicit default project. If not provided, reads from ConfigManager. + context: Optional FastMCP context that caches the active and default project Returns: Resolved project name or None if no resolution possible """ - with logfire.span( - "routing.resolve_project", - requested_project=project, - allow_discovery=allow_discovery, - ): + with logfire.span("routing.resolve_project", requested_project=project): config = ConfigManager().config # Trigger: project already resolved earlier in the same MCP request @@ -186,8 +178,8 @@ async def resolve_project_parameter( # Why: default-project discovery is only needed as a fallback; doing it # for explicit requests adds an avoidable /v2/projects/ round-trip # Outcome: skip default lookup when the active project is already known - if default_project is None and project is None: - # Load config for any values not explicitly provided. + default_project: Optional[str] = None + if project is None: # ConfigManager reads from the local config file, which doesn't exist in cloud mode. # When it returns None, fall back to querying the projects API for the is_default flag. default_project = config.default_project @@ -204,7 +196,7 @@ async def resolve_project_parameter( resolver = ProjectResolver.from_env( default_project=default_project, ) - result = resolver.resolve(project=project, allow_discovery=allow_discovery) + result = resolver.resolve(project=project) return _canonicalize_project_name(result.project, config) diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 68a1d5d8c..704022574 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -6,8 +6,6 @@ """ # Shared implementations used by the CLI and the bm_* tools; these do not register MCP tools. -from basic_memory.mcp.tools.build_context import build_context as build_context -from basic_memory.mcp.tools.recent_activity import recent_activity as recent_activity from basic_memory.mcp.tools.read_note import read_note as read_note from basic_memory.mcp.tools.search import search_notes as search_notes diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py deleted file mode 100644 index 516ba2bad..000000000 --- a/src/basic_memory/mcp/tools/build_context.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Build context tool for Basic Memory MCP server.""" - -from typing import Any, Annotated, Optional, Literal - -import logfire -from loguru import logger -from fastmcp import Context -from pydantic import AliasChoices, Field - -from basic_memory.config import ConfigManager -from basic_memory.mcp.project_context import ( - detect_project_from_memory_url_prefix, - get_project_client, - resolve_project_and_path, -) -from basic_memory.schemas.base import TimeFrame -from basic_memory.schemas.memory import ( - ContextResult, - DEFAULT_CONTEXT_PAGE_SIZE, - DEFAULT_CONTEXT_RELATED_RESULTS, - EntitySummary, - GraphContext, - MAX_CONTEXT_DEPTH, - MAX_CONTEXT_PAGE_SIZE, - MAX_CONTEXT_RELATED_RESULTS, - MemoryUrl, - ObservationSummary, - RelationSummary, -) - - -def _compact_observation(observation: ObservationSummary) -> ObservationSummary: - """Navigate to the owning file without repeating content-derived labels.""" - return observation.model_copy( - update={"title": observation.category, "permalink": observation.file_path} - ) - - -def _compact_context_labels(graph: GraphContext) -> GraphContext: - # Observation titles and permalinks embed source prose. Use the category and - # owning file for discovery; keep numeric and external IDs unchanged. - # Compact requests skip entity observation lists, so only observations that - # surface as primary or related results need relabeling. - return graph.model_copy( - update={ - "results": [ - result.model_copy( - update={ - "primary_result": _compact_observation(result.primary_result) - if isinstance(result.primary_result, ObservationSummary) - else result.primary_result, - "related_results": [ - _compact_observation(item) - if isinstance(item, ObservationSummary) - else item - for item in result.related_results - ], - } - ) - for result in graph.results - ] - } - ) - - -def _format_entity_block( - result: ContextResult, *, compact: bool = False, with_paths: bool = False -) -> str: - """Format a single context result as a markdown block. - - with_paths adds each note's file path, the address bm_read accepts in any - session, beside its permalink. - """ - primary = result.primary_result - lines = [] - - # --- Header --- - lines.append(f"## {primary.title}") - if primary.permalink: - lines.append(f"permalink: {primary.permalink}") - if with_paths: - lines.append(f"path: {primary.file_path}") - # RelationSummary has no content field; Entity/Observation do - if not compact and not isinstance(primary, RelationSummary) and primary.content: - lines.append("") - lines.append(primary.content) - - # --- Observations --- - if result.observations and not compact: - lines.append("") - lines.append("### Observations") - for obs in result.observations: - lines.append(f"- [{obs.category}] {obs.content}") - - # --- Relations (from primary's related_results that are RelationSummary) --- - relation_items: list[RelationSummary] = [ - r for r in result.related_results if isinstance(r, RelationSummary) - ] - if relation_items: - lines.append("") - lines.append("### Relations") - primary_external_id = primary.external_id if isinstance(primary, EntitySummary) else None - for rel in relation_items: - # Unresolved forward references have no resolved entity yet; fall back - # to the literal target text instead of rendering [[None]] (#955) - target = rel.to_entity or rel.to_name - # Trigger: the relation is incoming, or belongs to a neighbor at depth > 1. - # Why: listed bare under this note, `relates_to [[This Note]]` reads as a - # self-link (#1717). - # Outcome: relations this note owns stay bare; others name their source. - is_outgoing = ( - primary_external_id is not None - and rel.from_entity_external_id == primary_external_id - ) - if is_outgoing or rel.from_entity is None: - lines.append(f"- {rel.relation_type} [[{target}]]") - else: - lines.append(f"- [[{rel.from_entity}]] {rel.relation_type} [[{target}]]") - - # --- Related entities (non-relation related results) --- - related_entities: list[EntitySummary | ObservationSummary] = [ - r for r in result.related_results if not isinstance(r, RelationSummary) - ] - if related_entities: - lines.append("") - lines.append("### Related") - for item in related_entities: - address = item.file_path if with_paths else (item.permalink or "") - lines.append(f"- [[{item.title}]] ({address})") - - return "\n".join(lines) - - -def _format_context_markdown( - graph: GraphContext, project: str, *, compact: bool = False, with_paths: bool = False -) -> str: - """Format GraphContext as compact markdown text. - - Produces a human-readable markdown representation that is much smaller - than the equivalent JSON, suitable for LLM consumption when structured - data isn't needed. - """ - if not graph.results: - uri = graph.metadata.uri or "" - return f"No results found for '{uri}' in project '{project}'." - - parts = [] - - # --- Title from first primary result --- - first_title = graph.results[0].primary_result.title - if len(graph.results) == 1: - parts.append(f"# Context: {first_title}") - else: - uri = graph.metadata.uri or "" - parts.append(f"# Context: {uri}") - - parts.append("") - - # --- Entity blocks separated by --- --- - entity_blocks = [ - _format_entity_block(result, compact=compact, with_paths=with_paths) - for result in graph.results - ] - parts.append("\n\n---\n\n".join(entity_blocks)) - - # --- Footer --- - meta = graph.metadata - primary_count = meta.primary_count or 0 - related_count = meta.related_count or 0 - parts.append("") - parts.append("---") - parts.append( - f"*{primary_count} primary, {related_count} related" - f" | depth={meta.depth} | project: {project}*" - ) - - return "\n".join(parts) - - -async def build_context( - url: Annotated[ - MemoryUrl, - Field(validation_alias=AliasChoices("url", "uri", "memory_url")), - ], - project: Optional[str] = None, - project_id: Optional[str] = None, - depth: str | int | None = 1, - timeframe: Annotated[ - Optional[TimeFrame], - Field( - default="7d", - validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"), - ), - ] = "7d", - # `offset` is intentionally NOT aliased: it has different semantics - # (item-indexed vs. 1-indexed page-number). - page: Annotated[ - int, - Field(default=1, validation_alias=AliasChoices("page", "page_number")), - ] = 1, - page_size: Annotated[ - int, - Field( - default=DEFAULT_CONTEXT_PAGE_SIZE, - validation_alias=AliasChoices("page_size", "limit", "per_page"), - ), - ] = DEFAULT_CONTEXT_PAGE_SIZE, - max_related: Annotated[ - int, - Field( - default=DEFAULT_CONTEXT_RELATED_RESULTS, - validation_alias=AliasChoices("max_related", "max_results"), - ), - ] = DEFAULT_CONTEXT_RELATED_RESULTS, - output_format: Literal["json", "text"] = "json", - context: Context | None = None, - compact: Annotated[ - bool, - "Omit note bodies and entity observation lists for graph discovery; the server " - "skips loading observations. Preserve identifiers, relation targets and " - "pagination; use bm_read for selected content. Not a guaranteed token budget.", - ] = False, -) -> dict[str, Any] | str: - """Get context needed to continue a discussion within a specific project. - - This tool enables natural continuation of discussions by loading relevant context - from memory:// URIs. It uses pattern matching to find relevant content and builds - a rich context graph of related information. - - Project Resolution: - Server resolves projects using a unified priority chain (same in local and cloud modes): - Single Project Mode → project parameter → default project. - Uses default project automatically. Specify `project` parameter to target a different project. - - Args: - project: Project name to build context from. Optional - server will resolve using hierarchy. - If unknown, use list_memory_projects() to discover available projects. - project_id: 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(). - url: memory:// URI pointing to discussion content (e.g. memory://specs/search), - or a bare permalink path. - depth: How many relation hops to traverse (1-3 recommended, maximum: 5) - timeframe: How far back to look. Supports natural language like "2 days ago", "last week" - page: Page number of results to return (default: 1) - page_size: Number of primary results to return per page (default: 10, maximum: 50) - max_related: Maximum total related results to return (default: 10, maximum: 100) - output_format: Response format - "json" for structured JSON dict, - "text" for compact markdown text - context: Optional FastMCP context for performance caching. - compact: Omit note bodies and entity observation lists while retaining navigation - summaries. The server skips loading observations for compact requests. - - Returns: - dict (output_format="json"): Structured JSON with internal fields excluded - str (output_format="text"): Compact markdown representation - - Examples: - # Continue a specific discussion - build_context(url="memory://specs/search", project="my-project") - - # Get deeper context about a component - build_context(url="memory://components/memory-service", project="work-docs", depth=2) - - # Get text output for compact context - build_context(url="memory://specs/search", project="research", output_format="text") - - Raises: - ToolError: If project doesn't exist or depth parameter is invalid - """ - # Validate pagination arguments before they reach the context service. - # Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative). - # Why: a non-positive page_size flows into context_service as limit, where the - # primary slice does primary = primary[:limit] — so limit=0 truncates the - # requested entity to [] and the caller's valid memory:// lookup silently - # returns primary_count=0. Mirrors recent_activity's guard for consistency. - # Outcome: caller gets an explicit ValueError instead of a dropped primary result. - if page < 1: - raise ValueError(f"page must be >= 1, got {page}") - if page_size < 1: - raise ValueError(f"page_size must be >= 1, got {page_size}") - # Trigger: a caller tries to turn context traversal into a bulk vault read. - # Why: primary note bodies dominate response size, while broad graph reads also - # increase database work and consume the model context needed for traversal. - # Outcome: keep the tool bounded and direct callers toward pagination and links. - if page_size > MAX_CONTEXT_PAGE_SIZE: - raise ValueError( - f"page_size must be <= {MAX_CONTEXT_PAGE_SIZE}, got {page_size}. " - "bm_context is a bounded traversal starting point; request another " - "page or follow the returned memory:// links." - ) - if max_related < 0: - raise ValueError(f"max_related must be >= 0, got {max_related}") - if max_related > MAX_CONTEXT_RELATED_RESULTS: - raise ValueError( - f"max_related must be <= {MAX_CONTEXT_RELATED_RESULTS}, got {max_related}. " - "bm_context is a bounded traversal starting point; follow the returned " - "memory:// links for more context." - ) - - # Detect project from memory URL prefix before routing. - # project_id routes by external UUID, so it bypasses URL discovery entirely. - if project is None and project_id is None: - detected = await detect_project_from_memory_url_prefix( - url, - ConfigManager().config, - context=context, - ) - if detected is not None: - # The id rides along so the name is never re-resolved against a - # different accessible workspace holding the same permalink (#1432). - project, project_id = detected.project, detected.project_id - - # Convert string depth to integer if needed - if isinstance(depth, str): - try: - depth = int(depth) - except ValueError: - from fastmcp.exceptions import ToolError - - raise ToolError(f"Invalid depth parameter: '{depth}' is not a valid integer") - - # Trigger: a caller asks for a deep traversal of a densely linked project. - # Why: the relation CTE walks every simple path, so each extra hop multiplies - # database work; the API rejects the same range with a 422. - # Outcome: fail here with a clear message instead of a remote validation error. - depth = depth or 1 - if depth < 1 or depth > MAX_CONTEXT_DEPTH: - raise ValueError( - f"depth must be between 1 and {MAX_CONTEXT_DEPTH}, got {depth}. " - "Follow the returned memory:// links to explore further." - ) - - # URL is already validated and normalized by MemoryUrl type annotation - - with logfire.span( - "mcp.tool.build_context", - entrypoint="mcp", - tool_name="build_context", - requested_project=project, - requested_project_id=project_id, - depth=depth, - timeframe=timeframe, - page=page, - page_size=page_size, - max_related=max_related, - output_format=output_format, - is_memory_url=str(url).startswith("memory://"), - ): - async with get_project_client(project, context=context, project_id=project_id) as ( - client, - active_project, - ): - logger.debug( - f"MCP tool call tool=build_context project={active_project.name} " - f"url={url} depth={depth} timeframe={timeframe} output_format={output_format}" - ) - - # Resolve memory:// identifier with project-prefix awareness - _, resolved_path, _ = await resolve_project_and_path( - client, - url, - active_project.name, - context, - ) - - # Import here to avoid circular import - from basic_memory.mcp.clients import MemoryClient - - # Use typed MemoryClient for API calls - memory_client = MemoryClient(client, active_project.external_id) - graph = await memory_client.build_context( - resolved_path, - depth=depth, - timeframe=timeframe, - page=page, - page_size=page_size, - max_related=max_related, - # Trigger: compact discovery asks for graph shape, not bodies. - # Why: loading every entity's observations is the costly part of - # shaping the graph, and compact output never shows them (#1571). - # Outcome: the server skips the observation query; the primary and - # related result selection is unchanged. - include_observations=not compact, - ) - - logger.debug( - f"MCP tool response: tool=build_context project={active_project.name} " - f"uri={graph.metadata.uri or resolved_path} " - f"primary_count={graph.metadata.primary_count or 0} " - f"related_count={graph.metadata.related_count or 0} " - f"output_format={output_format}" - ) - - if compact: - graph = _compact_context_labels(graph) - - if output_format == "text": - return _format_context_markdown(graph, active_project.name, compact=compact) - - # Discovery keeps the same graph and pagination, but leaves source prose - # for an explicit read. The server returned no entity observations, so - # the list is dropped rather than shown empty. Exclude at serialization - # to preserve API models. - if compact: - return graph.model_dump( - exclude={ - "results": { - "__all__": { - "primary_result": {"content"}, - "observations": True, - "related_results": {"__all__": {"content"}}, - } - } - } - ) - - return graph.model_dump() diff --git a/src/basic_memory/mcp/tools/context.py b/src/basic_memory/mcp/tools/context.py index 0c08c537f..64d505962 100644 --- a/src/basic_memory/mcp/tools/context.py +++ b/src/basic_memory/mcp/tools/context.py @@ -19,18 +19,161 @@ from basic_memory.mcp.refusals import refusal from basic_memory.mcp.tool_params import OutputFormat, Page, Pagination, ProjectParam, Since from basic_memory.mcp.workspace_project_index import get_cached_active_workspace -from basic_memory.mcp.tools.build_context import ( - _compact_context_labels, - _format_context_markdown, -) from basic_memory.schemas.memory import ( MAX_CONTEXT_DEPTH, MAX_CONTEXT_PAGE_SIZE, MAX_CONTEXT_RELATED_RESULTS, + ContextResult, + EntitySummary, + GraphContext, + ObservationSummary, + RelationSummary, memory_url_path, ) +# --- Formatting --- + + +def _compact_observation(observation: ObservationSummary) -> ObservationSummary: + """Navigate to the owning file without repeating content-derived labels.""" + return observation.model_copy( + update={"title": observation.category, "permalink": observation.file_path} + ) + + +def _compact_context_labels(graph: GraphContext) -> GraphContext: + # Observation titles and permalinks embed source prose. Use the category and + # owning file for discovery; keep numeric and external IDs unchanged. + # Compact requests skip entity observation lists, so only observations that + # surface as primary or related results need relabeling. + return graph.model_copy( + update={ + "results": [ + result.model_copy( + update={ + "primary_result": _compact_observation(result.primary_result) + if isinstance(result.primary_result, ObservationSummary) + else result.primary_result, + "related_results": [ + _compact_observation(item) + if isinstance(item, ObservationSummary) + else item + for item in result.related_results + ], + } + ) + for result in graph.results + ] + } + ) + + +def _format_entity_block(result: ContextResult, *, compact: bool = False) -> str: + """Format a single context result as a markdown block. + + Each note carries its file path beside its permalink: a permalink need not + carry the project prefix, and the path is the address bm_read takes in any session. + """ + primary = result.primary_result + lines = [] + + # --- Header --- + lines.append(f"## {primary.title}") + if primary.permalink: + lines.append(f"permalink: {primary.permalink}") + lines.append(f"path: {primary.file_path}") + # RelationSummary has no content field; Entity/Observation do + if not compact and not isinstance(primary, RelationSummary) and primary.content: + lines.append("") + lines.append(primary.content) + + # --- Observations --- + if result.observations and not compact: + lines.append("") + lines.append("### Observations") + for obs in result.observations: + lines.append(f"- [{obs.category}] {obs.content}") + + # --- Relations (from primary's related_results that are RelationSummary) --- + relation_items: list[RelationSummary] = [ + r for r in result.related_results if isinstance(r, RelationSummary) + ] + if relation_items: + lines.append("") + lines.append("### Relations") + primary_external_id = primary.external_id if isinstance(primary, EntitySummary) else None + for rel in relation_items: + # Unresolved forward references have no resolved entity yet; fall back + # to the literal target text instead of rendering [[None]] (#955) + target = rel.to_entity or rel.to_name + # Trigger: the relation is incoming, or belongs to a neighbor at depth > 1. + # Why: listed bare under this note, `relates_to [[This Note]]` reads as a + # self-link (#1717). + # Outcome: relations this note owns stay bare; others name their source. + is_outgoing = ( + primary_external_id is not None + and rel.from_entity_external_id == primary_external_id + ) + if is_outgoing or rel.from_entity is None: + lines.append(f"- {rel.relation_type} [[{target}]]") + else: + lines.append(f"- [[{rel.from_entity}]] {rel.relation_type} [[{target}]]") + + # --- Related entities (non-relation related results) --- + related_entities: list[EntitySummary | ObservationSummary] = [ + r for r in result.related_results if not isinstance(r, RelationSummary) + ] + if related_entities: + lines.append("") + lines.append("### Related") + for item in related_entities: + lines.append(f"- [[{item.title}]] ({item.file_path})") + + return "\n".join(lines) + + +def _format_context_markdown(graph: GraphContext, project: str, *, compact: bool = False) -> str: + """Format GraphContext as compact markdown text. + + Produces a human-readable markdown representation that is much smaller + than the equivalent JSON, suitable for LLM consumption when structured + data isn't needed. + """ + if not graph.results: + uri = graph.metadata.uri or "" + return f"No results found for '{uri}' in project '{project}'." + + parts = [] + + # --- Title from first primary result --- + first_title = graph.results[0].primary_result.title + if len(graph.results) == 1: + parts.append(f"# Context: {first_title}") + else: + uri = graph.metadata.uri or "" + parts.append(f"# Context: {uri}") + + parts.append("") + + # --- Entity blocks separated by --- --- + entity_blocks = [_format_entity_block(result, compact=compact) for result in graph.results] + parts.append("\n\n---\n\n".join(entity_blocks)) + + # --- Footer --- + meta = graph.metadata + primary_count = meta.primary_count or 0 + related_count = meta.related_count or 0 + parts.append("") + parts.append("---") + parts.append( + f"*{primary_count} primary, {related_count} related" + f" | depth={meta.depth} | project: {project}*" + ) + + return "\n".join(parts) + + @mcp.tool( name="bm_context", title="Build context", @@ -134,11 +277,7 @@ async def bm_context( if compact: graph = _compact_context_labels(graph) if output_format == "text": - # A permalink need not carry the project prefix, so the text names each - # note's qualified file path too: the address bm_read takes in any session. - text = _format_context_markdown( - graph, target.project.name, compact=compact, with_paths=True - ) + text = _format_context_markdown(graph, target.project.name, compact=compact) next_call = Pagination(page=page, page_size=page_size, has_more=graph.has_more).next_call( "bm_context", { diff --git a/src/basic_memory/mcp/tools/projects.py b/src/basic_memory/mcp/tools/projects.py index ac625f27c..ac5a46f3a 100644 --- a/src/basic_memory/mcp/tools/projects.py +++ b/src/basic_memory/mcp/tools/projects.py @@ -118,9 +118,7 @@ async def bm_project( ) project_name, project_id = routing_arguments(parse_project(project)) activity = await recent_activity_page( - # recent_activity names whole notes "entity", the API's word. - type=["entity" if item == "note" else item for item in items] if items else "", - depth=1, + items=items, timeframe=since, page=page, page_size=page_size, @@ -136,16 +134,12 @@ async def bm_project( case str() as text if output_format == "text": return text case RecentRows() as recent: - payload: dict[str, Any] = { + return { "results": recent.rows, "page": recent.page, "page_size": recent.page_size, "has_more": recent.has_more, } - # Projects never indexed, beside the activity of those that were. - if recent.guidance: - payload["guidance"] = "\n\n".join(recent.guidance) - return payload case str() as guidance: return {"results": [], "guidance": guidance} diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index 3a7b9c999..451446b17 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -1,168 +1,37 @@ -"""Recent activity tool for Basic Memory MCP server.""" +"""Recent activity for bm_project(command="recent"). + +One project's recently changed notes, observations and relations, rendered as text +or returned as rows with paging for JSON callers. +""" from dataclasses import dataclass -from datetime import timezone from pathlib import PurePosixPath -from typing import Any, Annotated, List, Union, Optional, Literal +from typing import Any, Literal -from loguru import logger from fastmcp import Context -from pydantic import AliasChoices, Field +from loguru import logger -from basic_memory.mcp.async_client import get_client from basic_memory.mcp.index_readiness import project_index_required -from basic_memory.mcp.project_context import ( - get_project_client, - resolve_project_parameter, -) +from basic_memory.mcp.project_context import get_project_client +from basic_memory.mcp.tool_params import ItemKind, search_item_types from basic_memory.mcp.tools.utils import call_get -from basic_memory.schemas.base import TimeFrame -from basic_memory.schemas.memory import ( - GraphContext, - MAX_CONTEXT_DEPTH, - MAX_RECENT_ACTIVITY_PAGE_SIZE, - ProjectActivity, - ActivityStats, -) -from basic_memory.schemas.project_info import ProjectList, ProjectItem +from basic_memory.schemas.memory import GraphContext from basic_memory.schemas.search import SearchItemType -async def recent_activity( - type: Annotated[ - Union[str, List[str]], - Field(default="", validation_alias=AliasChoices("type", "types", "kind")), - ] = "", - depth: int = 1, - timeframe: Annotated[ - TimeFrame, - Field( - default="7d", - validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"), - ), - ] = "7d", - # `offset` is intentionally NOT aliased: it has different semantics - # (item-indexed vs. 1-indexed page-number). - page: Annotated[ - int, - Field(default=1, validation_alias=AliasChoices("page", "page_number")), - ] = 1, - page_size: Annotated[ - int, - Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")), - ] = 10, - project: Optional[str] = None, - project_id: Optional[str] = None, - output_format: Literal["text", "json"] = "text", - context: Context | None = None, -) -> str | list[dict[str, Any]]: - """Get recent activity for one project. - - Project Resolution: - The server resolves projects in this order: - 1. Single Project Mode - server constrained to one project, parameter ignored - 2. Explicit project parameter - specify which project to query - 3. Default project - server configured default if no project specified - - Discovery Mode: - When no specific project can be resolved, returns activity across all projects - to help discover available projects and their recent activity. - - Project Discovery (when project is unknown): - 1. Call list_memory_projects() to see available projects - 2. Ask the user which project to focus on - 3. Pass that project to this tool - 4. Remember their choice for the conversation - - Args: - type: Filter by content type(s). Can be a string or list of strings. - Valid options: - - "entity" or ["entity"] for knowledge entities - - "relation" or ["relation"] for connections between entities - - "observation" or ["observation"] for notes and observations - Multiple types can be combined: ["entity", "relation"] - Case-insensitive: "ENTITY" and "entity" are treated the same. - Default is entity-only. Specify other types explicitly to include - observations and relations. - depth: How many relation hops to traverse (1-3 recommended, maximum: 5) - page: Page number for pagination (default 1) - page_size: Number of items per page (default 10, maximum: 100) - timeframe: Time window to search. Supports natural language: - - Relative: "2 days ago", "last week", "yesterday" - - Points in time: "2024-01-01", "January 1st" - - Standard format: "7d", "24h" - Aliases: since, time_range, lookback. - project: Project name to query. Optional - when omitted, the server uses the - configured default project, and discovery mode across all projects - applies only when there is no default. - If unknown, use list_memory_projects() to discover available projects. - project_id: 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(). - output_format: "text" returns human-readable summary text. "json" returns - a flat list of recent items. - context: Optional FastMCP context for performance caching. - - Returns: - Human-readable summary of recent activity. When no specific project is - resolved, returns cross-project discovery information. When a specific - project is resolved, returns detailed activity for that project. - - Examples: - # The active or default project; every project only when neither is set - recent_activity() - recent_activity(timeframe="yesterday") - - # Project-specific activity - recent_activity(project="work-docs", type="entity", timeframe="yesterday") - recent_activity(project="research", type=["entity", "relation"], timeframe="today") - recent_activity(project="notes", type="entity", depth=2, timeframe="2 weeks ago") - - Raises: - ToolError: If project doesn't exist or type parameter contains invalid values - - Notes: - - Higher depth values (>3) may impact performance with large result sets - - For focused queries, consider using build_context with a specific URI - - Max timeframe is 1 year in the past - """ - page_result = await recent_activity_page( - type=type, - depth=depth, - timeframe=timeframe, - page=page, - page_size=page_size, - project=project, - project_id=project_id, - output_format=output_format, - context=context, - ) - match page_result: - case RecentRows(rows=rows): - return rows - case str() as text: - return text - - @dataclass(frozen=True, slots=True) class RecentRows: - """One page of recent activity as rows, and whether another page follows. - - In discovery mode `has_more` is true when any project has another page. - """ + """One page of recent activity as rows, and whether another page follows.""" rows: list[dict[str, Any]] page: int page_size: int has_more: bool - # Indexing guidance for projects that were never indexed (discovery mode). - guidance: tuple[str, ...] = () async def recent_activity_page( *, - type: str | list[str], - depth: int, + items: list[ItemKind] | None, timeframe: str, page: int, page_size: int, @@ -171,379 +40,96 @@ async def recent_activity_page( output_format: Literal["text", "json"], context: Context | None, ) -> str | RecentRows: - """Recent activity as text, or as rows with paging for JSON callers.""" - # Validate pagination arguments before they reach the API layer, - # where negative offset would cause a database error. - if page < 1: - raise ValueError(f"page must be >= 1, got {page}") - if page_size < 1: - raise ValueError(f"page_size must be >= 1, got {page_size}") - if page_size > MAX_RECENT_ACTIVITY_PAGE_SIZE: - raise ValueError(f"page_size must be <= {MAX_RECENT_ACTIVITY_PAGE_SIZE}, got {page_size}") - # Zero keeps meaning "use the default depth"; the API rejects the rest with a 422. - if depth < 0 or depth > MAX_CONTEXT_DEPTH: - raise ValueError(f"depth must be between 1 and {MAX_CONTEXT_DEPTH}, got {depth}") - - # Build common parameters for API calls + """Recent activity as text, or as rows with paging for JSON callers. + + A str is either the rendered text or, for a project that was never indexed, + the indexing guidance in place of an empty result. + """ + # Trigger: the caller named no item kinds. + # Why: one well-connected note would otherwise fill the page with its own + # observations and relations. + # Outcome: whole notes only. + item_types = search_item_types(items) or [SearchItemType.ENTITY] params: dict[str, Any] = { "page": page, "page_size": page_size, "max_related": 10, + "depth": 1, + "timeframe": timeframe, + "type": [item_type.value for item_type in item_types], } - if depth: - params["depth"] = depth - if timeframe: - params["timeframe"] = timeframe # pyright: ignore - - # Validate and convert type parameter - if type: - # Convert single string to list - if isinstance(type, str): - type_list = [type] - else: - type_list = type - - # Validate each type against SearchItemType enum - validated_types = [] - for t in type_list: - try: - # Try to convert string to enum - if isinstance(t, str): - validated_types.append(SearchItemType(t.lower())) - except ValueError: - valid_types = [t.value for t in SearchItemType] - raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}") - - # Add validated types to params - params["type"] = [t.value for t in validated_types] # pyright: ignore - - # Default to entity-only when no explicit type was provided. - # This prevents a single well-connected entity from filling the page - # with its observations and relations. - if "type" not in params: - params["type"] = [SearchItemType.ENTITY.value] - - # Resolve project parameter using the three-tier hierarchy. - # allow_discovery=True enables Discovery Mode, so a project is not required. - # project_id (UUID) takes precedence over project name — without this fallback, - # callers passing only project_id would fall into Discovery Mode. - effective_identifier = project_id if project_id else project - resolved_project = await resolve_project_parameter( - effective_identifier, allow_discovery=True, context=context - ) - if resolved_project is None: - # Discovery Mode: Get activity across all projects - # Uses plain get_client() since we iterate across all projects (no single project routing) + async with get_project_client(project, context=context, project_id=project_id) as ( + client, + active_project, + ): logger.debug( - f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}" + f"Getting recent activity from project {active_project.name}: " + f"items={items}, timeframe={timeframe}" ) - - async with get_client() as client: - # Get list of all projects - response = await call_get(client, "/v2/projects/") - project_list = ProjectList.model_validate(response.json()) - - projects_activity = {} - total_items = 0 - total_entities = 0 - total_relations = 0 - total_observations = 0 - most_active_project = None - most_active_count = 0 - active_projects = 0 - - # Guidance for each project that was never indexed, kept beside the - # activity of the projects that were, so neither hides the other. - unindexed: list[str] = [] - - # Query each project's activity - for project_info in project_list.projects: - project_activity = await _get_project_activity(client, project_info, params, depth) - if not project_activity.item_count: - guidance = await project_index_required(client, project_info) - if guidance is not None: - unindexed.append(guidance) - projects_activity[project_info.name] = project_activity - - # Aggregate stats - item_count = project_activity.item_count - if item_count > 0: - active_projects += 1 - total_items += item_count - - # Count by type - for result in project_activity.activity.results: - if result.primary_result.type == "entity": - total_entities += 1 - elif result.primary_result.type == "relation": - total_relations += 1 - elif result.primary_result.type == "observation": - total_observations += 1 - - # Track most active project - if item_count > most_active_count: - most_active_count = item_count - most_active_project = project_info.name - - # Trigger: no project has activity and some were never indexed. - # Why: an empty summary would read as "nothing happened" when the - # unindexed projects were never searched. - # Outcome: the indexing guidance alone, as before. - if unindexed and active_projects == 0: - return "\n\n".join(unindexed) - - if output_format == "json": - rows: list[dict[str, Any]] = [] - for project_name, project_activity in projects_activity.items(): - rows.extend(_extract_recent_rows(project_activity.activity, project_name)) - return RecentRows( - guidance=tuple(unindexed), - rows=rows, - page=page, - page_size=page_size, - has_more=any(activity.activity.has_more for activity in projects_activity.values()), - ) - - # Build summary stats - summary = ActivityStats( - total_projects=len(project_list.projects), - active_projects=active_projects, - most_active_project=most_active_project, - total_items=total_items, - total_entities=total_entities, - total_relations=total_relations, - total_observations=total_observations, - ) - - # Summarize where activity is; which project to use stays the caller's call. - guidance_lines = ["\n" + "─" * 40] - - if active_projects == 0: - guidance_lines.append("No recent activity found in any project.") - else: - # At least one project has activity: name the most active project. - suggested_project = most_active_project or next( - (name for name, activity in projects_activity.items() if activity.item_count > 0), - None, - ) - if suggested_project: - suffix = ( - f"(most active with {most_active_count} items)" if most_active_count > 0 else "" - ) - guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip()) - - # Projects never indexed are named after the activity found elsewhere. - for project_guidance in unindexed: - guidance_lines.extend(["", project_guidance]) - guidance = "\n".join(guidance_lines) - - # Format discovery mode output - return _format_discovery_output(projects_activity, summary, timeframe, guidance) - - else: - # Project-Specific Mode: Get activity for specific project - # Uses get_project_client() for per-project routing (local vs cloud) - async with get_project_client(resolved_project, context=context, project_id=project_id) as ( + response = await call_get( client, - active_project, - ): - # Trigger: caller routed by project_id (a UUID), so resolved_project holds the - # raw UUID rather than a human-readable name. - # Why: active_project.name is the canonical, display-safe project name regardless - # of whether routing was by name or by external_id. - # Outcome: logs and the formatted text header always show the project name. - logger.debug( - f"Getting recent activity from project {active_project.name}: " - f"type={type}, depth={depth}, timeframe={timeframe}" - ) - - response = await call_get( - client, - f"/v2/projects/{active_project.external_id}/memory/recent", - params=params, - ) - activity_data = GraphContext.model_validate(response.json()) - - # Do not offer first-note onboarding before the project was indexed. - if not activity_data.results: - guidance = await project_index_required(client, active_project) - if guidance is not None: - return guidance - - if output_format == "json": - return RecentRows( - rows=_extract_recent_rows(activity_data), - page=page, - page_size=page_size, - has_more=activity_data.has_more, - ) - - # Format project-specific mode output. Pass the external id so onboarding examples - # route by id, not by a name that can collide across cloud workspaces. - return _format_project_output( - active_project.name, - activity_data, - timeframe, - page=page, - project_id=active_project.external_id, - type_filter_applied=bool(type), - ) - - -async def _get_project_activity( - client, project_info: ProjectItem, params: dict[str, Any], depth: int -) -> ProjectActivity: - """Get activity data for a single project. - - Args: - client: HTTP client for API calls - project_info: Project information - params: Query parameters for the activity request - depth: Graph traversal depth + f"/v2/projects/{active_project.external_id}/memory/recent", + params=params, + ) + activity_data = GraphContext.model_validate(response.json()) + + # Do not offer first-note onboarding before the project was indexed. + if not activity_data.results: + guidance = await project_index_required(client, active_project) + if guidance is not None: + return guidance + + if output_format == "json": + return RecentRows( + rows=_extract_recent_rows(activity_data), + page=page, + page_size=page_size, + has_more=activity_data.has_more, + ) - Returns: - ProjectActivity with activity data or empty activity on error - """ - activity_response = await call_get( - client, - f"/v2/projects/{project_info.external_id}/memory/recent", - params=params, - ) - activity = GraphContext.model_validate(activity_response.json()) - - # Extract last activity timestamp and active folders - last_activity = None - active_folders = set() - - for result in activity.results: - if result.primary_result.created_at: - current_time = result.primary_result.created_at - if current_time.tzinfo is None: - current_time = current_time.replace(tzinfo=timezone.utc) - - if last_activity is None: - last_activity = current_time - else: - if current_time > last_activity: - last_activity = current_time - - # Extract folder from file_path - if result.primary_result.file_path: - folder = str(PurePosixPath(result.primary_result.file_path).parent) - if folder and folder != ".": - active_folders.add(folder) - - return ProjectActivity( - project_name=project_info.name, - project_path=project_info.path, - activity=activity, - item_count=len(activity.results), - last_activity=last_activity, - active_folders=list(active_folders)[:5], # Limit to top 5 folders + # Pass the external id so onboarding examples route by id, not by a name that + # can collide across cloud workspaces. + return _format_project_output( + active_project.name, + activity_data, + timeframe, + page=page, + project_id=active_project.external_id, + type_filter_applied=bool(items), ) -def _extract_recent_rows( - activity_data: GraphContext, project_name: Optional[str] = None -) -> list[dict[str, Any]]: +def _extract_recent_rows(activity_data: GraphContext) -> list[dict[str, Any]]: """Flatten GraphContext into a list of recent rows.""" rows: list[dict[str, Any]] = [] for result in activity_data.results: primary = result.primary_result - row = { - "type": primary.type, - "title": primary.title, - "permalink": primary.permalink, - "file_path": primary.file_path, - "created_at": primary.created_at.isoformat() if primary.created_at else None, - } - if project_name is not None: - row["project"] = project_name - rows.append(row) - return rows - - -def _format_discovery_output( - projects_activity: dict[str, Any], summary: ActivityStats, timeframe: str, guidance: str -) -> str: - """Format discovery mode output as human-readable text.""" - lines = [f"## Recent Activity Summary ({timeframe})"] - - # Most active project section - if summary.most_active_project and summary.total_items > 0: - most_active = projects_activity[summary.most_active_project] - lines.append( - f"\n**Most Active Project:** {summary.most_active_project} ({most_active.item_count} items)" + rows.append( + { + "type": primary.type, + "title": primary.title, + "permalink": primary.permalink, + "file_path": primary.file_path, + "created_at": primary.created_at.isoformat() if primary.created_at else None, + } ) - - # Get latest activity from most active project - if most_active.activity.results: - latest = most_active.activity.results[0].primary_result - title = latest.title or "Recent activity" - # Format relative time - time_str = ( - _format_relative_time(latest.created_at) if latest.created_at else "unknown time" - ) - lines.append(f"- 🔧 **Latest:** {title} ({time_str})") - - # Active folders - if most_active.active_folders: - folders = ", ".join(most_active.active_folders[:3]) - lines.append(f"- 📋 **Focus areas:** {folders}") - - # Other active projects - other_active = [ - (name, activity) - for name, activity in projects_activity.items() - if activity.item_count > 0 and name != summary.most_active_project - ] - - if other_active: - lines.append("\n**Other Active Projects:**") - for name, activity in sorted(other_active, key=lambda x: x[1].item_count, reverse=True)[:4]: - lines.append(f"- **{name}** ({activity.item_count} items)") - - # Key developments - extract from recent entities - key_items = [] - for name, activity in projects_activity.items(): - if activity.item_count > 0: - for result in activity.activity.results[:3]: # Top 3 from each active project - if result.primary_result.type == "entity": - title = result.primary_result.title - # Look for status indicators in titles - if any(word in title.lower() for word in ["complete", "fix", "test", "spec"]): - key_items.append(title) - - if key_items: - lines.append("\n**Key Developments:**") - for item in key_items[:5]: # Show top 5 - status = "✅" if any(word in item.lower() for word in ["complete", "fix"]) else "🧪" - lines.append(f"- {status} **{item}**") - - # Add summary stats - lines.append( - f"\n**Summary:** {summary.active_projects} active projects, {summary.total_items} recent items" - ) - - # Add guidance - lines.append(guidance) - - return "\n".join(lines) + return rows def _format_project_output( project_name: str, activity_data: GraphContext, timeframe: str, - page: int = 1, - project_id: str | None = None, - type_filter_applied: bool = False, + *, + page: int, + project_id: str, + type_filter_applied: bool, ) -> str: - """Format project-specific mode output as human-readable text. + """Format one project's recent activity as human-readable text. - `project_id` is the project's route-safe external id. When present, onboarding examples use - it instead of the display name so a model following them cannot land in a different + `project_id` is the project's route-safe external id. Onboarding examples use it + instead of the display name so a model following them cannot land in a different same-named project in another cloud workspace. """ lines = [f"## Recent Activity: {project_name} ({timeframe})"] @@ -569,12 +155,11 @@ def _format_project_output( return "\n".join(lines) lines.append(f"\nNo recent activity in '{project_name}' within {timeframe}.") - # recent_activity is the orientation call a model makes at session start, so an empty + # Recent activity is the orientation call a model makes at session start, so an empty # result is the natural moment to help. Stay offer-not-act, and cover both a brand-new # (empty) knowledge base and an established one that is merely quiet in this window. - # Prefer the external id in examples: names collide across cloud workspaces, project_id - # does not. - route = f'project="{project_id}"' if project_id else f'project="{project_name}"' + # Examples name the external id: names collide across cloud workspaces, ids do not. + route = f'project="{project_id}"' lines.append("") lines.append( "If the user is just getting started and has no notes yet, briefly explain that " @@ -669,47 +254,6 @@ def _format_project_output( return "\n".join(lines) -def _format_relative_time(timestamp) -> str: - """Format timestamp as relative time like '2 hours ago'.""" - try: - from datetime import datetime, timezone - from dateutil.relativedelta import relativedelta - - if isinstance(timestamp, str): - # Parse ISO format timestamp - dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - else: - dt = timestamp - - now = datetime.now(timezone.utc) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - - # Use relativedelta for accurate time differences - diff = relativedelta(now, dt) - - if diff.years > 0: - return f"{diff.years} year{'s' if diff.years > 1 else ''} ago" - elif diff.months > 0: - return f"{diff.months} month{'s' if diff.months > 1 else ''} ago" - elif diff.days > 0: - if diff.days == 1: - return "yesterday" - elif diff.days < 7: - return f"{diff.days} days ago" - else: - weeks = diff.days // 7 - return f"{weeks} week{'s' if weeks > 1 else ''} ago" - elif diff.hours > 0: - return f"{diff.hours} hour{'s' if diff.hours > 1 else ''} ago" - elif diff.minutes > 0: - return f"{diff.minutes} minute{'s' if diff.minutes > 1 else ''} ago" - else: - return "just now" - except Exception: - return "recently" - - def _truncate_at_word(text: str, max_length: int) -> str: """Truncate text at word boundary.""" if len(text) <= max_length: diff --git a/src/basic_memory/project_resolver.py b/src/basic_memory/project_resolver.py index 4374c59a0..011249a27 100644 --- a/src/basic_memory/project_resolver.py +++ b/src/basic_memory/project_resolver.py @@ -14,7 +14,6 @@ class ResolutionMode(Enum): ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var EXPLICIT = auto() # Explicit project parameter DEFAULT = auto() # default_project from config - DISCOVERY = auto() # Discovery mode allowed (no project) NONE = auto() # No resolution possible @@ -30,10 +29,6 @@ class ResolvedProject: def is_resolved(self) -> bool: return self.project is not None - @property - def is_discovery_mode(self) -> bool: - return self.mode in {ResolutionMode.DISCOVERY, ResolutionMode.NONE} and self.project is None - @dataclass class ProjectResolver: @@ -53,11 +48,7 @@ def from_env( constrained_project=os.environ.get("BASIC_MEMORY_MCP_PROJECT"), ) - def resolve( - self, - project: Optional[str] = None, - allow_discovery: bool = False, - ) -> ResolvedProject: + def resolve(self, project: Optional[str] = None) -> ResolvedProject: """Resolve project using a unified linear priority chain.""" if self.constrained_project: logger.debug(f"Using constrained project from env: {self.constrained_project}") @@ -83,14 +74,6 @@ def resolve( reason=f"Default project: {self.default_project}", ) - if allow_discovery: - logger.debug("No project resolved, using discovery mode") - return ResolvedProject( - project=None, - mode=ResolutionMode.DISCOVERY, - reason="Discovery mode enabled", - ) - logger.debug("No project resolution possible") return ResolvedProject( project=None, @@ -104,7 +87,7 @@ def require_project( error_message: Optional[str] = None, ) -> ResolvedProject: """Resolve project, raising an error if not resolved.""" - result = self.resolve(project, allow_discovery=False) + result = self.resolve(project) if not result.is_resolved: msg = error_message or ( "No project specified. Either set 'default_project' in config, " diff --git a/src/basic_memory/schemas/memory.py b/src/basic_memory/schemas/memory.py index 9d500d20b..9febb0c0d 100644 --- a/src/basic_memory/schemas/memory.py +++ b/src/basic_memory/schemas/memory.py @@ -1,7 +1,7 @@ """Schemas for memory context.""" from datetime import datetime -from typing import List, Optional, Annotated, Sequence, Literal, Union, Dict +from typing import List, Optional, Annotated, Sequence, Literal, Union from annotated_types import MinLen, MaxLen from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter, field_serializer @@ -17,8 +17,8 @@ # dense graph its cost grows exponentially with depth. Five matches the prompt # API's existing cap and keeps the documented 1-3 range comfortably inside it. MAX_CONTEXT_DEPTH = 5 -# recent_activity has always accepted larger pages than build_context because its -# default request is entity-only summaries, not full context bodies. +# Recent activity accepts larger pages than graph context because its default +# request is note-only summaries, not full context bodies. MAX_RECENT_ACTIVITY_PAGE_SIZE = 100 @@ -266,52 +266,3 @@ class GraphContext(BaseModel): page: Optional[int] = None page_size: Optional[int] = None has_more: bool = False - - -class ActivityStats(BaseModel): - """Statistics about activity across all projects.""" - - total_projects: int - active_projects: int = Field(description="Projects with activity in timeframe") - most_active_project: Optional[str] = None - total_items: int = Field(description="Total items across all projects") - total_entities: int = 0 - total_relations: int = 0 - total_observations: int = 0 - - -class ProjectActivity(BaseModel): - """Activity summary for a single project.""" - - project_name: str - project_path: str - activity: GraphContext = Field(description="The actual activity data for this project") - item_count: int = Field(description="Total items in this project's activity") - last_activity: Optional[ - Annotated[datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})] - ] = Field(default=None, description="Most recent activity timestamp") - active_folders: List[str] = Field(default_factory=list, description="Most active folders") - - @field_serializer("last_activity") - def serialize_last_activity(self, dt: Optional[datetime]) -> Optional[str]: - return dt.isoformat() if dt else None # pragma: no cover - - -class ProjectActivitySummary(BaseModel): - """Summary of activity across all projects.""" - - projects: Dict[str, ProjectActivity] = Field( - description="Activity per project, keyed by project name" - ) - summary: ActivityStats - timeframe: str = Field(description="The timeframe used for the query") - generated_at: Annotated[ - datetime, Field(json_schema_extra={"type": "string", "format": "date-time"}) - ] - guidance: Optional[str] = Field( - default=None, description="Assistant guidance for project selection and session management" - ) - - @field_serializer("generated_at") - def serialize_generated_at(self, dt: datetime) -> str: - return dt.isoformat() # pragma: no cover diff --git a/tests/implementation_mcp.py b/tests/implementation_mcp.py index 0ad835cfa..a2d44824b 100644 --- a/tests/implementation_mcp.py +++ b/tests/implementation_mcp.py @@ -19,7 +19,6 @@ IMPLEMENTATION_NAMES = ( - "build_context", "cat", "edit_note", "fetch", @@ -29,7 +28,6 @@ "ls", "man", "read_note", - "recent_activity", "search", "search_notes", "tail", diff --git a/tests/mcp/test_compact_context_labels.py b/tests/mcp/test_compact_context_labels.py deleted file mode 100644 index 8be9d2ab7..000000000 --- a/tests/mcp/test_compact_context_labels.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Content-derived observation labels must not reintroduce omitted prose.""" - -from datetime import UTC, datetime - -from basic_memory.mcp.tools.build_context import _compact_context_labels, _format_context_markdown -from basic_memory.schemas.memory import ( - ContextResult, - GraphContext, - MemoryMetadata, - ObservationSummary, -) - - -def test_compact_observation_navigation_uses_owning_file_without_mutating_graph(): - observation = ObservationSummary( - observation_id=12, - entity_external_id="source-uuid", - title="fact: Private source prose", - file_path="notes/Source.md", - permalink="notes/source/observations/fact/private-source-prose", - category="fact", - content="Private source prose", - created_at=datetime.now(UTC), - ) - graph = GraphContext( - results=[ContextResult(primary_result=observation, related_results=[observation])], - metadata=MemoryMetadata(depth=1), - ) - compact = _compact_context_labels(graph) - for item in [compact.results[0].primary_result, *compact.results[0].related_results]: - assert isinstance(item, ObservationSummary) - assert item.title == "fact" - assert item.permalink == "notes/Source.md" - assert item.entity_external_id == "source-uuid" - assert item.observation_id == 12 - text = _format_context_markdown(compact, "project", compact=True) - assert "notes/Source.md" in text - assert "private" not in text.lower() - assert graph.results[0].primary_result.title == "fact: Private source prose" diff --git a/tests/mcp/test_index_readiness.py b/tests/mcp/test_index_readiness.py index 4875b71c1..d06d517c3 100644 --- a/tests/mcp/test_index_readiness.py +++ b/tests/mcp/test_index_readiness.py @@ -1,5 +1,6 @@ """Readiness must survive MCP serialization and account-wide search.""" +from datetime import datetime, timezone from unittest.mock import AsyncMock import pytest @@ -9,7 +10,7 @@ from basic_memory import db from basic_memory.mcp.clients.project import ProjectClient -from basic_memory.mcp.tools import bm_write, recent_activity, search_notes +from basic_memory.mcp.tools import bm_project, bm_search, bm_write from basic_memory.models import Project @@ -31,46 +32,50 @@ async def test_index_required_through_mcp(mcp, client, test_project, tool, outpu assert "For a cloud project:" in result.content[0].text -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_all_project_search_preserves_index_required(client, test_project, output_format): - result = await search_notes( - query="missing", search_type="text", search_all_projects=True, output_format=output_format - ) - assert isinstance(result, str) - assert result.startswith("# Project Index Required") - assert test_project.name in result +async def test_all_project_search_preserves_index_required(client, test_project): + text = await bm_search(query="missing", mode="text", all_projects=True) + payload = await bm_search(query="missing", mode="text", all_projects=True, output_format="json") + + assert isinstance(text, str) + assert text.startswith("# Project Index Required") + assert test_project.name in text + assert isinstance(payload, dict) + assert payload["results"] == [] + assert payload["guidance"].startswith("# Project Index Required") async def test_readiness_failure_is_not_an_empty_success(client, test_project, monkeypatch): + # Failure injection: no real path makes the status endpoint fail on demand. monkeypatch.setattr( ProjectClient, "get_status", AsyncMock(side_effect=ToolError("readiness unavailable")) ) - result = await search_notes(project=test_project.name, query="missing", output_format="json") - assert isinstance(result, str) - assert "Search Failed" in result - assert "readiness unavailable" in result + + with pytest.raises(ToolError, match="readiness unavailable"): + await bm_search(project=test_project.name, query="missing", output_format="json") with pytest.raises(ToolError, match="readiness unavailable"): - await recent_activity(project=test_project.name, output_format="json") + await bm_project(command="recent", project=test_project.name, output_format="json") async def test_nonempty_reads_skip_status(client, test_project, monkeypatch): await bm_write( project=test_project.name, title="Visible", content="Visible content", directory="notes" ) + # Failure injection: any status call fails the test. status = AsyncMock(side_effect=AssertionError("nonempty reads must not scan status")) monkeypatch.setattr(ProjectClient, "get_status", status) - result = await search_notes( - project=test_project.name, query="Visible", search_type="text", output_format="json" + + found = await bm_search( + project=test_project.name, query="Visible", mode="text", output_format="json" ) - assert isinstance(result, dict) and result["results"] - assert isinstance(await recent_activity(project=test_project.name, output_format="json"), list) + recent = await bm_project(command="recent", project=test_project.name, output_format="json") + + assert isinstance(found, dict) and found["results"] + assert isinstance(recent, dict) and recent["results"] status.assert_not_called() async def test_indexed_pending_misses_stay_empty(client, test_project, session_maker, config_home): """Pending new files do not revoke an already-indexed project's ordinary misses.""" - from datetime import datetime, timezone - (config_home / "unindexed.md").write_text("# A file awaiting the next pass\n") async with db.scoped_session(session_maker) as session: await session.execute( @@ -78,22 +83,12 @@ async def test_indexed_pending_misses_stay_empty(client, test_project, session_m .where(Project.id == test_project.id) .values(last_indexed_at=datetime.now(timezone.utc)) ) - result = await search_notes( - project=test_project.name, query="missing", search_type="text", output_format="json" - ) - assert isinstance(result, dict) and result["results"] == [] - assert await recent_activity(project=test_project.name, output_format="json") == [] + found = await bm_search( + project=test_project.name, query="missing", mode="text", output_format="json" + ) + recent = await bm_project(command="recent", project=test_project.name, output_format="json") -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_activity_discovery_preserves_index_required( - client, test_project, monkeypatch, output_format -): - import importlib - - activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - monkeypatch.setattr(activity_module, "resolve_project_parameter", AsyncMock(return_value=None)) - result = await recent_activity(output_format=output_format) - assert isinstance(result, str) - assert result.startswith("# Project Index Required") - assert test_project.name in result + assert isinstance(found, dict) and found["results"] == [] + assert "guidance" not in found + assert recent == {"results": [], "page": 1, "page_size": 10, "has_more": False} diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 1d5f75d30..e5736c237 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -94,26 +94,7 @@ async def _no_api_fallback(): "basic_memory.mcp.project_context._resolve_default_project_from_api", _no_api_fallback, ) - assert await resolve_project_parameter(project=None, allow_discovery=False) is None - - -@pytest.mark.asyncio -async def test_allows_discovery_when_enabled(config_manager, monkeypatch): - from basic_memory.mcp.project_context import resolve_project_parameter - - cfg = config_manager.load_config() - cfg.default_project = None - config_manager.save_config(cfg) - - # Prevent API fallback from returning a project via stale dependency overrides - async def _no_api_fallback(): - return None - - monkeypatch.setattr( - "basic_memory.mcp.project_context._resolve_default_project_from_api", - _no_api_fallback, - ) - assert await resolve_project_parameter(project=None, allow_discovery=True) is None + assert await resolve_project_parameter(project=None) is None @pytest.mark.asyncio @@ -2212,7 +2193,7 @@ async def test_get_project_client_prefers_project_id_over_project_name(monkeypat captured: dict[str, str | None] = {} sentinel = RuntimeError("stop after resolution") - async def fake_resolve(project=None, *, allow_discovery=True, context=None): + async def fake_resolve(project=None, *, context=None): captured["project"] = project raise sentinel diff --git a/tests/mcp/test_tool_bm_context.py b/tests/mcp/test_tool_bm_context.py index 6944b52fd..a2b99d405 100644 --- a/tests/mcp/test_tool_bm_context.py +++ b/tests/mcp/test_tool_bm_context.py @@ -3,6 +3,8 @@ These run the real ASGI stack through the shared `client` fixture. """ +import json +from datetime import UTC, datetime from typing import Any import pytest @@ -10,7 +12,21 @@ from fastmcp.exceptions import ToolError from basic_memory.mcp.server import mcp -from basic_memory.mcp.tools import bm_context, bm_write +from basic_memory.mcp.tools import bm_context, bm_read, bm_write +from basic_memory.mcp.tools.context import ( + _compact_context_labels, + _format_context_markdown, + _format_entity_block, +) +from basic_memory.schemas.memory import ( + MAX_CONTEXT_DEPTH, + ContextResult, + EntitySummary, + GraphContext, + MemoryMetadata, + ObservationSummary, + RelationSummary, +) async def _seed(project_name: str) -> None: @@ -155,3 +171,426 @@ async def test_malformed_locator_is_actionable_input_feedback(client, test_proje message = str(exc_info.value) assert "protocol scheme" in message assert "Internal server error" not in message + + +# --- Graph shape (ported from the pre-1.0 build_context tests) --- + + +@pytest.mark.asyncio +async def test_basic_context_fields(client, test_graph, test_project): + """JSON output carries the primary note, its relations, and the metadata.""" + result = await context_json(identifier="memory://test/root", project=test_project.name) + + assert len(result["results"]) == 1 + primary = result["results"][0]["primary_result"] + assert primary["permalink"] == f"{test_project.name}/test/root" + assert len(result["results"][0]["related_results"]) > 0 + + meta = result["metadata"] + assert meta["uri"] == f"{test_project.name}/test/root" + assert meta["depth"] == 1 + assert meta["timeframe"] is not None + assert meta["primary_count"] == 1 + # COMPAT(v0.18): generated_at and total_results restored for old clients + assert "generated_at" in meta + assert "total_results" in meta + + assert "entity_id" in primary + assert "created_at" in primary + + for obs in result["results"][0]["observations"]: + for field in ( + "observation_id", + "entity_id", + "file_path", + "created_at", + "permalink", + "category", + "content", + ): + assert field in obs + + for related in result["results"][0]["related_results"]: + if related["type"] == "entity": + fields = ("title", "file_path", "created_at", "entity_id") + else: + fields = ("relation_type", "title", "file_path", "created_at", "relation_id") + for field in fields: + assert field in related + + +@pytest.mark.asyncio +async def test_pattern_matches_many_notes(client, test_graph, test_project): + result = await context_json(identifier="memory://test/*", project=test_project.name) + + assert len(result["results"]) > 1 + assert all( + f"{test_project.name}/test/" in item["primary_result"]["permalink"] + for item in result["results"] + ) + assert result["metadata"]["depth"] == 1 + + +@pytest.mark.asyncio +async def test_external_id_keeps_workspace_canonical_path(app, test_project): + """An external-id route keeps the ContextVar workspace prefix in memory URL lookups.""" + from basic_memory.workspace_context import workspace_permalink_context + + with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): + await bm_write( + project=test_project.external_id, + title="Workspace Build Context Note", + directory="tests", + content="Build context should find this workspace note", + ) + + result = await context_json( + identifier="memory://tests/*", since="30d", project=test_project.external_id + ) + + assert len(result["results"]) == 1 + primary = result["results"][0]["primary_result"] + assert primary["permalink"] == ( + f"team-paul/{test_project.name}/tests/workspace-build-context-note" + ) + assert primary["content"] == "Build context should find this workspace note" + + +@pytest.mark.asyncio +async def test_wider_window_never_returns_fewer_related(client, test_graph, test_project): + recent = await context_json( + identifier="memory://test/root", since="1d", project=test_project.name + ) + older = await context_json( + identifier="memory://test/root", since="30d", project=test_project.name + ) + + def related_count(graph: dict[str, Any]) -> int: + return sum(len(item["related_results"]) for item in graph["results"]) + + assert related_count(older) >= related_count(recent) + + +@pytest.mark.asyncio +async def test_missing_note_is_an_empty_graph(client, test_project): + result = await context_json( + identifier="memory://test/does-not-exist", project=test_project.name + ) + + assert result["results"] == [] + assert result["metadata"]["primary_count"] == 0 + assert result["metadata"]["related_count"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("since", ["7d", "yesterday", "0d"]) +async def test_since_accepts_relative_and_natural_language(client, test_graph, test_project, since): + result = await context_json( + identifier="memory://specs/test", since=since, project=test_project.name + ) + + assert "results" in result + + +@pytest.mark.asyncio +async def test_since_rejects_nonsense_through_mcp(client, test_project): + async with Client(mcp) as mcp_client: + with pytest.raises(ToolError): + await mcp_client.call_tool( + "bm_context", + { + "identifier": "memory://specs/test", + "since": "invalid", + "project": test_project.name, + }, + ) + + +@pytest.mark.asyncio +async def test_string_depth_is_coerced_through_mcp(client, test_graph, test_project): + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool( + "bm_context", + {"identifier": "memory://test/root", "depth": "2", "project": test_project.name}, + ) + + graph = json.loads(result.content[0].text) + assert graph["metadata"]["depth"] == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("depth", [MAX_CONTEXT_DEPTH + 1, str(MAX_CONTEXT_DEPTH + 1), -1, "deep"]) +async def test_depth_outside_bounds_is_refused_through_mcp(client, test_project, depth): + """Deep traversals fail at the tool boundary with the bound the API enforces.""" + async with Client(mcp) as mcp_client: + with pytest.raises(ToolError): + await mcp_client.call_tool( + "bm_context", + {"identifier": "memory://test/root", "depth": depth, "project": test_project.name}, + ) + + +@pytest.mark.asyncio +async def test_max_depth_is_inclusive(client, test_graph, test_project): + """The maximum depth reaches the API without a 422.""" + result = await context_json( + identifier="memory://test/root", depth=MAX_CONTEXT_DEPTH, project=test_project.name + ) + + assert result["metadata"]["depth"] == MAX_CONTEXT_DEPTH + + +@pytest.mark.asyncio +async def test_text_output_for_one_note(client, test_graph, test_project): + text = await bm_context( + identifier="memory://test/root", output_format="text", project=test_project.name + ) + + assert isinstance(text, str) + assert "# Context: Root" in text + assert "1 primary" in text + assert f"project: {test_project.name}" in text + + +@pytest.mark.asyncio +async def test_compact_skips_observations(client, test_graph, test_project): + """Compact asks the server not to load observations and lists none (#1571).""" + full = await context_json(identifier="memory://test/root", project=test_project.name) + compact = await context_json( + identifier="memory://test/root", compact=True, project=test_project.name + ) + + assert [obs["content"] for obs in full["results"][0]["observations"]] == [ + "Root note 1", + "Root tech note", + ] + assert full["metadata"]["total_observations"] > 0 + + # The server counted no observations, so the tool sent include_observations=False, + # and compact JSON drops the list entirely. + assert compact["metadata"]["total_observations"] == 0 + assert "observations" not in compact["results"][0] + assert "content" not in compact["results"][0]["primary_result"] + assert ( + compact["results"][0]["primary_result"]["permalink"] + == full["results"][0]["primary_result"]["permalink"] + ) + assert len(compact["results"][0]["related_results"]) == len( + full["results"][0]["related_results"] + ) + + +@pytest.mark.asyncio +async def test_text_output_for_a_pattern(client, test_graph, test_project): + """Many results take the URI as the title and separate the note blocks.""" + text = await bm_context( + identifier="memory://test/*", output_format="text", project=test_project.name + ) + + assert isinstance(text, str) + assert "# Context: " in text + assert "# Context: Root\n" not in text + assert "\n\n---\n\n" in text + assert "primary" in text + + +@pytest.mark.asyncio +async def test_text_output_for_a_missing_note(client, test_project): + text = await bm_context( + identifier="memory://test/does-not-exist", output_format="text", project=test_project.name + ) + + assert isinstance(text, str) + assert "No results found" in text + assert test_project.name in text + + +def test_unresolved_relations_render_by_name(): + """Unresolved forward references render their target text, not [[None]] (#955).""" + now = datetime.now(UTC) + page = EntitySummary( + external_id="entity-1", + entity_id=1, + title="write-note(3)", + permalink="man3/write-note-3", + content="# write-note(3)", + file_path="man3/write-note-3.md", + created_at=now, + ) + unresolved = RelationSummary( + title="see_also: edit-note(3)", + file_path="man3/write-note-3.md", + permalink="man3/write-note-3/see-also/edit-note-3", + relation_type="see_also", + from_entity="write-note(3)", + from_entity_external_id="entity-1", + to_entity=None, + to_name="edit-note(3)", + created_at=now, + ) + resolved = RelationSummary( + title="see_also: bm-note(5)", + file_path="man3/write-note-3.md", + permalink="man3/write-note-3/see-also/bm-note-5", + relation_type="see_also", + from_entity="write-note(3)", + from_entity_external_id="entity-1", + to_entity="bm-note(5)", + to_name="bm-note(5)", + created_at=now, + ) + block = _format_entity_block( + ContextResult(primary_result=page, observations=[], related_results=[unresolved, resolved]) + ) + + assert "- see_also [[edit-note(3)]]" in block + assert "- see_also [[bm-note(5)]]" in block + assert "[[None]]" not in block + + +def test_compact_observation_labels_use_the_owning_file(): + """Content-derived observation labels must not reintroduce omitted prose.""" + observation = ObservationSummary( + observation_id=12, + entity_external_id="source-uuid", + title="fact: Private source prose", + file_path="notes/Source.md", + permalink="notes/source/observations/fact/private-source-prose", + category="fact", + content="Private source prose", + created_at=datetime.now(UTC), + ) + graph = GraphContext( + results=[ContextResult(primary_result=observation, related_results=[observation])], + metadata=MemoryMetadata(depth=1), + ) + + compact = _compact_context_labels(graph) + + for item in [compact.results[0].primary_result, *compact.results[0].related_results]: + assert isinstance(item, ObservationSummary) + assert item.title == "fact" + assert item.permalink == "notes/Source.md" + assert item.entity_external_id == "source-uuid" + assert item.observation_id == 12 + text = _format_context_markdown(compact, "project", compact=True) + assert "notes/Source.md" in text + assert "private" not in text.lower() + # The input graph is not mutated. + assert graph.results[0].primary_result.title == "fact: Private source prose" + + +@pytest.mark.asyncio +async def test_text_names_the_source_of_incoming_relations(client, test_project): + """An incoming relation names its source instead of reading as a self-link (#1717).""" + await bm_write( + project=test_project.name, + title="Bean Origins", + directory="coffee", + content="# Bean Origins\n\n- informs [[Brewing Notes]]\n", + ) + await bm_write( + project=test_project.name, + title="Brewing Notes", + directory="coffee", + content="# Brewing Notes\n\n- relates_to [[Bean Origins]]\n", + ) + + text = await bm_context( + identifier="memory://coffee/bean-origins", output_format="text", project=test_project.name + ) + + assert isinstance(text, str) + # Owned by the primary note: unchanged, bare form. + assert "- informs [[Brewing Notes]]" in text + # Incoming: the source is named, so it no longer reads as Bean Origins -> itself. + assert "- [[Brewing Notes]] relates_to [[Bean Origins]]" in text + assert "\n- relates_to [[Bean Origins]]" not in text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "identifier_template", + [ + "memory://s/2026/09/ses_AbCdEfGhIj", + "memory://{project}/s/2026/09/ses_AbCdEfGhIj", + "s/2026/09/ses_AbCdEfGhIj", + ], +) +async def test_finds_explicit_non_slug_permalink(client, test_project, identifier_template): + """A verbatim frontmatter permalink resolves through every spelling (#1549).""" + permalink = "s/2026/09/ses_AbCdEfGhIj" + await bm_write( + project=test_project.name, + title="index", + directory="s/2026/09/sessions", + content=f"---\npermalink: {permalink}\n---\n\n# T\n\nBody.\n", + ) + identifier = identifier_template.format(project=test_project.name) + + result = await context_json(identifier=identifier, project=test_project.name) + assert result["metadata"]["primary_count"] == 1 + assert result["results"][0]["primary_result"]["permalink"] == permalink + + note = await bm_read(identifier=identifier, project=test_project.name, output_format="json") + assert isinstance(note, dict) + assert note["permalink"] == permalink + + +@pytest.mark.asyncio +async def test_miss_returns_nothing(client, test_project): + """A memory:// URL is an address; a miss must not become a fuzzy guess (#1626).""" + for title in ("Search Spec", "Cache Layer Design"): + await bm_write( + project=test_project.name, + title=title, + directory="specs", + content=f"# {title}\n\nNotes about {title.lower()}.\n\n- relates_to [[Search Spec]]\n", + ) + + for identifier in ( + "memory://zzq-nonexistent", + f"memory://{test_project.name}/zzq-nonexistent", + ): + result = await context_json(identifier=identifier, project=test_project.name) + assert result["metadata"]["primary_count"] == 0, identifier + assert result["results"] == [] + + titled = await context_json(identifier="memory://Cache Layer Design", project=test_project.name) + assert [r["primary_result"]["title"] for r in titled["results"]] == ["Cache Layer Design"] + + +@pytest.mark.asyncio +async def test_refuses_ambiguous_title(client, test_project): + """A title naming two notes is refused, as bm_read refuses it, not guessed.""" + for directory in ("coffee", "archive"): + await bm_write( + project=test_project.name, + title="Bean Origins", + directory=directory, + content=f"# Bean Origins\n\nThe {directory} copy.\n", + ) + + with pytest.raises(ToolError, match="Ambiguous identifier 'Bean Origins'"): + await bm_context(identifier="memory://Bean Origins", project=test_project.name) + + exact = await context_json( + identifier="memory://archive/bean-origins", project=test_project.name + ) + assert [r["primary_result"]["file_path"] for r in exact["results"]] == [ + "archive/Bean Origins.md" + ] + + +@pytest.mark.asyncio +async def test_timestamps_all_carry_an_offset(client, test_graph, test_project): + """Primary and related results report created_at in one shape, offset included.""" + result = await context_json(identifier="memory://test/root", project=test_project.name) + + item = result["results"][0] + timestamps = [item["primary_result"]["created_at"]] + [ + related["created_at"] for related in item["related_results"] + ] + assert len(timestamps) > 1 + for timestamp in timestamps: + assert datetime.fromisoformat(timestamp).tzinfo is not None, timestamp diff --git a/tests/mcp/test_tool_bm_project_recent.py b/tests/mcp/test_tool_bm_project_recent.py new file mode 100644 index 000000000..6b35cffea --- /dev/null +++ b/tests/mcp/test_tool_bm_project_recent.py @@ -0,0 +1,307 @@ +"""bm_project(command="recent"): what changed lately in one project. + +Tool calls run the real ASGI stack through the shared `client` fixture; argument +validation runs through the MCP server, where the published schema applies. The text +formatter is also exercised directly with hand-built graphs for shapes a small test +project cannot produce cheaply (many rows, long observations, a next page). +""" + +import re +from datetime import datetime, timezone +from typing import Any + +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError +from sqlalchemy import text as sa_text + +from basic_memory import db +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools import bm_project +from basic_memory.mcp.tools.recent_activity import _format_project_output, _truncate_at_word +from basic_memory.schemas.memory import ( + ContextResult, + EntitySummary, + GraphContext, + MemoryMetadata, + ObservationSummary, + RelationSummary, +) + + +async def recent_text(project: str, **arguments: Any) -> str: + result = await bm_project(command="recent", project=project, **arguments) + assert isinstance(result, str) + return result + + +async def recent_rows(project: str, **arguments: Any) -> list[dict[str, Any]]: + result = await bm_project(command="recent", project=project, output_format="json", **arguments) + assert isinstance(result, dict) + return result["results"] + + +# --- Tool calls --- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("since", ["7d", "yesterday", "0d"]) +async def test_since_accepts_relative_and_natural_language(client, test_project, test_graph, since): + text = await recent_text(test_project.name, items=["note"], since=since) + + assert "Recent Activity:" in text + assert since in text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "arguments", + [ + {"since": "invalid"}, + {"items": ["bogus"]}, + {"page": 0}, + {"page_size": 0}, + {"page_size": 999}, + ], +) +async def test_invalid_arguments_are_refused_through_mcp(client, test_project, arguments): + async with Client(mcp) as mcp_client: + with pytest.raises(ToolError): + await mcp_client.call_tool( + "bm_project", {"command": "recent", "project": test_project.name, **arguments} + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "items", [["note"], ["note", "observation"], ["note", "observation", "relation"]] +) +async def test_item_filters_render_sections(client, test_project, test_graph, items): + text = await recent_text(test_project.name, items=items) + + assert "Recent Activity:" in text + assert "Recent Notes & Documents" in text + assert "Activity Summary:" in text + + +@pytest.mark.asyncio +async def test_notes_only_by_default(client, test_project, test_graph): + """One well-connected note must not fill the page with its observations and relations.""" + rows = await recent_rows(test_project.name) + + assert rows + assert {row["type"] for row in rows} == {"entity"} + + +@pytest.mark.asyncio +async def test_explicit_items_return_those_kinds(client, test_project, test_graph): + observations = await recent_rows(test_project.name, items=["observation"]) + relations = await recent_rows(test_project.name, items=["relation"]) + mixed = await recent_rows(test_project.name, items=["note", "observation", "relation"]) + + assert observations and {row["type"] for row in observations} == {"observation"} + assert relations and {row["type"] for row in relations} == {"relation"} + assert "entity" in {row["type"] for row in mixed} + for row in observations + relations + mixed: + assert set(row) == {"type", "title", "permalink", "file_path", "created_at"} + + +@pytest.mark.asyncio +async def test_text_page_names_the_next_page(client, test_project, test_graph): + text = await recent_text(test_project.name, items=["note"], page=1, page_size=2) + + assert "Showing 2 items (page 1). Use page=2 to see more." in text + + +@pytest.mark.asyncio +async def test_note_rows_carry_the_external_id(client, test_graph, test_project): + """The hosted MCP link template has agents substitute this id, so text shows it.""" + text = await recent_text(test_project.name, since="30d") + + uuid_pattern = r"\[id: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\]" + note_lines = [line for line in text.splitlines() if line.strip().startswith("•")] + assert note_lines, text + assert any(re.search(uuid_pattern, line) for line in note_lines), note_lines + + +@pytest.mark.asyncio +async def test_never_indexed_project_says_so(client, test_project, session_maker, config_home): + """A never-indexed project must not report an ordinary empty activity feed (#1534).""" + notes_dir = config_home / "notes" + notes_dir.mkdir(exist_ok=True) + (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNot yet indexed.\n") + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), + {"id": test_project.id}, + ) + + text = await recent_text(test_project.name) + payload = await bm_project(command="recent", project=test_project.name, output_format="json") + + assert "never been indexed" in text + assert "bm project index" in text + assert isinstance(payload, dict) + assert payload["results"] == [] + assert "never been indexed" in payload["guidance"] + + +@pytest.mark.asyncio +async def test_indexed_empty_project_offers_a_first_note( + client, test_project, session_maker, config_home +): + """An indexed-but-quiet project keeps the onboarding copy, routed by external id (#1534).""" + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), + {"now": datetime.now(), "id": test_project.id}, + ) + + text = await recent_text(test_project.name) + + assert f"No recent activity in '{test_project.name}' within 7d" in text + assert "never been indexed" not in text + assert "wait for them to agree" in text + # Names collide across cloud workspaces; external ids do not. + route = f'project="{test_project.external_id}"' + assert f"bm_write({route}" in text + assert f'bm_project(command="recent", {route}' in text + assert f"bm_search({route}" in text + + +# --- Text formatting --- + + +def _empty_graph() -> GraphContext: + return GraphContext( + results=[], metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)) + ) + + +def _entity_result(index: int, title: str) -> ContextResult: + return ContextResult( + primary_result=EntitySummary( + external_id=f"550e8400-e29b-41d4-a716-44665544{index:04d}", + entity_id=index, + permalink=f"notes/entity-{index}", + title=title, + content=None, + file_path=f"notes/entity-{index}.md", + created_at=datetime.now(timezone.utc), + ), + observations=[], + related_results=[], + ) + + +def _format(activity: GraphContext, *, page: int = 1, type_filter_applied: bool = False) -> str: + return _format_project_output( + "proj", + activity, + "7d", + page=page, + project_id="ext-123", + type_filter_applied=type_filter_applied, + ) + + +def test_filtered_empty_page_has_no_first_note_offer(): + out = _format(_empty_graph(), type_filter_applied=True) + + assert "No recent activity matched the requested type filter" in out + assert "omit `items`" in out + assert "bm_write" not in out + + +def test_later_empty_page_has_no_first_note_offer(): + out = _format(_empty_graph(), page=3) + + assert "No recent activity was found on page 3" in out + assert "Try page=2 or return to page=1" in out + assert "bm_write" not in out + + +def test_every_returned_row_is_rendered(): + """Regression for #784: the heading count and the rendered rows always agree.""" + now = datetime.now(timezone.utc) + entity_titles = [f"Entity {i}" for i in range(15)] + relations = [ + ContextResult( + primary_result=RelationSummary( + relation_id=100 + i, + entity_id=i, + title=f"Relation {i}", + file_path=f"notes/entity-{i}.md", + permalink=f"notes/entity-{i}", + relation_type="references", + from_entity=f"Entity {i}", + to_entity=f"Entity {i + 1}", + created_at=now, + ), + observations=[], + related_results=[], + ) + for i in range(12) + ] + activity = GraphContext( + results=[_entity_result(i, title) for i, title in enumerate(entity_titles)] + relations, + metadata=MemoryMetadata(depth=1, generated_at=now), + ) + + out = _format(activity) + + for title in entity_titles: + assert title in out + for i in range(12): + assert f"[[Entity {i}]] → references → [[Entity {i + 1}]]" in out + assert "Recent Notes & Documents (15)" in out + assert "Recent Connections (12)" in out + assert "27 items found." in out + assert "Use page=" not in out + + +def test_long_observations_are_truncated(): + activity = GraphContext( + results=[ + ContextResult( + primary_result=ObservationSummary( + observation_id=1, + entity_id=1, + title="Obs", + file_path="notes/obs.md", + permalink="notes/obs", + category="test", + content="This is a very long observation " * 10, + created_at=datetime.now(timezone.utc), + ), + observations=[], + related_results=[], + ) + ], + metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)), + ) + + out = _format(activity) + + assert "Recent Observations (1)" in out + assert "**test:** 1 items" in out + assert "..." in out + + +def test_truncate_at_word(): + assert _truncate_at_word("short", 80) == "short" + assert _truncate_at_word("word " * 40, 80).endswith("word...") + assert _truncate_at_word("x" * 200, 80) == "x" * 77 + "..." + + +def test_a_next_page_is_named(): + now = datetime.now(timezone.utc) + activity = GraphContext( + results=[_entity_result(1, "Test Note")], + metadata=MemoryMetadata(depth=1, generated_at=now), + has_more=True, + ) + + out = _format(activity) + + assert "Showing 1 items (page 1). Use page=2 to see more." in out diff --git a/tests/mcp/test_tool_build_context.py b/tests/mcp/test_tool_build_context.py deleted file mode 100644 index 9a9812ecc..000000000 --- a/tests/mcp/test_tool_build_context.py +++ /dev/null @@ -1,480 +0,0 @@ -"""Tests for discussion context MCP tool.""" - -from datetime import datetime - -import pytest - -from fastmcp.exceptions import ToolError - -from basic_memory.mcp.tools import bm_write, build_context -from basic_memory.schemas.memory import MAX_CONTEXT_DEPTH - - -@pytest.mark.asyncio -async def test_get_basic_discussion_context(client, test_graph, test_project): - """Test getting basic discussion context returns JSON dict with expected fields.""" - result = await build_context(project=test_project.name, url="memory://test/root") - - assert isinstance(result, dict) - assert len(result["results"]) == 1 - - primary = result["results"][0]["primary_result"] - assert primary["permalink"] == f"{test_project.name}/test/root" - assert len(result["results"][0]["related_results"]) > 0 - - # Verify metadata fields - meta = result["metadata"] - assert meta["uri"] == f"{test_project.name}/test/root" - assert meta["depth"] == 1 # default depth - assert meta["timeframe"] is not None - assert meta["primary_count"] == 1 - # COMPAT(v0.18): generated_at and total_results restored for old clients - assert "generated_at" in meta - assert "total_results" in meta - - # Entity fields present - assert "entity_id" in primary - assert "created_at" in primary - - # Verify observation-level fields - if result["results"][0]["observations"]: - obs = result["results"][0]["observations"][0] - assert "observation_id" in obs - assert "entity_id" in obs - assert "file_path" in obs - assert "created_at" in obs - assert "permalink" in obs - assert "category" in obs - assert "content" in obs - - # Verify related_results item structure — entities have identifying fields - for related in result["results"][0]["related_results"]: - item_type = related["type"] - if item_type == "entity": - assert "title" in related - assert "file_path" in related - assert "created_at" in related - assert "entity_id" in related - elif item_type == "relation": - assert "relation_type" in related - assert "title" in related - assert "file_path" in related - assert "created_at" in related - assert "relation_id" in related - assert "entity_id" in related - - -@pytest.mark.asyncio -async def test_get_discussion_context_pattern(client, test_graph, test_project): - """Test getting context with pattern matching.""" - result = await build_context(project=test_project.name, url="memory://test/*", depth=1) - - assert isinstance(result, dict) - assert len(result["results"]) > 1 # Should match multiple test/* paths - assert all( - f"{test_project.name}/test/" in item["primary_result"]["permalink"] - for item in result["results"] - ) - assert result["metadata"]["depth"] == 1 - - -@pytest.mark.asyncio -async def test_build_context_project_id_preserves_workspace_contextvar_canonical_path( - app, test_project -): - """project_id routing keeps ContextVar workspace prefixes in memory URL lookups.""" - from basic_memory.workspace_context import workspace_permalink_context - - with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): - await bm_write( - project=test_project.external_id, - title="Workspace Build Context Note", - directory="tests", - content="Build context should find this workspace note", - ) - - result = await build_context( - project_id=test_project.external_id, - url="memory://tests/*", - timeframe="30d", - ) - - assert isinstance(result, dict) - assert len(result["results"]) == 1 - primary = result["results"][0]["primary_result"] - assert primary["permalink"] == ( - f"team-paul/{test_project.name}/tests/workspace-build-context-note" - ) - assert primary["content"] == "Build context should find this workspace note" - - -@pytest.mark.asyncio -async def test_get_discussion_context_timeframe(client, test_graph, test_project): - """Test timeframe parameter filtering.""" - # Get recent context - recent = await build_context( - project=test_project.name, - url="memory://test/root", - timeframe="1d", - ) - - # Get older context - older = await build_context( - project=test_project.name, - url="memory://test/root", - timeframe="30d", - ) - - assert isinstance(recent, dict) - assert isinstance(older, dict) - - # Calculate total related items - total_recent_related = ( - sum(len(item["related_results"]) for item in recent["results"]) if recent["results"] else 0 - ) - total_older_related = ( - sum(len(item["related_results"]) for item in older["results"]) if older["results"] else 0 - ) - - assert total_older_related >= total_recent_related - - -@pytest.mark.asyncio -async def test_get_discussion_context_not_found(client, test_project): - """Test handling of non-existent URIs.""" - result = await build_context(project=test_project.name, url="memory://test/does-not-exist") - - assert isinstance(result, dict) - assert len(result["results"]) == 0 - assert result["metadata"]["primary_count"] == 0 - assert result["metadata"]["related_count"] == 0 - - -# Test data for different timeframe formats -valid_timeframes = [ - "7d", # Standard format - "yesterday", # Natural language - "0d", # Zero duration -] - -invalid_timeframes = [ - "invalid", # Nonsense string - # NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid -] - - -@pytest.mark.asyncio -async def test_build_context_timeframe_formats(client, test_graph, test_project): - """Test that build_context accepts various timeframe formats.""" - test_url = "memory://specs/test" - - # Test each valid timeframe - for timeframe in valid_timeframes: - try: - result = await build_context( - project=test_project.name, - url=test_url, - timeframe=timeframe, - page=1, - page_size=10, - max_related=10, - ) - assert result is not None - except Exception as e: - pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}") - - # Test invalid timeframes should raise ValidationError - for timeframe in invalid_timeframes: - with pytest.raises(ToolError): - await build_context(project=test_project.name, url=test_url, timeframe=timeframe) - - -@pytest.mark.asyncio -async def test_build_context_string_depth_parameter(client, test_graph, test_project): - """Test that build_context handles string depth parameter correctly.""" - test_url = "memory://test/root" - - # Test valid string depth parameter — should convert to int - try: - result = await build_context(url=test_url, depth="2", project=test_project.name) - assert isinstance(result, dict) - assert isinstance(result["metadata"]["depth"], int) - assert result["metadata"]["depth"] == 2 - except ToolError: - # This is also acceptable behavior - type validation should catch it - pass - - # Test invalid string depth parameter - should raise ToolError - with pytest.raises(ToolError): - await build_context(test_url, depth="invalid", project=test_project.name) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("depth", [MAX_CONTEXT_DEPTH + 1, str(MAX_CONTEXT_DEPTH + 1), -1]) -async def test_build_context_rejects_depth_outside_bounds(client, test_project, depth): - """Deep traversals fail in the tool with the same bound the API enforces.""" - with pytest.raises(ValueError, match=f"depth must be between 1 and {MAX_CONTEXT_DEPTH}"): - await build_context(url="memory://test/root", depth=depth, project=test_project.name) - - -@pytest.mark.asyncio -async def test_build_context_accepts_max_depth(client, test_graph, test_project): - """The maximum depth is inclusive and reaches the API without a 422.""" - result = await build_context( - url="memory://test/root", depth=MAX_CONTEXT_DEPTH, project=test_project.name - ) - - assert isinstance(result, dict) - assert result["metadata"]["depth"] == MAX_CONTEXT_DEPTH - - -@pytest.mark.asyncio -async def test_build_context_text_format(client, test_graph, test_project): - """Test that output_format='text' returns compact text.""" - result = await build_context( - project=test_project.name, - url="memory://test/root", - output_format="text", - ) - - assert isinstance(result, str) - # Should contain the context header - assert "# Context:" in result - # Should contain the entity title - assert "Root" in result - # Should contain the footer with counts - assert "primary" in result - assert "project:" in result - - -@pytest.mark.asyncio -async def test_build_context_compact_skips_observations(client, test_graph, test_project): - """Compact asks the server not to load observations and lists none (#1571).""" - full = await build_context(project=test_project.name, url="memory://test/root") - compact = await build_context(project=test_project.name, url="memory://test/root", compact=True) - - assert isinstance(full, dict) - assert isinstance(compact, dict) - - # Non-compact output is unchanged: observations with their content. - assert [obs["content"] for obs in full["results"][0]["observations"]] == [ - "Root note 1", - "Root tech note", - ] - assert full["metadata"]["total_observations"] > 0 - - # The server counted no observations, so the tool sent include_observations=False, - # and compact JSON drops the list entirely. - assert compact["metadata"]["total_observations"] == 0 - assert "observations" not in compact["results"][0] - assert "content" not in compact["results"][0]["primary_result"] - assert ( - compact["results"][0]["primary_result"]["permalink"] - == full["results"][0]["primary_result"]["permalink"] - ) - assert len(compact["results"][0]["related_results"]) == len( - full["results"][0]["related_results"] - ) - - -@pytest.mark.asyncio -async def test_build_context_markdown_pattern(client, test_graph, test_project): - """Test markdown format with pattern matching (multiple results).""" - result = await build_context( - project=test_project.name, - url="memory://test/*", - output_format="text", - ) - - assert isinstance(result, str) - # Multiple results should use URI as title, not single entity title - assert "# Context:" in result - # Should contain separator between entity blocks - assert "---" in result - assert "primary" in result - - -@pytest.mark.asyncio -async def test_build_context_markdown_not_found(client, test_project): - """Test markdown format for non-existent URIs.""" - result = await build_context( - project=test_project.name, - url="memory://test/does-not-exist", - output_format="text", - ) - - assert isinstance(result, str) - assert "No results found" in result - assert test_project.name in result - - -def test_format_entity_block_renders_unresolved_relations_by_name(): - """Unresolved forward references render their target text, not [[None]] (#955).""" - from datetime import UTC, datetime - - from basic_memory.mcp.tools.build_context import _format_entity_block - from basic_memory.schemas.memory import ( - ContextResult, - EntitySummary, - RelationSummary, - ) - - now = datetime.now(UTC) - page = EntitySummary( - external_id="entity-1", - entity_id=1, - title="write-note(3)", - permalink="man3/write-note-3", - content="# write-note(3)", - file_path="man3/write-note-3.md", - created_at=now, - ) - unresolved = RelationSummary( - title="see_also: edit-note(3)", - file_path="man3/write-note-3.md", - permalink="man3/write-note-3/see-also/edit-note-3", - relation_type="see_also", - from_entity="write-note(3)", - from_entity_external_id="entity-1", - to_entity=None, - to_name="edit-note(3)", - created_at=now, - ) - resolved = RelationSummary( - title="see_also: bm-note(5)", - file_path="man3/write-note-3.md", - permalink="man3/write-note-3/see-also/bm-note-5", - relation_type="see_also", - from_entity="write-note(3)", - from_entity_external_id="entity-1", - to_entity="bm-note(5)", - to_name="bm-note(5)", - created_at=now, - ) - block = _format_entity_block( - ContextResult(primary_result=page, observations=[], related_results=[unresolved, resolved]) - ) - - assert "- see_also [[edit-note(3)]]" in block - assert "- see_also [[bm-note(5)]]" in block - assert "[[None]]" not in block - - -@pytest.mark.asyncio -async def test_build_context_text_names_the_source_of_incoming_relations(client, test_project): - """An incoming relation names its source instead of reading as a self-link (#1717).""" - await bm_write( - project=test_project.name, - title="Bean Origins", - directory="coffee", - content="# Bean Origins\n\n- informs [[Brewing Notes]]\n", - ) - await bm_write( - project=test_project.name, - title="Brewing Notes", - directory="coffee", - content="# Brewing Notes\n\n- relates_to [[Bean Origins]]\n", - ) - - result = await build_context( - project=test_project.name, - url="memory://coffee/bean-origins", - output_format="text", - ) - - assert isinstance(result, str) - # Owned by the primary note: unchanged, bare form. - assert "- informs [[Brewing Notes]]" in result - # Incoming: the source is named, so it no longer reads as Bean Origins -> itself. - assert "- [[Brewing Notes]] relates_to [[Bean Origins]]" in result - assert "\n- relates_to [[Bean Origins]]" not in result - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "url_template", - [ - "memory://s/2026/09/ses_AbCdEfGhIj", - "memory://{project}/s/2026/09/ses_AbCdEfGhIj", - "s/2026/09/ses_AbCdEfGhIj", - ], -) -async def test_build_context_finds_explicit_non_slug_permalink(client, test_project, url_template): - """A verbatim frontmatter permalink resolves through every URL spelling (#1549).""" - from basic_memory.mcp.tools import read_note - - permalink = "s/2026/09/ses_AbCdEfGhIj" - await bm_write( - project=test_project.name, - title="index", - directory="s/2026/09/sessions", - content=f"---\npermalink: {permalink}\n---\n\n# T\n\nBody.\n", - ) - url = url_template.format(project=test_project.name) - - result = await build_context(project=test_project.name, url=url) - assert isinstance(result, dict) - assert result["metadata"]["primary_count"] == 1 - assert result["results"][0]["primary_result"]["permalink"] == permalink - - note = await read_note(url, project=test_project.name, output_format="json") - assert isinstance(note, dict) - assert note["permalink"] == permalink - - -@pytest.mark.asyncio -async def test_build_context_miss_returns_nothing(client, test_project): - """A memory:// URL is an address; a miss must not become a fuzzy guess (#1626).""" - for title in ("Search Spec", "Cache Layer Design"): - await bm_write( - project=test_project.name, - title=title, - directory="specs", - content=f"# {title}\n\nNotes about {title.lower()}.\n\n- relates_to [[Search Spec]]\n", - ) - - for url in ("memory://zzq-nonexistent", f"memory://{test_project.name}/zzq-nonexistent"): - result = await build_context(project=test_project.name, url=url) - assert isinstance(result, dict) - assert result["metadata"]["primary_count"] == 0, url - assert result["results"] == [] - - titled = await build_context(project=test_project.name, url="memory://Cache Layer Design") - assert isinstance(titled, dict) - assert [r["primary_result"]["title"] for r in titled["results"]] == ["Cache Layer Design"] - - -@pytest.mark.asyncio -async def test_build_context_refuses_ambiguous_title(client, test_project): - """A title URL naming two notes is refused, as read_note refuses it, not guessed.""" - for directory in ("coffee", "archive"): - await bm_write( - project=test_project.name, - title="Bean Origins", - directory=directory, - content=f"# Bean Origins\n\nThe {directory} copy.\n", - ) - - with pytest.raises(ToolError, match="Ambiguous identifier 'Bean Origins'"): - await build_context(project=test_project.name, url="memory://Bean Origins") - - exact = await build_context(project=test_project.name, url="memory://archive/bean-origins") - assert isinstance(exact, dict) - assert [r["primary_result"]["file_path"] for r in exact["results"]] == [ - "archive/Bean Origins.md" - ] - - -@pytest.mark.asyncio -async def test_build_context_timestamps_all_carry_an_offset(client, test_graph, test_project): - """Primary and related results report created_at in one shape, offset included.""" - result = await build_context(project=test_project.name, url="memory://test/root") - assert isinstance(result, dict) - - item = result["results"][0] - timestamps = [item["primary_result"]["created_at"]] + [ - related["created_at"] for related in item["related_results"] - ] - assert len(timestamps) > 1 - for timestamp in timestamps: - assert datetime.fromisoformat(timestamp).tzinfo is not None, timestamp diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 4546bee1a..994f2d7fa 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -136,18 +136,6 @@ "output_format", "project", ], - "build_context": [ - "url", - "project", - "project_id", - "depth", - "timeframe", - "page", - "page_size", - "max_related", - "output_format", - "compact", - ], "cat": [ "identifier", "start_line", @@ -210,16 +198,6 @@ "end_line", "include_content", ], - "recent_activity": [ - "type", - "depth", - "timeframe", - "page", - "page_size", - "project", - "project_id", - "output_format", - ], "search": ["query"], "search_notes": [ "query", @@ -264,11 +242,9 @@ "bm_project": {"readOnlyHint": False, "destructiveHint": False}, "bm_schema": {"readOnlyHint": True, "destructiveHint": False}, "bm_man": {"readOnlyHint": True, "destructiveHint": False}, - "build_context": {"readOnlyHint": True, "destructiveHint": False}, "fetch": {"readOnlyHint": True, "destructiveHint": False}, "list_workspaces": {"readOnlyHint": True, "destructiveHint": False}, "read_note": {"readOnlyHint": True, "destructiveHint": False}, - "recent_activity": {"readOnlyHint": True, "destructiveHint": False}, "search": {"readOnlyHint": True, "destructiveHint": False}, "search_notes": {"readOnlyHint": True, "destructiveHint": False}, # edit_note's find_replace/replace_section overwrite existing content, so it is @@ -320,7 +296,6 @@ "bm_project_delete": tools.bm_project_delete, "bm_schema": tools.bm_schema, "bm_man": tools.bm_man, - "build_context": tools.build_context, "cat": tools.cat, "edit_note": tools.edit_note, "fetch": tools.fetch, @@ -330,7 +305,6 @@ "ls": tools.ls, "man": tools.man, "read_note": tools.read_note, - "recent_activity": tools.recent_activity, "search": tools.search, "search_notes": tools.search_notes, "tail": tools.tail, diff --git a/tests/mcp/test_tool_json_output_modes.py b/tests/mcp/test_tool_json_output_modes.py index bdb72375a..7ee408166 100644 --- a/tests/mcp/test_tool_json_output_modes.py +++ b/tests/mcp/test_tool_json_output_modes.py @@ -9,10 +9,8 @@ from basic_memory.mcp.tools import ( bm_project, bm_write, - build_context, edit_note, read_note, - recent_activity, ) @@ -118,78 +116,6 @@ async def test_edit_note_text_and_json_modes(app, test_project): assert "checksum" in json_result -@pytest.mark.asyncio -async def test_recent_activity_text_and_json_modes(app, test_project): - await bm_write( - project=test_project.name, - title="Mode Activity Note", - directory="mode-tests", - content="# Mode Activity Note\n\nactivity", - ) - - text_result = await recent_activity( - project=test_project.name, - timeframe="7d", - output_format="text", - ) - assert isinstance(text_result, str) - assert "Recent Activity" in text_result - - json_result = await recent_activity( - project=test_project.name, - timeframe="7d", - output_format="json", - ) - assert isinstance(json_result, list) - assert any(item.get("title") == "Mode Activity Note" for item in json_result) - for item in json_result: - assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys()) - - -@pytest.mark.asyncio -async def test_recent_activity_json_preserves_relation_and_observation_types(app, test_project): - await bm_write( - project=test_project.name, - title="Activity Type Source", - directory="mode-tests", - content=( - "# Activity Type Source\n\n" - "- [note] observation from source\n" - "- links_to [[Activity Type Target]]" - ), - ) - await bm_write( - project=test_project.name, - title="Activity Type Target", - directory="mode-tests", - content="# Activity Type Target", - ) - - relation_json = await recent_activity( - project=test_project.name, - type="relation", - timeframe="7d", - output_format="json", - ) - assert isinstance(relation_json, list) - assert relation_json - assert all(item.get("type") == "relation" for item in relation_json) - for item in relation_json: - assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys()) - - observation_json = await recent_activity( - project=test_project.name, - type="observation", - timeframe="7d", - output_format="json", - ) - assert isinstance(observation_json, list) - assert observation_json - assert all(item.get("type") == "observation" for item in observation_json) - for item in observation_json: - assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys()) - - @pytest.mark.asyncio async def test_list_and_create_project_text_and_json_modes(app, test_project, tmp_path): list_text = await bm_project(command="list", output_format="text") @@ -268,21 +194,3 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm ) assert "Set as default project\\n" not in default_text_again assert "Set as default project\n" in default_text_again - - -@pytest.mark.asyncio -async def test_build_context_json_default_and_text_mode(client, test_graph, test_project): - json_result = await build_context( - project=test_project.name, - url="memory://test/root", - ) - assert isinstance(json_result, dict) - assert "results" in json_result - - text_result = await build_context( - project=test_project.name, - url="memory://test/root", - output_format="text", - ) - assert isinstance(text_result, str) - assert "# Context:" in text_result diff --git a/tests/mcp/test_tool_recent_activity.py b/tests/mcp/test_tool_recent_activity.py deleted file mode 100644 index b6461ce72..000000000 --- a/tests/mcp/test_tool_recent_activity.py +++ /dev/null @@ -1,758 +0,0 @@ -"""Tests for discussion context MCP tool.""" - -from datetime import datetime, timedelta, timezone -from typing import Any, cast - -import pytest - -from fastmcp.exceptions import ToolError - -from basic_memory.mcp.tools import recent_activity -from basic_memory.schemas.search import SearchItemType -from basic_memory.schemas.memory import ( - ActivityStats, - ProjectActivity, - GraphContext, - MemoryMetadata, - ContextResult, - EntitySummary, - ObservationSummary, -) - -# Test data for different timeframe formats -valid_timeframes = [ - "7d", # Standard format - "yesterday", # Natural language - "0d", # Zero duration -] - -invalid_timeframes = [ - "invalid", # Nonsense string - # NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid -] - - -@pytest.mark.asyncio -async def test_recent_activity_timeframe_formats(client, test_project, test_graph): - """Test that recent_activity accepts various timeframe formats.""" - # Test each valid timeframe with project-specific mode - for timeframe in valid_timeframes: - try: - result = await recent_activity( - project=test_project.name, - type=["entity"], - timeframe=timeframe, - ) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - assert timeframe in result - except Exception as e: - pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}") - - # Test invalid timeframes should raise ValidationError - for timeframe in invalid_timeframes: - with pytest.raises(ToolError): - await recent_activity(project=test_project.name, timeframe=timeframe) - - -@pytest.mark.asyncio -async def test_recent_activity_type_filters(client, test_project, test_graph): - """Test that recent_activity correctly filters by types.""" - - # Test single string type - result = await recent_activity(project=test_project.name, type=SearchItemType.ENTITY) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - assert "Recent Notes & Documents" in result - - # Test single string type - result = await recent_activity(project=test_project.name, type="entity") - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - assert "Recent Notes & Documents" in result - - # Test single type - result = await recent_activity(project=test_project.name, type=["entity"]) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - assert "Recent Notes & Documents" in result - - # Test multiple types - result = await recent_activity(project=test_project.name, type=["entity", "observation"]) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - # Should contain sections for both types - assert "Recent Notes & Documents" in result or "Recent Observations" in result - - # Test multiple types - result = await recent_activity( - project=test_project.name, type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION] - ) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - # Should contain sections for both types - assert "Recent Notes & Documents" in result or "Recent Observations" in result - - # Test all types - result = await recent_activity( - project=test_project.name, type=["entity", "observation", "relation"] - ) - assert result is not None - assert isinstance(result, str) - assert "Recent Activity:" in result - assert "Activity Summary:" in result - - -@pytest.mark.asyncio -async def test_recent_activity_type_invalid(client, test_project, test_graph): - """Test that recent_activity correctly filters by types.""" - - # Test single invalid string type - with pytest.raises(ValueError) as e: - await recent_activity(project=test_project.name, type="note") - assert ( - str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']" - ) - - # Test invalid string array type - with pytest.raises(ValueError) as e: - await recent_activity(project=test_project.name, type=["note"]) - assert ( - str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']" - ) - - -@pytest.mark.asyncio -async def test_recent_activity_uses_default_project(client, test_project, test_graph): - """When no project parameter is given, recent_activity uses the default project.""" - # Call without explicit project — should resolve to the default - result = await recent_activity() - assert result is not None - assert isinstance(result, str) - - # Should return project-specific output for the default project - assert "Recent Activity:" in result - assert "Activity Summary:" in result - - -def test_recent_activity_format_relative_time_and_truncate_helpers(): - """Unit-test helper formatting to keep MCP output stable.""" - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - # _format_relative_time: naive datetime should be treated as UTC. - naive_dt = datetime.now() - timedelta(days=1) - assert recent_activity_module._format_relative_time(naive_dt) in {"yesterday", "recently"} - - # ISO string parsing path - iso_dt = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() - assert "hour" in recent_activity_module._format_relative_time(iso_dt) - - now = datetime.now(timezone.utc) - assert "year" in recent_activity_module._format_relative_time(now - timedelta(days=800)) - assert "month" in recent_activity_module._format_relative_time(now - timedelta(days=40)) - assert "week" in recent_activity_module._format_relative_time(now - timedelta(days=14)) - assert "days ago" in recent_activity_module._format_relative_time(now - timedelta(days=3)) - assert "minute" in recent_activity_module._format_relative_time(now - timedelta(minutes=5)) - assert recent_activity_module._format_relative_time(now) in {"just now", "recently"} - - # Exception fallback - assert recent_activity_module._format_relative_time(object()) == "recently" - - # _truncate_at_word: both branches - assert recent_activity_module._truncate_at_word("short", 80) == "short" - assert recent_activity_module._truncate_at_word("word " * 40, 80).endswith("...") - assert recent_activity_module._truncate_at_word("x" * 200, 80).endswith("...") - - -@pytest.mark.asyncio -async def test_recent_activity_get_project_activity_timezone_normalization(monkeypatch): - """_get_project_activity should handle naive datetimes and extract active folders.""" - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - class FakeResponse: - def __init__(self, payload): - self._payload = payload - - def json(self): - return self._payload - - async def fake_call_get(client, url, params=None): - assert "/memory/recent" in str(url) - t1 = datetime.now() - timedelta(minutes=2) - t2 = datetime.now() - timedelta(minutes=1) - return FakeResponse( - { - "results": [ - { - "primary_result": { - "type": "entity", - "external_id": "550e8400-e29b-41d4-a716-446655440001", - "entity_id": 1, - "permalink": "notes/x", - "title": "X", - "content": None, - "file_path": "folder/x.md", - # Naive datetime (no timezone) on purpose. - "created_at": t1.isoformat(), - }, - "observations": [], - "related_results": [], - }, - { - "primary_result": { - "type": "entity", - "external_id": "550e8400-e29b-41d4-a716-446655440002", - "entity_id": 2, - "permalink": "notes/y", - "title": "Y", - "content": None, - "file_path": "folder/y.md", - "created_at": t2.isoformat(), - }, - "observations": [], - "related_results": [], - }, - ], - "metadata": {"depth": 1, "generated_at": datetime.now(timezone.utc).isoformat()}, - } - ) - - monkeypatch.setattr(recent_activity_module, "call_get", fake_call_get) - - class P: - id = 1 - external_id = "test-external-id" - name = "p" - path = "/tmp/p" - - proj_activity = await recent_activity_module._get_project_activity( - client=None, project_info=cast(Any, P()), params={}, depth=1 - ) - assert proj_activity.item_count == 2 - assert "folder" in proj_activity.active_folders - assert proj_activity.last_activity is not None - - -def test_recent_activity_format_project_output_no_results(): - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - empty = GraphContext( - results=[], - metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)), - ) - - out = recent_activity_module._format_project_output( - project_name="proj", activity_data=empty, timeframe="7d", page=1 - ) - assert "No recent activity in 'proj' within 7d" in out - # Empty orientation call carries the offer-not-act first-note guidance. - assert "bm_write" in out - assert "wait for them to agree" in out - # Without a route-safe id, examples fall back to the display name. - assert 'bm_write(project="proj"' in out - - # With an external id, examples route by id so they cannot hit a same-named project in - # another cloud workspace. - out_routed = recent_activity_module._format_project_output( - project_name="proj", - activity_data=empty, - timeframe="7d", - page=1, - project_id="ext-123", - ) - assert 'bm_write(project="ext-123"' in out_routed - assert 'bm_project(command="recent", project="ext-123"' in out_routed - assert 'bm_search(project="ext-123"' in out_routed - assert 'project="proj"' not in out_routed - - -def test_recent_activity_format_project_output_filtered_no_results(): - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - empty = GraphContext( - results=[], - metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)), - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=empty, - timeframe="7d", - page=1, - type_filter_applied=True, - ) - - assert "No recent activity matched the requested type filter" in out - assert "omit `items`" in out - assert "bm_write" not in out - assert "wait for them to agree" not in out - - -def test_recent_activity_format_project_output_later_page_no_results(): - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - empty = GraphContext( - results=[], - metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)), - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=empty, - timeframe="7d", - page=3, - ) - - assert "No recent activity was found on page 3" in out - assert "Try page=2 or return to page=1" in out - assert "bm_write" not in out - assert "wait for them to agree" not in out - - -def test_recent_activity_format_project_output_renders_all_entities_and_relations(): - """Regression for #784: the formatter must render every row the API returned. - Previously the body was hardcoded to `[:5]` while the heading reported the - true total — a result set of N>5 entities would show 5 rows under a heading - that claimed N, with no signal the body was truncated. `page_size` is now - the only knob; heading count and body row count must always agree. - """ - import importlib - - from basic_memory.schemas.memory import RelationSummary - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - now = datetime.now(timezone.utc) - - # Counts chosen to comfortably exceed the old hardcoded `[:5]` slice and any - # plausible reintroduced default cap. - entity_titles = [f"Entity {i}" for i in range(15)] - relation_titles = [f"Relation {i}" for i in range(12)] - - results = [ - ContextResult( - primary_result=EntitySummary( - external_id=f"550e8400-e29b-41d4-a716-44665544{i:04d}", - entity_id=i, - permalink=f"notes/entity-{i}", - title=title, - content=None, - file_path=f"notes/entity-{i}.md", - created_at=now, - ), - observations=[], - related_results=[], - ) - for i, title in enumerate(entity_titles) - ] + [ - ContextResult( - primary_result=RelationSummary( - relation_id=100 + i, - entity_id=i, - title=title, - file_path=f"notes/entity-{i}.md", - permalink=f"notes/entity-{i}", - relation_type="references", - from_entity=f"Entity {i}", - to_entity=f"Entity {i + 1}", - created_at=now, - ), - observations=[], - related_results=[], - ) - for i, title in enumerate(relation_titles) - ] - - activity = GraphContext( - results=results, - metadata=MemoryMetadata(depth=1, generated_at=now), - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=activity, - timeframe="7d", - page=1, - ) - - for title in entity_titles: - assert title in out, f"Entity {title!r} missing from formatter output" - for i in range(len(relation_titles)): - assert f"[[Entity {i}]] → references → [[Entity {i + 1}]]" in out, ( - f"Relation {i} missing from formatter output" - ) - # Heading total matches the body — no silent truncation. - assert f"Recent Notes & Documents ({len(entity_titles)})" in out - assert f"Recent Connections ({len(relation_titles)})" in out - - -def test_recent_activity_format_project_output_includes_observation_truncation(): - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - long_content = "This is a very long observation " * 10 - - activity = GraphContext( - results=[ - ContextResult( - primary_result=ObservationSummary( - observation_id=1, - entity_id=1, - title="Obs", - file_path="notes/obs.md", - permalink="notes/obs", - category="test", - content=long_content, - created_at=datetime.now(timezone.utc), - ), - observations=[], - related_results=[], - ) - ], - metadata=MemoryMetadata(depth=1, generated_at=datetime.now(timezone.utc)), - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=activity, - timeframe="7d", - page=1, - ) - assert "Recent Observations" in out - assert "..." in out # truncated - - -def test_recent_activity_format_discovery_output_includes_other_active_projects_and_key_developments(): - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - now = datetime.now(timezone.utc) - activity_one = GraphContext( - results=[ - ContextResult( - primary_result=EntitySummary( - external_id="550e8400-e29b-41d4-a716-446655440001", - entity_id=1, - permalink="docs/complete-feature", - title="Complete Feature Spec", - content=None, - file_path="docs/complete-feature.md", - created_at=now, - ), - observations=[], - related_results=[], - ) - ], - metadata=MemoryMetadata(depth=1, generated_at=now), - ) - activity_two = GraphContext( - results=[ - ContextResult( - primary_result=EntitySummary( - external_id="550e8400-e29b-41d4-a716-446655440002", - entity_id=2, - permalink="docs/other", - title="Other Note", - content=None, - file_path="docs/other.md", - created_at=now - timedelta(hours=1), - ), - observations=[], - related_results=[], - ) - ], - metadata=MemoryMetadata(depth=1, generated_at=now), - ) - - projects_activity = { - "A": ProjectActivity( - project_name="A", - project_path="/a", - activity=activity_one, - item_count=2, - last_activity=now, - active_folders=["docs"], - ), - "B": ProjectActivity( - project_name="B", - project_path="/b", - activity=activity_two, - item_count=1, - last_activity=now - timedelta(hours=1), - active_folders=[], - ), - } - summary = ActivityStats( - total_projects=2, - active_projects=2, - most_active_project="A", - total_items=3, - total_entities=3, - total_relations=0, - total_observations=0, - ) - - out = recent_activity_module._format_discovery_output( - projects_activity=projects_activity, - summary=summary, - timeframe="7d", - guidance="Suggested project: 'A' (most active with 2 items)", - ) - assert "Most Active Project:" in out - assert "Other Active Projects:" in out - assert "Key Developments:" in out - - -@pytest.mark.asyncio -async def test_recent_activity_entity_only_default(client, test_project, test_graph): - """When no type is specified, recent_activity should default to entity-only. - - test_graph creates entities with observations and relations, so if all types - were returned we'd see observation/relation rows in the JSON output. - """ - json_result = await recent_activity(project=test_project.name, output_format="json") - assert isinstance(json_result, list) - assert len(json_result) > 0 - # Every item should be an entity — no observations or relations - for item in json_result: - assert item["type"] == "entity", f"Expected entity-only default, got type={item['type']}" - - -@pytest.mark.asyncio -async def test_recent_activity_explicit_types_returns_requested_types( - client, test_project, test_graph -): - """Explicitly requesting observation/relation types should return those types.""" - # Request observations only - obs_result = await recent_activity( - project=test_project.name, type=["observation"], output_format="json" - ) - assert isinstance(obs_result, list) - for item in obs_result: - assert item["type"] == "observation", f"Expected observation type, got type={item['type']}" - - # Request relations only - rel_result = await recent_activity( - project=test_project.name, type=["relation"], output_format="json" - ) - assert isinstance(rel_result, list) - for item in rel_result: - assert item["type"] == "relation", f"Expected relation type, got type={item['type']}" - - # Request all types explicitly - all_result = await recent_activity( - project=test_project.name, - type=["entity", "observation", "relation"], - output_format="json", - ) - assert isinstance(all_result, list) - types_found = {item["type"] for item in all_result} - # test_graph creates entities with observations and relations, - # so we expect at least entity and one other type - assert "entity" in types_found - - -@pytest.mark.asyncio -async def test_recent_activity_pagination_params(client, test_project, test_graph): - """Test that page and page_size params are forwarded correctly.""" - result = await recent_activity( - project=test_project.name, - type=["entity"], - page=1, - page_size=2, - ) - assert isinstance(result, str) - assert "Recent Activity:" in result - - -@pytest.mark.asyncio -async def test_recent_activity_pagination_validation(): - """Invalid page/page_size values should raise clear ValueError messages.""" - with pytest.raises(ValueError, match="page must be >= 1, got 0"): - await recent_activity(page=0) - - with pytest.raises(ValueError, match="page must be >= 1, got -1"): - await recent_activity(page=-1) - - with pytest.raises(ValueError, match="page_size must be >= 1, got 0"): - await recent_activity(page_size=0) - - with pytest.raises(ValueError, match="page_size must be >= 1, got -5"): - await recent_activity(page_size=-5) - - with pytest.raises(ValueError, match="page_size must be <= 100, got 999"): - await recent_activity(page_size=999) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("depth", [6, -1]) -async def test_recent_activity_depth_validation(depth): - """Deep traversals fail in the tool with the same bound the API enforces.""" - with pytest.raises(ValueError, match=f"depth must be between 1 and 5, got {depth}"): - await recent_activity(depth=depth) - - -def test_format_project_output_has_more_pagination_guidance(): - """When has_more is True, activity summary should show pagination guidance.""" - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - now = datetime.now(timezone.utc) - activity = GraphContext( - results=[ - ContextResult( - primary_result=EntitySummary( - external_id="550e8400-e29b-41d4-a716-446655440001", - entity_id=1, - permalink="notes/test", - title="Test Note", - content=None, - file_path="notes/test.md", - created_at=now, - ), - observations=[], - related_results=[], - ) - ], - metadata=MemoryMetadata(depth=1, generated_at=now), - has_more=True, - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=activity, - timeframe="7d", - page=1, - ) - assert "Use page=2 to see more" in out - assert "Showing 1 items (page 1)" in out - - -def test_format_project_output_no_more_pages(): - """When has_more is False, activity summary should not show pagination guidance.""" - import importlib - - recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") - - now = datetime.now(timezone.utc) - activity = GraphContext( - results=[ - ContextResult( - primary_result=EntitySummary( - external_id="550e8400-e29b-41d4-a716-446655440001", - entity_id=1, - permalink="notes/test", - title="Test Note", - content=None, - file_path="notes/test.md", - created_at=now, - ), - observations=[], - related_results=[], - ) - ], - metadata=MemoryMetadata(depth=1, generated_at=now), - has_more=False, - ) - - out = recent_activity_module._format_project_output( - project_name="proj", - activity_data=activity, - timeframe="7d", - page=1, - ) - assert "1 items found." in out - assert "Use page=" not in out - - -@pytest.mark.asyncio -async def test_recent_activity_entity_rows_include_external_id(client, test_graph, test_project): - """Entity rows carry the note external_id for web-app link building. - - The hosted MCP link template tells agents to substitute this id, so it - must be visible in the text output they read. - """ - import re - - result = await recent_activity(project=test_project.name, timeframe="30d") - - assert isinstance(result, str) - - assert "Recent Notes & Documents" in result - uuid_pattern = r"\[id: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\]" - entity_lines = [line for line in result.splitlines() if line.strip().startswith("•")] - assert entity_lines, f"no entity rows in: {result!r}" - assert any(re.search(uuid_pattern, line) for line in entity_lines), ( - f"entity rows missing external_id: {entity_lines!r}" - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_recent_activity_never_indexed_says_so( - client, test_project, session_maker, config_home, output_format -): - """A never-indexed project must not report an ordinary empty activity feed (#1534).""" - from sqlalchemy import text as sa_text - - from basic_memory import db - - # Trigger: files exist on disk but no index pass has ever completed. - notes_dir = config_home / "notes" - notes_dir.mkdir(exist_ok=True) - (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNot yet indexed.\n") - async with db.scoped_session(session_maker) as session: - await session.execute( - sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), - {"id": test_project.id}, - ) - - result = await recent_activity( - project=test_project.name, timeframe="7d", output_format=output_format - ) - - assert isinstance(result, str) - assert "never been indexed" in result - assert "bm project index" in result - - -@pytest.mark.asyncio -async def test_recent_activity_indexed_empty_keeps_onboarding( - client, test_project, session_maker, config_home -): - """An indexed-but-quiet project keeps the original onboarding copy (#1534).""" - from datetime import datetime - - from sqlalchemy import text as sa_text - - from basic_memory import db - - async with db.scoped_session(session_maker) as session: - await session.execute( - sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), - {"now": datetime.now(), "id": test_project.id}, - ) - - result = await recent_activity(project=test_project.name, timeframe="7d") - - assert isinstance(result, str) - assert "No recent activity" in result - assert "never been indexed" not in result diff --git a/tests/mcp/test_tool_telemetry.py b/tests/mcp/test_tool_telemetry.py index 4813fa2a9..3d95de383 100644 --- a/tests/mcp/test_tool_telemetry.py +++ b/tests/mcp/test_tool_telemetry.py @@ -9,9 +9,8 @@ import pytest from typing import Any -from basic_memory.mcp.tools import bm_write +from basic_memory.mcp.tools import bm_context, bm_write -build_context_module = importlib.import_module("basic_memory.mcp.tools.build_context") edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note") search_module = importlib.import_module("basic_memory.mcp.tools.search") @@ -205,7 +204,7 @@ async def test_edit_note_emits_root_operation_and_project_context( @pytest.mark.asyncio -async def test_build_context_emits_root_operation_and_project_context( +async def test_bm_context_emits_client_operation_and_project_context( app, test_project, monkeypatch ) -> None: await bm_write( @@ -218,32 +217,21 @@ async def test_build_context_emits_root_operation_and_project_context( spans, fake_span = _recording_spans() monkeypatch.setattr(logfire, "span", fake_span) - await build_context_module.build_context( - url="memory://notes/context-telemetry-note", + await bm_context( + identifier="memory://notes/context-telemetry-note", project=test_project.name, depth=2, - timeframe="7d", + since="7d", page=1, page_size=5, max_related=3, output_format="json", ) - assert spans[0] == ( - "mcp.tool.build_context", - { - "entrypoint": "mcp", - "tool_name": "build_context", - "requested_project": test_project.name, - "requested_project_id": None, - "depth": 2, - "timeframe": "7d", - "page": 1, - "page_size": 5, - "max_related": 3, - "output_format": "json", - "is_memory_url": True, - }, + assert _contains_span_attrs( + spans, + "mcp.client.memory.build_context", + {"client_name": "memory", "operation": "build_context", "page": 1, "page_size": 5}, ) span_names = [name for name, _ in spans] assert "api.request.memory.build_context" in span_names diff --git a/tests/test_project_resolver.py b/tests/test_project_resolver.py index f6fadf614..28e8f82b7 100644 --- a/tests/test_project_resolver.py +++ b/tests/test_project_resolver.py @@ -41,8 +41,8 @@ def test_default_project_is_used_as_fallback(self): assert result.project == "my-default" assert result.mode == ResolutionMode.DEFAULT - def test_no_resolution_when_no_default_and_no_discovery(self): - """Without explicit/default/discovery, resolution should return NONE.""" + def test_no_resolution_when_no_default(self): + """Without explicit/default, resolution should return NONE.""" resolver = ProjectResolver(default_project=None) result = resolver.resolve(project=None) @@ -51,16 +51,6 @@ def test_no_resolution_when_no_default_and_no_discovery(self): assert result.mode == ResolutionMode.NONE assert result.is_resolved is False - def test_discovery_resolution_when_allowed(self): - """Discovery mode should return DISCOVERY when allowed.""" - resolver = ProjectResolver(default_project=None) - - result = resolver.resolve(project=None, allow_discovery=True) - - assert result.project is None - assert result.mode == ResolutionMode.DISCOVERY - assert result.is_discovery_mode is True - def test_require_project_success(self): """require_project returns result when project resolves.""" resolver = ProjectResolver(default_project="required-project") @@ -122,33 +112,6 @@ def test_is_resolved_false(self): ) assert result.is_resolved is False - def test_is_discovery_mode_discovery(self): - """is_discovery_mode is True for DISCOVERY.""" - result = ResolvedProject( - project=None, - mode=ResolutionMode.DISCOVERY, - reason="test", - ) - assert result.is_discovery_mode is True - - def test_is_discovery_mode_none(self): - """is_discovery_mode is True for NONE with no project.""" - result = ResolvedProject( - project=None, - mode=ResolutionMode.NONE, - reason="test", - ) - assert result.is_discovery_mode is True - - def test_is_discovery_mode_false(self): - """is_discovery_mode is False when project is resolved.""" - result = ResolvedProject( - project="test", - mode=ResolutionMode.EXPLICIT, - reason="test", - ) - assert result.is_discovery_mode is False - def test_frozen_dataclass(self): """ResolvedProject is immutable.""" result = ResolvedProject(