feat: enable default_project_mode by default (#560)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-12 20:34:46 -06:00
committed by GitHub
parent 8df88e4d02
commit ed9487708e
12 changed files with 164 additions and 70 deletions
+1 -1
View File
@@ -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.",
)
+6 -8
View File
@@ -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
@@ -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
@@ -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") # ✓
+3 -2
View File
@@ -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.
+3 -2
View File
@@ -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
+4 -8
View File
@@ -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",
+33 -41
View File
@@ -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(
+1 -1
View File
@@ -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
+23 -1
View File
@@ -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"
+18 -3
View File
@@ -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")
+70 -1
View File
@@ -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."""