Skip to content

Commit a4e0422

Browse files
committed
perf fixes
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 552a835 commit a4e0422

4 files changed

Lines changed: 270 additions & 29 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,41 @@ async def _resolve_default_project_from_api() -> Optional[str]:
6464
return None
6565

6666

67+
async def _get_cached_active_project(context: Optional[Context]) -> Optional[ProjectItem]:
68+
"""Return the cached active project from context when available."""
69+
if not context:
70+
return None
71+
72+
cached_raw = await context.get_state("active_project")
73+
if isinstance(cached_raw, dict):
74+
return ProjectItem.model_validate(cached_raw)
75+
return None
76+
77+
78+
async def _set_cached_active_project(
79+
context: Optional[Context],
80+
active_project: ProjectItem,
81+
) -> None:
82+
"""Persist the active project and known default-project metadata in context."""
83+
if not context:
84+
return
85+
86+
await context.set_state("active_project", active_project.model_dump())
87+
if active_project.is_default:
88+
await context.set_state("default_project_name", active_project.name)
89+
90+
91+
async def _get_cached_default_project(context: Optional[Context]) -> Optional[str]:
92+
"""Return the cached default project name from context when available."""
93+
if not context:
94+
return None
95+
96+
cached_default = await context.get_state("default_project_name")
97+
if isinstance(cached_default, str):
98+
return cached_default
99+
return None
100+
101+
67102
def _canonicalize_project_name(
68103
project_name: Optional[str],
69104
config: BasicMemoryConfig,
@@ -85,11 +120,23 @@ def _canonicalize_project_name(
85120
return project_name
86121

87122

123+
def _project_matches_identifier(project_item: ProjectItem, identifier: Optional[str]) -> bool:
124+
"""Return True when the identifier refers to the cached project."""
125+
if identifier is None:
126+
return True
127+
128+
normalized_identifier = generate_permalink(identifier)
129+
return normalized_identifier in {
130+
generate_permalink(project_item.name),
131+
project_item.permalink,
132+
}
133+
88134

89135
async def resolve_project_parameter(
90136
project: Optional[str] = None,
91137
allow_discovery: bool = False,
92138
default_project: Optional[str] = None,
139+
context: Optional[Context] = None,
93140
) -> Optional[str]:
94141
"""Resolve project parameter using unified linear priority chain.
95142
@@ -119,14 +166,32 @@ async def resolve_project_parameter(
119166
):
120167
config = ConfigManager().config
121168

122-
# Load config for any values not explicitly provided.
123-
# ConfigManager reads from the local config file, which doesn't exist in cloud mode.
124-
# When it returns None, fall back to querying the projects API for the is_default flag.
125-
if default_project is None:
169+
# Trigger: project already resolved earlier in the same MCP request
170+
# Why: the active project is request-constant, so re-discovering the
171+
# default project via /v2/projects/ just repeats work
172+
# Outcome: reuse the cached project name as the explicit candidate
173+
if project is None:
174+
cached_project = await _get_cached_active_project(context)
175+
if cached_project is not None:
176+
project = cached_project.name
177+
178+
# Trigger: there is no explicit project after env/context normalization
179+
# Why: default-project discovery is only needed as a fallback; doing it
180+
# for explicit requests adds an avoidable /v2/projects/ round-trip
181+
# Outcome: skip default lookup when the active project is already known
182+
if default_project is None and project is None:
183+
# Load config for any values not explicitly provided.
184+
# ConfigManager reads from the local config file, which doesn't exist in cloud mode.
185+
# When it returns None, fall back to querying the projects API for the is_default flag.
126186
default_project = config.default_project
127187

128-
if default_project is None:
129-
default_project = await _resolve_default_project_from_api()
188+
if default_project is None:
189+
default_project = await _get_cached_default_project(context)
190+
191+
if default_project is None:
192+
default_project = await _resolve_default_project_from_api()
193+
if default_project and context:
194+
await context.set_state("default_project_name", default_project)
130195

131196
# Create resolver with configuration and resolve
132197
resolver = ProjectResolver.from_env(
@@ -290,7 +355,12 @@ async def get_active_project(
290355
# Deferred import to avoid circular dependency with tools
291356
from basic_memory.mcp.tools.utils import call_post
292357

293-
resolved_project = await resolve_project_parameter(project)
358+
cached_project = await _get_cached_active_project(context)
359+
if cached_project and _project_matches_identifier(cached_project, project):
360+
logger.debug(f"Using cached project from context: {cached_project.name}")
361+
return cached_project
362+
363+
resolved_project = await resolve_project_parameter(project, context=context)
294364
if not resolved_project:
295365
project_names = await get_project_names(client, headers)
296366
raise ValueError(
@@ -301,14 +371,9 @@ async def get_active_project(
301371

302372
project = resolved_project
303373

304-
# Check if already cached in context
305-
if context:
306-
cached_raw = await context.get_state("active_project")
307-
if isinstance(cached_raw, dict):
308-
cached_project = ProjectItem.model_validate(cached_raw)
309-
if cached_project.name == project:
310-
logger.debug(f"Using cached project from context: {project}")
311-
return cached_project
374+
if cached_project and _project_matches_identifier(cached_project, project):
375+
logger.debug(f"Using cached project from context: {cached_project.name}")
376+
return cached_project
312377

313378
# Validate project exists by calling API
314379
logger.debug(f"Validating project: {project}")
@@ -328,8 +393,8 @@ async def get_active_project(
328393
)
329394

330395
# Cache in context if available
396+
await _set_cached_active_project(context, active_project)
331397
if context:
332-
await context.set_state("active_project", active_project.model_dump())
333398
logger.debug(f"Cached project in context: {project}")
334399

335400
logger.debug(f"Validated project: {active_project.name}")
@@ -383,6 +448,21 @@ async def resolve_project_and_path(
383448
# Why: allow project-scoped memory URLs without requiring a separate project parameter
384449
# Outcome: attempt to resolve the prefix as a project and route to it
385450
if project_prefix:
451+
cached_project = await _get_cached_active_project(context)
452+
if cached_project and _project_matches_identifier(cached_project, project_prefix):
453+
resolved_project = await resolve_project_parameter(project_prefix, context=context)
454+
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
455+
project_prefix
456+
):
457+
raise ValueError(
458+
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
459+
)
460+
461+
resolved_path = (
462+
f"{cached_project.permalink}/{remainder}" if include_project else remainder
463+
)
464+
return cached_project, resolved_path, True
465+
386466
try:
387467
from basic_memory.mcp.tools.utils import call_post
388468

@@ -397,7 +477,7 @@ async def resolve_project_and_path(
397477
if "project not found" not in str(exc).lower():
398478
raise
399479
else:
400-
resolved_project = await resolve_project_parameter(project_prefix)
480+
resolved_project = await resolve_project_parameter(project_prefix, context=context)
401481
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
402482
project_prefix
403483
):
@@ -412,8 +492,7 @@ async def resolve_project_and_path(
412492
path=resolved.path,
413493
is_default=resolved.is_default,
414494
)
415-
if context:
416-
await context.set_state("active_project", active_project.model_dump())
495+
await _set_cached_active_project(context, active_project)
417496

418497
resolved_path = (
419498
f"{resolved.permalink}/{remainder}" if include_project else remainder
@@ -530,7 +609,7 @@ async def get_project_client(
530609
)
531610

532611
# Step 1: Resolve project name from config (no network call)
533-
resolved_project = await resolve_project_parameter(project)
612+
resolved_project = await resolve_project_parameter(project, context=context)
534613
if not resolved_project:
535614
# Fall back to local client to discover projects and raise helpful error
536615
async with get_client() as client:

src/basic_memory/sync/sync_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,9 @@ async def sync(
350350
# Only resolve relations if there were actual changes
351351
# If no files changed, no new unresolved relations could have been created
352352
if report.total > 0:
353-
with telemetry.scope("sync.project.resolve_relations", relation_scope="all_pending"):
353+
with telemetry.scope(
354+
"sync.project.resolve_relations", relation_scope="all_pending"
355+
):
354356
await self.resolve_relations()
355357
else:
356358
logger.info("Skipping relation resolution - no file changes detected")

tests/mcp/test_project_context.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,169 @@ async def fail_if_called(context=None): # pragma: no cover
311311
assert resolved.tenant_id == cached_workspace.tenant_id
312312

313313

314+
@pytest.mark.asyncio
315+
async def test_resolve_project_parameter_uses_cached_active_project_before_api_default_lookup(
316+
config_manager, monkeypatch
317+
):
318+
from basic_memory.mcp.project_context import resolve_project_parameter
319+
from basic_memory.schemas.project_info import ProjectItem
320+
321+
config = config_manager.load_config()
322+
config.default_project = None
323+
config_manager.save_config(config)
324+
325+
context = _ContextState()
326+
cached_project = ProjectItem(
327+
id=1,
328+
external_id="11111111-1111-1111-1111-111111111111",
329+
name="Cached Project",
330+
path="/tmp/cached-project",
331+
is_default=True,
332+
)
333+
await context.set_state("active_project", cached_project.model_dump())
334+
335+
async def fail_if_called(): # pragma: no cover
336+
raise AssertionError("Default project API lookup should not run when project is cached")
337+
338+
monkeypatch.setattr(
339+
"basic_memory.mcp.project_context._resolve_default_project_from_api",
340+
fail_if_called,
341+
)
342+
343+
resolved = await resolve_project_parameter(project=None, context=context)
344+
assert resolved == cached_project.name
345+
346+
347+
@pytest.mark.asyncio
348+
async def test_resolve_project_parameter_caches_api_default_project_name(
349+
config_manager, monkeypatch
350+
):
351+
from basic_memory.mcp.project_context import resolve_project_parameter
352+
353+
config = config_manager.load_config()
354+
config.default_project = None
355+
config_manager.save_config(config)
356+
357+
context = _ContextState()
358+
api_calls = {"count": 0}
359+
360+
async def fake_default_lookup():
361+
api_calls["count"] += 1
362+
return "cloud-default"
363+
364+
monkeypatch.setattr(
365+
"basic_memory.mcp.project_context._resolve_default_project_from_api",
366+
fake_default_lookup,
367+
)
368+
369+
first = await resolve_project_parameter(project=None, context=context)
370+
second = await resolve_project_parameter(project=None, context=context)
371+
372+
assert first == "cloud-default"
373+
assert second == "cloud-default"
374+
assert api_calls["count"] == 1
375+
376+
377+
@pytest.mark.asyncio
378+
async def test_get_active_project_uses_cached_project_before_resolution(monkeypatch):
379+
from basic_memory.mcp.project_context import get_active_project
380+
from basic_memory.schemas.project_info import ProjectItem
381+
382+
context = _ContextState()
383+
cached_project = ProjectItem(
384+
id=1,
385+
external_id="11111111-1111-1111-1111-111111111111",
386+
name="Cached Project",
387+
path="/tmp/cached-project",
388+
is_default=True,
389+
)
390+
await context.set_state("active_project", cached_project.model_dump())
391+
392+
async def fail_if_called(*args, **kwargs): # pragma: no cover
393+
raise AssertionError("Project resolution should not run when cache matches")
394+
395+
monkeypatch.setattr(
396+
"basic_memory.mcp.project_context.resolve_project_parameter",
397+
fail_if_called,
398+
)
399+
400+
resolved = await get_active_project(client=None, context=context)
401+
assert resolved == cached_project
402+
403+
404+
@pytest.mark.asyncio
405+
async def test_get_active_project_uses_cached_project_for_explicit_permalink(monkeypatch):
406+
from basic_memory.mcp.project_context import get_active_project
407+
from basic_memory.schemas.project_info import ProjectItem
408+
409+
context = _ContextState()
410+
cached_project = ProjectItem(
411+
id=1,
412+
external_id="11111111-1111-1111-1111-111111111111",
413+
name="My Research",
414+
path="/tmp/my-research",
415+
is_default=False,
416+
)
417+
await context.set_state("active_project", cached_project.model_dump())
418+
419+
async def fail_if_called(*args, **kwargs): # pragma: no cover
420+
raise AssertionError(
421+
"Project resolution should not run when explicit project matches cache"
422+
)
423+
424+
monkeypatch.setattr(
425+
"basic_memory.mcp.project_context.resolve_project_parameter",
426+
fail_if_called,
427+
)
428+
429+
resolved = await get_active_project(client=None, project="my-research", context=context)
430+
assert resolved == cached_project
431+
432+
433+
@pytest.mark.asyncio
434+
async def test_resolve_project_and_path_uses_cached_project_for_memory_url_prefix(
435+
config_manager, monkeypatch
436+
):
437+
from basic_memory.mcp.project_context import resolve_project_and_path
438+
from basic_memory.schemas.project_info import ProjectItem
439+
440+
config = config_manager.load_config()
441+
config.permalinks_include_project = False
442+
config_manager.save_config(config)
443+
444+
context = _ContextState()
445+
cached_project = ProjectItem(
446+
id=1,
447+
external_id="11111111-1111-1111-1111-111111111111",
448+
name="My Research",
449+
path="/tmp/my-research",
450+
is_default=False,
451+
)
452+
await context.set_state("active_project", cached_project.model_dump())
453+
454+
async def fail_if_called(*args, **kwargs): # pragma: no cover
455+
raise AssertionError("Project resolve API should not run when memory URL matches cache")
456+
457+
async def fake_resolve_project_parameter(project=None, **kwargs):
458+
return cached_project.name if project else cached_project.name
459+
460+
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fail_if_called)
461+
monkeypatch.setattr(
462+
"basic_memory.mcp.project_context.resolve_project_parameter",
463+
fake_resolve_project_parameter,
464+
)
465+
466+
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
467+
client=None,
468+
identifier="memory://my-research/notes/roadmap.md",
469+
context=context,
470+
)
471+
472+
assert active_project == cached_project
473+
assert resolved_path == "notes/roadmap.md"
474+
assert is_memory_url is True
475+
476+
314477
@pytest.mark.asyncio
315478
async def test_get_project_client_rejects_workspace_for_local_project(config_manager):
316479
from basic_memory.mcp.project_context import get_project_client
@@ -383,7 +546,6 @@ def test_matches_case_insensitive_via_permalink(self, config_manager):
383546
assert result == "My Research"
384547

385548

386-
387549
class TestGetProjectClientRoutingOrder:
388550
"""Test that get_project_client respects explicit routing before workspace resolution."""
389551

0 commit comments

Comments
 (0)