2929from basic_memory .schemas .project_info import ProjectItem , ProjectList
3030from basic_memory .schemas .v2 import ProjectResolveResponse
3131from basic_memory .schemas .memory import memory_url_path
32- from basic_memory .utils import generate_permalink , normalize_project_reference
32+ from basic_memory .utils import (
33+ build_qualified_permalink_reference ,
34+ generate_permalink ,
35+ normalize_project_reference ,
36+ )
3337from basic_memory .workspace_context import (
3438 current_workspace_permalink_context ,
3539 workspace_permalink_context ,
@@ -417,14 +421,20 @@ def _unqualified_project_identifier(identifier: str) -> str:
417421 return project_identifier
418422
419423
420- def _split_workspace_memory_url_segments (identifier : str ) -> tuple [ str , str , str ] | None :
421- """Split ``memory://<workspace>/<project>/< path>`` into route segments ."""
422- if not identifier .strip (). startswith ( "memory://" ):
423- return None
424+ def _identifier_path (identifier : str ) -> str :
425+ """Return the routable path portion of a raw identifier or memory URL ."""
426+ stripped = identifier .strip ()
427+ return memory_url_path ( stripped ) if stripped . startswith ( "memory://" ) else stripped
424428
425- normalized = normalize_project_reference (memory_url_path (identifier ))
429+
430+ def _split_workspace_identifier_segments (identifier : str ) -> tuple [str , str , str ] | None :
431+ """Split ``<workspace>/<project>/<path>`` identifiers into route segments."""
432+ normalized = normalize_project_reference (_identifier_path (identifier )).strip ("/" )
426433 parts = normalized .split ("/" , 2 )
427434 if len (parts ) != 3 :
435+ # Trigger: two-segment identifiers such as `workspace/project` or `project/path`.
436+ # Why: without a remainder, the shape is ambiguous with existing project-prefix routing.
437+ # Outcome: fall through so the normal project-prefix/default-project resolver decides.
428438 return None
429439
430440 workspace_slug , project_identifier , remainder = parts
@@ -433,6 +443,14 @@ def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str
433443 return workspace_slug , project_identifier , remainder
434444
435445
446+ def _split_workspace_memory_url_segments (identifier : str ) -> tuple [str , str , str ] | None :
447+ """Split ``memory://<workspace>/<project>/<path>`` into route segments."""
448+ if not identifier .strip ().startswith ("memory://" ):
449+ return None
450+
451+ return _split_workspace_identifier_segments (identifier )
452+
453+
436454def _canonical_memory_path_for_workspace (
437455 * ,
438456 workspace_slug : str ,
@@ -448,10 +466,14 @@ def _canonical_memory_path_for_workspace(
448466 # Trigger: a caller supplied a workspace-qualified memory URL.
449467 # Why: the first two path segments are the global route, even for Personal.
450468 # Outcome: lookups preserve the complete workspace/project canonical permalink.
451- prefix = f"{ generate_permalink (workspace_slug )} /{ project_permalink } "
452469 if not normalized_remainder :
453- return prefix
454- return f"{ prefix } /{ normalized_remainder } "
470+ normalized_remainder = project_permalink
471+ return build_qualified_permalink_reference (
472+ project_permalink ,
473+ normalized_remainder ,
474+ include_project = True ,
475+ workspace_permalink = workspace_slug ,
476+ )
455477
456478
457479def _canonical_memory_path_for_active_route (
@@ -528,6 +550,27 @@ async def resolve_workspace_qualified_memory_url(
528550 if segments is None :
529551 return None
530552
553+ return await _resolve_workspace_segments (identifier , segments , context = context )
554+
555+
556+ async def resolve_workspace_qualified_identifier (
557+ identifier : str ,
558+ context : Optional [Context ] = None ,
559+ ) -> WorkspaceMemoryUrlResolution | None :
560+ """Resolve a workspace-qualified permalink or memory URL against accessible workspaces."""
561+ segments = _split_workspace_identifier_segments (identifier )
562+ if segments is None :
563+ return None
564+
565+ return await _resolve_workspace_segments (identifier , segments , context = context )
566+
567+
568+ async def _resolve_workspace_segments (
569+ identifier : str ,
570+ segments : tuple [str , str , str ],
571+ context : Optional [Context ] = None ,
572+ ) -> WorkspaceMemoryUrlResolution | None :
573+ """Resolve parsed workspace/project/path segments against accessible workspaces."""
531574 workspace_slug , project_identifier , remainder = segments
532575 index = await _ensure_workspace_project_index (context = context )
533576 workspace = next (
@@ -1278,17 +1321,51 @@ async def detect_project_from_memory_url_prefix(
12781321 if not identifier .strip ().startswith ("memory://" ):
12791322 return None
12801323
1324+ return await detect_project_from_identifier_prefix (identifier , config , context = context )
1325+
1326+
1327+ async def detect_project_from_identifier_prefix (
1328+ identifier : str ,
1329+ config : BasicMemoryConfig ,
1330+ context : Optional [Context ] = None ,
1331+ ) -> Optional [str ]:
1332+ """Resolve a project from a plain permalink, memory URL, or workspace route prefix."""
12811333 local_project = detect_project_from_url_prefix (identifier , config )
12821334 if local_project is not None :
12831335 return local_project
12841336
1337+ normalized_identifier = normalize_project_reference (_identifier_path (identifier )).strip ("/" )
1338+ if "/" not in normalized_identifier :
1339+ # Trigger: plain text search query or single-segment title/permalink.
1340+ # Why: cloud project discovery can build a workspace index; only path-shaped
1341+ # identifiers carry enough structure to justify that cost.
1342+ # Outcome: keep unqualified search/title input on the active/default project route.
1343+ return None
1344+
12851345 if _cloud_workspace_discovery_available (config ):
1286- resolution = await resolve_workspace_qualified_memory_url (
1346+ workspace_resolution = await resolve_workspace_qualified_identifier (
12871347 identifier ,
12881348 context = context ,
12891349 )
1290- if resolution is not None :
1291- return resolution .project_identifier
1350+ if workspace_resolution is not None :
1351+ return workspace_resolution .project_identifier
1352+
1353+ project_prefix , _ = _split_project_prefix (normalized_identifier )
1354+ if project_prefix is None :
1355+ return None
1356+
1357+ try :
1358+ project_resolution = await resolve_workspace_project_identifier (
1359+ project_prefix ,
1360+ context = context ,
1361+ )
1362+ except ValueError as exc :
1363+ message = str (exc ).lower ()
1364+ if "not found" in message or "no accessible workspaces" in message :
1365+ return None
1366+ raise
1367+
1368+ return project_resolution .qualified_name
12921369
12931370 return None
12941371
0 commit comments