1212
1313from basic_memory .config import ConfigManager , has_cloud_credentials
1414from basic_memory .mcp .async_client import get_client , get_cloud_proxy_client , is_factory_mode
15+ from basic_memory .mcp .project_context import (
16+ WorkspaceProjectEntry ,
17+ _ensure_workspace_project_index ,
18+ resolve_workspace_parameter ,
19+ )
1520from basic_memory .mcp .server import mcp
1621from basic_memory .schemas .project_info import ProjectInfoRequest , ProjectItem , ProjectList
1722from basic_memory .utils import generate_permalink
@@ -26,7 +31,8 @@ async def _fetch_cloud_projects(
2631) -> ProjectList | None :
2732 """Fetch projects from the cloud API, returning None on failure.
2833
29- Logs warnings on failure so the caller can fall back to local-only results.
34+ Logs warnings on failure so list_memory_projects can fall back to local-only
35+ results. Project-scoped routing does not use this listing fallback.
3036 """
3137 try :
3238 from basic_memory .mcp .clients import ProjectClient
@@ -38,9 +44,14 @@ async def _fetch_cloud_projects(
3844 await context .info (f"Discovered { len (cloud_list .projects )} cloud projects" )
3945 return cloud_list
4046 except Exception as exc :
41- logger .warning (f"Cloud project discovery failed: { exc } " )
47+ logger .warning (
48+ f"Cloud project discovery failed while listing projects; "
49+ f"showing local-only project list: { exc } "
50+ )
4251 if context : # pragma: no cover
43- await context .info ("Cloud project discovery failed, showing local projects only" )
52+ await context .info (
53+ "Cloud project discovery failed while listing projects; showing local projects only"
54+ )
4455 return None
4556
4657
@@ -51,6 +62,8 @@ def _merge_projects(
5162 cloud_workspace_name : str | None = None ,
5263 cloud_workspace_type : str | None = None ,
5364 cloud_workspace_tenant_id : str | None = None ,
65+ cloud_workspace_slug : str | None = None ,
66+ cloud_workspace_is_default : bool = False ,
5467) -> list [dict ]:
5568 """Merge local and cloud project lists by permalink.
5669
@@ -126,21 +139,118 @@ def _merge_projects(
126139 "workspace_name" : ws_name ,
127140 "workspace_type" : ws_type ,
128141 "workspace_tenant_id" : ws_tenant_id ,
142+ "workspace_slug" : cloud_workspace_slug if cloud_proj else None ,
143+ "workspace_is_default" : cloud_workspace_is_default if cloud_proj else False ,
144+ "qualified_name" : (
145+ f"{ cloud_workspace_slug } /{ permalink } "
146+ if cloud_proj and cloud_workspace_slug
147+ else None
148+ ),
129149 }
130150 )
131151
132152 return merged
133153
134154
155+ def _merge_workspace_projects (
156+ local_list : ProjectList | None ,
157+ cloud_entries : tuple [WorkspaceProjectEntry , ...],
158+ ) -> list [dict ]:
159+ """Merge local projects with cloud projects from every accessible workspace."""
160+ local_by_permalink : dict [str , ProjectItem ] = {}
161+ if local_list :
162+ for project in local_list .projects :
163+ local_by_permalink [project .permalink ] = project
164+
165+ cloud_permalinks = {entry .project .permalink for entry in cloud_entries }
166+ merged : list [dict ] = []
167+
168+ for entry in sorted (
169+ cloud_entries ,
170+ key = lambda item : (
171+ not item .workspace .is_default ,
172+ item .workspace .workspace_type != "personal" ,
173+ item .workspace .name .casefold (),
174+ item .project .permalink ,
175+ ),
176+ ):
177+ permalink = entry .project .permalink
178+ local_proj = local_by_permalink .get (permalink )
179+ cloud_proj = entry .project
180+ source = "local+cloud" if local_proj else "cloud"
181+ local_path = local_proj .path if local_proj else None
182+ cloud_path = cloud_proj .path
183+
184+ merged .append (
185+ {
186+ "name" : cloud_proj .name ,
187+ "path" : local_path or cloud_path ,
188+ "local_path" : local_path ,
189+ "cloud_path" : cloud_path ,
190+ "source" : source ,
191+ "is_default" : bool ((local_proj and local_proj .is_default ) or cloud_proj .is_default ),
192+ "is_private" : cloud_proj .is_private ,
193+ "display_name" : cloud_proj .display_name ,
194+ "workspace_name" : entry .workspace .name ,
195+ "workspace_type" : entry .workspace .workspace_type ,
196+ "workspace_tenant_id" : entry .workspace .tenant_id ,
197+ "workspace_slug" : entry .workspace .slug ,
198+ "workspace_is_default" : entry .workspace .is_default ,
199+ "qualified_name" : entry .qualified_name ,
200+ }
201+ )
202+
203+ if local_list :
204+ for project in sorted (local_list .projects , key = lambda item : item .permalink ):
205+ if project .permalink in cloud_permalinks :
206+ continue
207+ merged .append (
208+ {
209+ "name" : project .name ,
210+ "path" : project .path ,
211+ "local_path" : project .path ,
212+ "cloud_path" : None ,
213+ "source" : "local" ,
214+ "is_default" : project .is_default ,
215+ "is_private" : project .is_private ,
216+ "display_name" : project .display_name ,
217+ "workspace_name" : None ,
218+ "workspace_type" : None ,
219+ "workspace_tenant_id" : None ,
220+ "workspace_slug" : None ,
221+ "workspace_is_default" : False ,
222+ "qualified_name" : None ,
223+ }
224+ )
225+
226+ return merged
227+
228+
135229def _format_project_list_text (merged : list [dict ]) -> str :
136230 """Format merged project list as human-readable text."""
137231 result = "Available projects:\n "
232+
233+ current_workspace : tuple [str | None , str | None ] | None = None
138234 for project in merged :
235+ workspace_slug = project .get ("workspace_slug" )
236+ workspace_name = project .get ("workspace_name" )
237+ if workspace_slug :
238+ workspace_key = (workspace_slug , workspace_name )
239+ if workspace_key != current_workspace :
240+ default_label = " default" if project .get ("workspace_is_default" ) else ""
241+ result += f"\n Workspace: { workspace_name } ({ workspace_slug } { default_label } )\n "
242+ current_workspace = workspace_key
243+ elif current_workspace is not None :
244+ result += "\n Local projects:\n "
245+ current_workspace = None
246+
139247 display_name = project ["display_name" ]
140248 name = project ["name" ]
141249 label = f"{ display_name } ({ name } )" if display_name else name
142250 source = project ["source" ]
143- result += f"• { label } ({ source } )\n "
251+ qualified_name = project .get ("qualified_name" )
252+ qualified_suffix = f" [{ qualified_name } ]" if qualified_name else ""
253+ result += f"• { label } ({ source } ){ qualified_suffix } \n "
144254
145255 result += "\n " + "─" * 40 + "\n "
146256 result += "Next: Ask which project to use for this session.\n "
@@ -248,39 +358,49 @@ async def list_memory_projects(
248358
249359 # Fetch cloud projects when credentials are available
250360 cloud_list : ProjectList | None = None
361+ cloud_entries : tuple [WorkspaceProjectEntry , ...] = ()
251362 cloud_ws_name : str | None = None
252363 cloud_ws_type : str | None = None
253364 cloud_ws_tenant_id : str | None = None
365+ cloud_ws_slug : str | None = None
366+ cloud_ws_is_default = False
254367 config = ConfigManager ().config
255368 if has_cloud_credentials (config ):
256- # Use explicit workspace, fall back to config default
257- effective_workspace = workspace or config .default_workspace
258- cloud_list = await _fetch_cloud_projects (effective_workspace , context )
259-
260- # Resolve workspace metadata so each cloud project carries its workspace info
261- if cloud_list :
262- cloud_ws_tenant_id = effective_workspace
369+ if workspace :
370+ active_workspace = await resolve_workspace_parameter (workspace , context )
371+ cloud_list = await _fetch_cloud_projects (active_workspace .tenant_id , context )
372+ cloud_ws_name = active_workspace .name
373+ cloud_ws_type = active_workspace .workspace_type
374+ cloud_ws_tenant_id = active_workspace .tenant_id
375+ cloud_ws_slug = active_workspace .slug
376+ cloud_ws_is_default = active_workspace .is_default
377+ else :
263378 try :
264- from basic_memory . mcp . project_context import get_available_workspaces
265-
266- workspaces = await get_available_workspaces ( context )
267- matched = next (
268- ( ws for ws in workspaces if ws . tenant_id == effective_workspace ),
269- None ,
379+ workspace_index = await _ensure_workspace_project_index ( context = context )
380+ cloud_entries = workspace_index . entries
381+ except Exception as exc :
382+ logger . warning (
383+ f"Cloud workspace project index discovery failed while listing projects; "
384+ f"showing local-only project list: { exc } "
270385 )
271- if matched :
272- cloud_ws_name = matched .name
273- cloud_ws_type = matched .workspace_type
274- except Exception :
275- pass # workspace lookup is best-effort
276-
277- merged = _merge_projects (
278- local_list ,
279- cloud_list ,
280- cloud_workspace_name = cloud_ws_name ,
281- cloud_workspace_type = cloud_ws_type ,
282- cloud_workspace_tenant_id = cloud_ws_tenant_id ,
283- )
386+ if context : # pragma: no cover
387+ await context .info (
388+ "Cloud workspace project discovery failed while listing projects; "
389+ "showing local projects only"
390+ )
391+
392+ if cloud_entries :
393+ merged = _merge_workspace_projects (local_list , cloud_entries )
394+ else :
395+ merged = _merge_projects (
396+ local_list ,
397+ cloud_list ,
398+ cloud_workspace_name = cloud_ws_name ,
399+ cloud_workspace_type = cloud_ws_type ,
400+ cloud_workspace_tenant_id = cloud_ws_tenant_id ,
401+ cloud_workspace_slug = cloud_ws_slug ,
402+ cloud_workspace_is_default = cloud_ws_is_default ,
403+ )
284404 default_project = local_list .default_project
285405
286406 if output_format == "json" :
@@ -390,6 +510,9 @@ async def create_memory_project(
390510 )
391511
392512 status_response = await project_client .create_project (project_request .model_dump ())
513+ from basic_memory .mcp .project_context import invalidate_workspace_project_index
514+
515+ await invalidate_workspace_project_index (context )
393516
394517 if output_format == "json" :
395518 new_project = status_response .new_project
@@ -479,6 +602,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
479602
480603 # Delete project using project external_id
481604 status_response = await project_client .delete_project (target_project .external_id )
605+ from basic_memory .mcp .project_context import invalidate_workspace_project_index
606+
607+ await invalidate_workspace_project_index (context )
482608
483609 result = f"✓ { status_response .message } \n \n "
484610
0 commit comments