Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,12 @@ def data_dir_path(self) -> Path:

# Module-level cache for configuration
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
# Track config file mtime+size so cross-process changes (e.g. `bm project set-cloud`
# in a separate terminal) invalidate the cache in long-lived processes like the
# MCP stdio server. Using both mtime and size guards against coarse-granularity
# filesystems where two writes within the same second share the same mtime.
_CONFIG_MTIME: Optional[float] = None
_CONFIG_SIZE: Optional[int] = None


class ConfigManager:
Expand Down Expand Up @@ -662,13 +668,38 @@ def load_config(self) -> BasicMemoryConfig:
Environment variables take precedence over file config values,
following Pydantic Settings best practices.

Uses module-level cache for performance across ConfigManager instances.
Uses module-level cache with file mtime validation so that
cross-process config changes (e.g. `bm project set-cloud` in a
separate terminal) are picked up by long-lived processes like
the MCP stdio server.
"""
global _CONFIG_CACHE
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE

# Return cached config if available
# Trigger: cached config exists but the on-disk file may have been
# modified by another process (CLI command in a different terminal).
# Why: the MCP server is long-lived; without this check it would
# serve stale project routing forever.
# Outcome: cheap os.stat() per access; re-read only when mtime or size differs.
if _CONFIG_CACHE is not None:
return _CONFIG_CACHE
try:
st = self.config_file.stat()
current_mtime = st.st_mtime
current_size = st.st_size
except OSError:
current_mtime = None
current_size = None

if (
current_mtime is not None
and current_mtime == _CONFIG_MTIME
and current_size == _CONFIG_SIZE
):
return _CONFIG_CACHE

# mtime/size changed or file gone — invalidate and fall through to re-read
_CONFIG_CACHE = None
_CONFIG_MTIME = None
_CONFIG_SIZE = None

if self.config_file.exists():
try:
Expand Down Expand Up @@ -723,6 +754,15 @@ def load_config(self) -> BasicMemoryConfig:

_CONFIG_CACHE = BasicMemoryConfig(**merged_data)

# Record mtime+size so subsequent calls detect cross-process changes
try:
st = self.config_file.stat()
_CONFIG_MTIME = st.st_mtime
_CONFIG_SIZE = st.st_size
except OSError:
_CONFIG_MTIME = None
_CONFIG_SIZE = None

# Re-save to normalize legacy config into current format
if needs_resave:
# Create backup before overwriting so users can revert if needed
Expand Down Expand Up @@ -753,10 +793,12 @@ def load_config(self) -> BasicMemoryConfig:

def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file and invalidate cache."""
global _CONFIG_CACHE
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
save_basic_memory_config(self.config_file, config)
# Invalidate cache so next load_config() reads fresh data
_CONFIG_CACHE = None
_CONFIG_MTIME = None
_CONFIG_SIZE = None

@property
def projects(self) -> Dict[str, str]:
Expand Down
2 changes: 2 additions & 0 deletions test-int/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_manager = ConfigManager()
# Update its paths to use the test directory
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

monkeypatch.setenv("HOME", str(tmp_path))
if os.name == "nt":
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_json_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ def _write(config_data: dict):
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_project_add_with_local_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def mock_config(tmp_path, monkeypatch):
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_project_list_and_ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def _write(config_data: dict) -> Path:
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand Down
24 changes: 24 additions & 0 deletions tests/cli/test_project_set_cloud_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def mock_config(tmp_path, monkeypatch):
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -68,6 +70,8 @@ def test_set_cloud_no_credentials(self, runner, tmp_path, monkeypatch):
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -91,6 +95,8 @@ def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -161,18 +167,24 @@ def test_set_local_clears_workspace_id(self, runner, mock_config):

# Manually set workspace_id on the project
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
config_data = json.loads(mock_config.read_text())
config_data["projects"]["research"]["mode"] = "cloud"
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
mock_config.write_text(json.dumps(config_data, indent=2))
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

# Set back to local
result = runner.invoke(app, ["project", "set-local", "research"])
assert result.exit_code == 0

