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 "
@@ -207,6 +317,8 @@ async def list_memory_projects(
207317 cloud_ws_name : str | None = None
208318 cloud_ws_type : str | None = None
209319 cloud_ws_tenant_id : str | None = None
320+ cloud_ws_slug : str | None = None
321+ cloud_ws_is_default = False
210322 try :
211323 from basic_memory .mcp .project_context import get_available_workspaces
212324
@@ -222,6 +334,8 @@ async def list_memory_projects(
222334 cloud_ws_name = matched .name
223335 cloud_ws_type = matched .workspace_type
224336 cloud_ws_tenant_id = matched .tenant_id
337+ cloud_ws_slug = matched .slug
338+ cloud_ws_is_default = matched .is_default
225339 except Exception :
226340 pass # workspace lookup is best-effort
227341
@@ -231,6 +345,8 @@ async def list_memory_projects(
231345 cloud_workspace_name = cloud_ws_name ,
232346 cloud_workspace_type = cloud_ws_type ,
233347 cloud_workspace_tenant_id = cloud_ws_tenant_id ,
348+ cloud_workspace_slug = cloud_ws_slug ,
349+ cloud_workspace_is_default = cloud_ws_is_default ,
234350 )
235351 if output_format == "json" :
236352 return _format_project_list_json (
@@ -248,39 +364,63 @@ async def list_memory_projects(
248364
249365 # Fetch cloud projects when credentials are available
250366 cloud_list : ProjectList | None = None
367+ cloud_entries : tuple [WorkspaceProjectEntry , ...] = ()
251368 cloud_ws_name : str | None = None
252369 cloud_ws_type : str | None = None
253370 cloud_ws_tenant_id : str | None = None
371+ cloud_ws_slug : str | None = None
372+ cloud_ws_is_default = False
254373 config = ConfigManager ().config
255374 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
375+ if workspace :
263376 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 ,
377+ active_workspace = await resolve_workspace_parameter ( workspace , context )
378+ except Exception as exc :
379+ logger . warning (
380+ f"Cloud workspace discovery failed while listing projects for "
381+ f"workspace ' { workspace } '; trying direct workspace routing before "
382+ f"falling back to local-only project list: { exc } "
270383 )
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- )
384+ if context : # pragma: no cover
385+ await context .info (
386+ "Cloud workspace discovery failed while listing projects; "
387+ "trying direct workspace routing"
388+ )
389+ cloud_list = await _fetch_cloud_projects (workspace , context )
390+ else :
391+ cloud_list = await _fetch_cloud_projects (active_workspace .tenant_id , context )
392+ cloud_ws_name = active_workspace .name
393+ cloud_ws_type = active_workspace .workspace_type
394+ cloud_ws_tenant_id = active_workspace .tenant_id
395+ cloud_ws_slug = active_workspace .slug
396+ cloud_ws_is_default = active_workspace .is_default
397+ else :
398+ try :
399+ workspace_index = await ensure_workspace_project_index (context = context )
400+ cloud_entries = workspace_index .entries
401+ except Exception as exc :
402+ logger .warning (
403+ f"Cloud workspace project index discovery failed while listing projects; "
404+ f"showing local-only project list: { exc } "
405+ )
406+ if context : # pragma: no cover
407+ await context .info (
408+ "Cloud workspace project discovery failed while listing projects; "
409+ "showing local projects only"
410+ )
411+
412+ if cloud_entries :
413+ merged = _merge_workspace_projects (local_list , cloud_entries )
414+ else :
415+ merged = _merge_projects (
416+ local_list ,
417+ cloud_list ,
418+ cloud_workspace_name = cloud_ws_name ,
419+ cloud_workspace_type = cloud_ws_type ,
420+ cloud_workspace_tenant_id = cloud_ws_tenant_id ,
421+ cloud_workspace_slug = cloud_ws_slug ,
422+ cloud_workspace_is_default = cloud_ws_is_default ,
423+ )
284424 default_project = local_list .default_project
285425
286426 if output_format == "json" :
@@ -390,6 +530,9 @@ async def create_memory_project(
390530 )
391531
392532 status_response = await project_client .create_project (project_request .model_dump ())
533+ from basic_memory .mcp .project_context import invalidate_workspace_project_index
534+
535+ await invalidate_workspace_project_index (context )
393536
394537 if output_format == "json" :
395538 new_project = status_response .new_project
@@ -479,6 +622,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
479622
480623 # Delete project using project external_id
481624 status_response = await project_client .delete_project (target_project .external_id )
625+ from basic_memory .mcp .project_context import invalidate_workspace_project_index
626+
627+ await invalidate_workspace_project_index (context )
482628
483629 result = f"✓ { status_response .message } \n \n "
484630
0 commit comments