diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 8f0561d2..4414c3e4 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -81,7 +81,7 @@ class BasicMemoryConfig(BaseSettings): description="Name of the default project to use", ) default_project_mode: bool = Field( - default=False, + default=True, description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.", ) diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index 69a9822d..cef32ca9 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -29,19 +29,17 @@ async def resolve_project_parameter( default_project_mode: Optional[bool] = None, default_project: Optional[str] = None, ) -> Optional[str]: - """Resolve project parameter using three-tier hierarchy. + """Resolve project parameter using unified linear priority chain. This is a thin wrapper around ProjectResolver for backwards compatibility. New code should consider using ProjectResolver directly for more detailed resolution information. - if cloud_mode: - project is required (unless allow_discovery=True for tools that support discovery mode) - else: - Resolution order: - 1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority - 2. Explicit project parameter - medium priority - 3. Default project if default_project_mode=true - lowest priority + Resolution order (same for local and cloud modes): + 1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority) + 2. EXPLICIT: project parameter passed directly + 3. DEFAULT: default project when default_project_mode=true + 4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE Args: project: Optional explicit project parameter diff --git a/src/basic_memory/mcp/prompts/ai_assistant_guide.py b/src/basic_memory/mcp/prompts/ai_assistant_guide.py index fcd733d4..02bdc7c1 100644 --- a/src/basic_memory/mcp/prompts/ai_assistant_guide.py +++ b/src/basic_memory/mcp/prompts/ai_assistant_guide.py @@ -32,7 +32,7 @@ def ai_assistant_guide() -> str: # Add mode-specific header mode_info = "" - if config.default_project_mode: # pragma: no cover + if config.default_project_mode: mode_info = f""" # 🎯 Default Project Mode Active diff --git a/src/basic_memory/mcp/resources/ai_assistant_guide.md b/src/basic_memory/mcp/resources/ai_assistant_guide.md index cc822216..e84b2947 100644 --- a/src/basic_memory/mcp/resources/ai_assistant_guide.md +++ b/src/basic_memory/mcp/resources/ai_assistant_guide.md @@ -43,7 +43,7 @@ await write_note("Note", "Content", "folder") await write_note("Note", "Content", "folder", project="main") ``` -When `default_project_mode=false` (default): +When `default_project_mode=false`: ```python # Project required: await write_note("Note", "Content", "folder", project="main") # ✓ diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index 17efff49..2a720a7f 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -50,8 +50,9 @@ async def build_context( a rich context graph of related information. Project Resolution: - Server resolves projects in this order: Single Project Mode → project parameter → default project. - If project unknown, use list_memory_projects() or recent_activity() first. + 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. diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 54e22809..e3424ceb 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -30,8 +30,9 @@ async def read_note( returning the raw markdown content including observations, relations, and metadata. Project Resolution: - Server resolves projects in this order: Single Project Mode → project parameter → default project. - If project unknown, use list_memory_projects() or recent_activity() first. + 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. This tool will try multiple lookup strategies to find the most relevant note: 1. Direct permalink lookup diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index a9a79c89..a82887e4 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -32,8 +32,9 @@ async def write_note( Creates or updates a markdown note with semantic observations and relations. Project Resolution: - Server resolves projects in this order: Single Project Mode → project parameter → default project. - If project unknown, use list_memory_projects() or recent_activity() first. + 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. The content can include semantic observations and relations using markdown syntax: @@ -79,12 +80,7 @@ async def write_note( - Session tracking metadata for project awareness Examples: - # Assistant flow when project is unknown - # 1. list_memory_projects() -> Ask user which project - # 2. User: "Use my-research" - # 3. write_note(...) and remember "my-research" for session - - # Create a simple note + # Create a simple note (uses default project automatically) write_note( project="my-research", title="Meeting Notes", diff --git a/src/basic_memory/project_resolver.py b/src/basic_memory/project_resolver.py index 20b129f4..8cb60ddc 100644 --- a/src/basic_memory/project_resolver.py +++ b/src/basic_memory/project_resolver.py @@ -3,12 +3,13 @@ This module provides a single canonical implementation of project resolution logic, eliminating duplicated decision trees across the codebase. -The resolution follows a three-tier hierarchy: -1. Constrained mode: BASIC_MEMORY_MCP_PROJECT env var (highest priority) -2. Explicit parameter: Project passed directly to operation -3. Default project: Used when default_project_mode=true (lowest priority) +The resolution follows a unified linear priority chain that works +identically in both local and cloud modes: -In cloud mode, project is required unless discovery mode is explicitly allowed. +1. ENV_CONSTRAINT: BASIC_MEMORY_MCP_PROJECT env var (highest priority) +2. EXPLICIT: Project passed directly to operation +3. DEFAULT: Default project when default_project_mode=true +4. Fallback: cloud → CLOUD_DISCOVERY or ValueError; local → NONE """ import os @@ -68,7 +69,7 @@ class ProjectResolver: used by MCP tools, API routes, and CLI commands. Args: - cloud_mode: Whether running in cloud mode (project required) + cloud_mode: Whether running in cloud mode default_project_mode: Whether to use default project when not specified default_project: The default project name constrained_project: Optional env-constrained project override @@ -110,13 +111,13 @@ class ProjectResolver: project: Optional[str] = None, allow_discovery: bool = False, ) -> ResolvedProject: - """Resolve project using the three-tier hierarchy. + """Resolve project using a unified linear priority chain. - Resolution order: - 1. Cloud mode check (project required unless discovery allowed) - 2. Constrained project from env var (highest priority in local mode) - 3. Explicit project parameter - 4. Default project if default_project_mode=true + The same resolution order applies in both local and cloud modes: + 1. ENV_CONSTRAINT — BASIC_MEMORY_MCP_PROJECT env var (highest priority) + 2. EXPLICIT — project parameter passed directly + 3. DEFAULT — default project when default_project_mode=true + 4. Fallback — cloud: CLOUD_DISCOVERY or ValueError; local: NONE Args: project: Optional explicit project parameter @@ -127,31 +128,10 @@ class ProjectResolver: ResolvedProject with project name, resolution mode, and reason Raises: - ValueError: If in cloud mode and no project specified (unless discovery allowed) + ValueError: If in cloud mode and no project could be resolved + (unless allow_discovery=True) """ - # --- Cloud Mode Handling --- - # In cloud mode, project is required unless discovery is explicitly allowed - if self.cloud_mode: - if project: - logger.debug(f"Cloud mode: using explicit project '{project}'") - return ResolvedProject( - project=project, - mode=ResolutionMode.CLOUD_EXPLICIT, - reason=f"Explicit project in cloud mode: {project}", - ) - elif allow_discovery: - logger.debug("Cloud mode: discovery mode allowed, no project required") - return ResolvedProject( - project=None, - mode=ResolutionMode.CLOUD_DISCOVERY, - reason="Discovery mode enabled in cloud", - ) - else: - raise ValueError("No project specified. Project is required for cloud mode.") - - # --- Local Mode: Three-Tier Hierarchy --- - - # Priority 1: CLI constraint overrides everything + # --- Priority 1: ENV constraint overrides everything --- if self.constrained_project: logger.debug(f"Using CLI constrained project: {self.constrained_project}") return ResolvedProject( @@ -160,16 +140,17 @@ class ProjectResolver: reason=f"Environment constraint: BASIC_MEMORY_MCP_PROJECT={self.constrained_project}", ) - # Priority 2: Explicit project parameter + # --- Priority 2: Explicit project parameter --- if project: + mode = ResolutionMode.CLOUD_EXPLICIT if self.cloud_mode else ResolutionMode.EXPLICIT logger.debug(f"Using explicit project parameter: {project}") return ResolvedProject( project=project, - mode=ResolutionMode.EXPLICIT, + mode=mode, reason=f"Explicit parameter: {project}", ) - # Priority 3: Default project mode + # --- Priority 3: Default project mode --- if self.default_project_mode and self.default_project: logger.debug(f"Using default project from config: {self.default_project}") return ResolvedProject( @@ -178,12 +159,23 @@ class ProjectResolver: reason=f"Default project mode: {self.default_project}", ) - # No resolution possible + # --- Fallback: mode-dependent behavior --- + if self.cloud_mode: + if allow_discovery: + logger.debug("Cloud mode: discovery mode allowed, no project required") + return ResolvedProject( + project=None, + mode=ResolutionMode.CLOUD_DISCOVERY, + reason="Discovery mode enabled in cloud", + ) + raise ValueError("No project specified. Project is required for cloud mode.") + + # Local mode: no resolution possible logger.debug("No project resolution possible") return ResolvedProject( project=None, mode=ResolutionMode.NONE, - reason="No project specified and default_project_mode is disabled", + reason="No project specified and no default project configured", ) def require_project( diff --git a/test-int/conftest.py b/test-int/conftest.py index 0783ce21..ef04dbe8 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -243,7 +243,7 @@ def app_config( env="test", projects=projects, default_project="test-project", - default_project_mode=False, # Match real-world usage - tools must pass explicit project + default_project_mode=False, # Explicit False for test isolation - tests pass project explicitly update_permalinks_on_move=True, cloud_mode=False, # Explicitly disable cloud mode sync_changes=False, # Disable file sync in tests - prevents lifespan from starting blocking task diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 6b7e9b07..6526fa57 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -10,11 +10,14 @@ import pytest @pytest.mark.asyncio -async def test_cloud_mode_requires_project_by_default(config_manager, monkeypatch): +async def test_cloud_mode_requires_project_when_no_default(config_manager, monkeypatch): from basic_memory.mcp.project_context import resolve_project_parameter cfg = config_manager.load_config() cfg.cloud_mode = True + # default_project_mode defaults to True, so explicitly disable it + # to test the "no default available" path + cfg.default_project_mode = False config_manager.save_config(cfg) with pytest.raises(ValueError) as exc_info: @@ -30,6 +33,8 @@ async def test_cloud_mode_allows_discovery_when_enabled(config_manager): cfg = config_manager.load_config() cfg.cloud_mode = True + # Disable default_project_mode so discovery fallback is reached + cfg.default_project_mode = False config_manager.save_config(cfg) assert await resolve_project_parameter(project=None, allow_discovery=True) is None @@ -101,3 +106,20 @@ async def test_local_mode_returns_none_when_no_resolution(config_manager, monkey monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) assert await resolve_project_parameter(project=None) is None + + +@pytest.mark.asyncio +async def test_cloud_mode_uses_default_project(config_manager, config_home, monkeypatch): + """In cloud mode with default_project_mode=True, default project is resolved.""" + from basic_memory.mcp.project_context import resolve_project_parameter + + cfg = config_manager.load_config() + cfg.cloud_mode = True + cfg.default_project_mode = True + (config_home / "cloud-default").mkdir(parents=True, exist_ok=True) + cfg.projects["cloud-default"] = str(config_home / "cloud-default") + cfg.default_project = "cloud-default" + config_manager.save_config(cfg) + + monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False) + assert await resolve_project_parameter(project=None) == "cloud-default" diff --git a/tests/mcp/test_tool_recent_activity.py b/tests/mcp/test_tool_recent_activity.py index df9a6387..f60fb686 100644 --- a/tests/mcp/test_tool_recent_activity.py +++ b/tests/mcp/test_tool_recent_activity.py @@ -128,8 +128,13 @@ async def test_recent_activity_type_invalid(client, test_project, test_graph): @pytest.mark.asyncio -async def test_recent_activity_discovery_mode(client, test_project, test_graph): +async def test_recent_activity_discovery_mode(client, test_project, test_graph, config_manager): """Test that recent_activity discovery mode works without project parameter.""" + # Explicit False to test discovery mode - default_project_mode is True by default + cfg = config_manager.load_config() + cfg.default_project_mode = False + config_manager.save_config(cfg) + # Test discovery mode (no project parameter) result = await recent_activity.fn() assert result is not None @@ -147,8 +152,13 @@ async def test_recent_activity_discovery_mode(client, test_project, test_graph): @pytest.mark.asyncio -async def test_recent_activity_discovery_mode_no_activity(client, test_project): +async def test_recent_activity_discovery_mode_no_activity(client, test_project, config_manager): """If there is no activity in any project, discovery mode should say so.""" + # Explicit False to test discovery mode - default_project_mode is True by default + cfg = config_manager.load_config() + cfg.default_project_mode = False + config_manager.save_config(cfg) + result = await recent_activity.fn() assert "Recent Activity Summary" in result assert "No recent activity found in any project." in result @@ -156,9 +166,14 @@ async def test_recent_activity_discovery_mode_no_activity(client, test_project): @pytest.mark.asyncio async def test_recent_activity_discovery_mode_multiple_active_projects( - app, client, test_project, tmp_path_factory + app, client, test_project, tmp_path_factory, config_manager ): """Discovery mode should use the multi-project guidance when multiple projects have activity.""" + # Explicit False to test discovery mode - default_project_mode is True by default + cfg = config_manager.load_config() + cfg.default_project_mode = False + config_manager.save_config(cfg) + from basic_memory.mcp.tools import create_memory_project, write_note second_root = tmp_path_factory.mktemp("second-project-home") diff --git a/tests/test_project_resolver.py b/tests/test_project_resolver.py index 2383a213..bba241ab 100644 --- a/tests/test_project_resolver.py +++ b/tests/test_project_resolver.py @@ -100,7 +100,7 @@ class TestProjectResolver: assert result.project is None assert result.mode == ResolutionMode.NONE - assert "default_project_mode is disabled" in result.reason + assert "no default project configured" in result.reason def test_require_project_success(self): """require_project returns result when project resolved.""" @@ -149,6 +149,75 @@ class TestProjectResolver: assert resolver.constrained_project == "env-project" + def test_cloud_mode_uses_default_project(self): + """In cloud mode, default project is used when default_project_mode=True.""" + resolver = ProjectResolver( + cloud_mode=True, + default_project_mode=True, + default_project="my-default", + ) + result = resolver.resolve(project=None) + + assert result.project == "my-default" + assert result.mode == ResolutionMode.DEFAULT + assert result.is_resolved is True + + def test_cloud_mode_no_default_still_requires_project(self): + """In cloud mode with default_project_mode=False, project is still required.""" + resolver = ProjectResolver( + cloud_mode=True, + default_project_mode=False, + ) + with pytest.raises(ValueError, match="Project is required for cloud mode"): + resolver.resolve(project=None) + + def test_cloud_mode_explicit_overrides_default(self): + """In cloud mode, explicit project wins over default project.""" + resolver = ProjectResolver( + cloud_mode=True, + default_project_mode=True, + default_project="my-default", + ) + result = resolver.resolve(project="explicit-project") + + assert result.project == "explicit-project" + assert result.mode == ResolutionMode.CLOUD_EXPLICIT + + def test_cloud_mode_default_mode_true_but_no_default_project(self): + """In cloud mode with default_project_mode=True but no default_project, still raises.""" + resolver = ProjectResolver( + cloud_mode=True, + default_project_mode=True, + default_project=None, + ) + with pytest.raises(ValueError, match="Project is required for cloud mode"): + resolver.resolve(project=None) + + def test_cloud_mode_discovery_fallback_after_no_default(self): + """In cloud mode, discovery still works as last resort when no default is configured.""" + resolver = ProjectResolver( + cloud_mode=True, + default_project_mode=False, + ) + result = resolver.resolve(project=None, allow_discovery=True) + + assert result.project is None + assert result.mode == ResolutionMode.CLOUD_DISCOVERY + assert result.is_discovery_mode is True + + def test_cloud_mode_env_constraint_overrides_everything(self, monkeypatch): + """In cloud mode, env constraint has highest priority.""" + monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "constrained-project") + resolver = ProjectResolver.from_env( + cloud_mode=True, + default_project_mode=True, + default_project="default-project", + ) + result = resolver.resolve(project="explicit-project") + + assert result.project == "constrained-project" + assert result.mode == ResolutionMode.ENV_CONSTRAINT + class TestResolvedProject: """Test ResolvedProject dataclass."""