# Verify workspace_id was cleared
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
updated_data = json.loads(mock_config.read_text())
assert updated_data["projects"]["research"]["workspace_id"] is None
assert updated_data["projects"]["research"]["mode"] == "local"
Expand All @@ -187,6 +199,8 @@ def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config,
from basic_memory.schemas.cloud import WorkspaceInfo

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

async def fake_get_available_workspaces():
return [
Expand All @@ -210,6 +224,8 @@ async def fake_get_available_workspaces():

# Verify workspace_id was persisted
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
updated_data = json.loads(mock_config.read_text())
assert (
updated_data["projects"]["research"]["workspace_id"]
Expand All @@ -222,6 +238,8 @@ def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypat
from basic_memory.schemas.cloud import WorkspaceInfo

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

async def fake_get_available_workspaces():
return [
Expand Down Expand Up @@ -249,17 +267,23 @@ def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

# Set default_workspace in config
config_data = json.loads(mock_config.read_text())
config_data["default_workspace"] = "global-default-tenant-id"
mock_config.write_text(json.dumps(config_data, indent=2))
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0

# Verify workspace_id was set from default
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
updated_data = json.loads(mock_config.read_text())
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"
4 changes: 4 additions & 0 deletions tests/cli/test_workspace_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ def _setup_config(self, monkeypatch):
monkeypatch.setenv("HOME", str(temp_path))
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None

config_manager = ConfigManager()
test_config = BasicMemoryConfig(
Expand Down Expand Up @@ -106,6 +108,8 @@ async def fake_get_available_workspaces(context=None):

# Verify config was updated
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
config = ConfigManager().config
assert config.default_workspace == "11111111-1111-1111-1111-111111111111"

Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

# Create a new ConfigManager that uses the test home directory
config_manager = ConfigManager()
Expand Down
8 changes: 8 additions & 0 deletions tests/mcp/test_tool_write_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,11 @@ async def test_write_note_config_overwrite_default_true(
# Set config to allow overwrites by default
app_config.write_note_overwrite_default = True
config_module._CONFIG_CACHE = app_config
# Pin mtime+size to the on-disk file so the cache guard sees a match
# and keeps our injected config instead of re-reading from disk.
_st = config_manager.config_file.stat()
config_module._CONFIG_MTIME = _st.st_mtime
config_module._CONFIG_SIZE = _st.st_size

try:
await write_note(
Expand All @@ -1281,6 +1286,9 @@ async def test_write_note_config_overwrite_default_true(
# Restore config
app_config.write_note_overwrite_default = False
config_module._CONFIG_CACHE = app_config
_st = config_manager.config_file.stat()
config_module._CONFIG_MTIME = _st.st_mtime
config_module._CONFIG_SIZE = _st.st_size

@pytest.mark.asyncio
async def test_write_note_new_note_unaffected(self, app, test_project):
Expand Down
10 changes: 10 additions & 0 deletions tests/services/test_project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@ async def test_add_project_with_project_root_sanitizes_paths(
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

test_cases = [
# (project_name, user_path, expected_sanitized_name)
Expand Down Expand Up @@ -845,6 +847,8 @@ async def test_add_project_with_project_root_rejects_escape_attempts(
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

# All of these should succeed by being sanitized to paths under project_root
# The sanitization removes dangerous patterns, so they don't escape
Expand Down Expand Up @@ -931,6 +935,8 @@ async def test_add_project_with_project_root_normalizes_case(
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

test_cases = [
# (input_path, expected_normalized_path)
Expand Down Expand Up @@ -985,6 +991,8 @@ async def test_add_project_with_project_root_detects_case_collisions(
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

# First, create a project with lowercase path
first_project = "documents-project"
Expand Down Expand Up @@ -1159,6 +1167,8 @@ async def test_add_project_nested_validation_with_project_root(
from basic_memory import config as config_module

config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None

parent_project_name = f"cloud-parent-{os.urandom(4).hex()}"
child_project_name = f"cloud-child-{os.urandom(4).hex()}"
Expand Down
Loading
Loading