mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
@@ -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") # ✓
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user