|
10 | 10 | from fastmcp import Context |
11 | 11 | from loguru import logger |
12 | 12 |
|
13 | | -from basic_memory.config import ConfigManager, has_cloud_credentials |
| 13 | +from basic_memory.config import ( |
| 14 | + BasicMemoryConfig, |
| 15 | + ConfigManager, |
| 16 | + ProjectEntry, |
| 17 | + has_cloud_credentials, |
| 18 | +) |
14 | 19 | from basic_memory.mcp.async_client import ( |
15 | 20 | _explicit_routing, |
16 | 21 | _force_local_mode, |
@@ -131,30 +136,102 @@ def _merge_projects( |
131 | 136 | return merged |
132 | 137 |
|
133 | 138 |
|
| 139 | +def _workspace_entry_priority(entry: WorkspaceProjectEntry) -> tuple[bool, int, str, str]: |
| 140 | + """Prefer default/personal workspaces when duplicate project permalinks exist.""" |
| 141 | + workspace_type_rank = 0 if entry.workspace.workspace_type == "personal" else 1 |
| 142 | + return ( |
| 143 | + not entry.workspace.is_default, |
| 144 | + workspace_type_rank, |
| 145 | + entry.workspace.name.casefold(), |
| 146 | + entry.workspace.tenant_id, |
| 147 | + ) |
| 148 | + |
| 149 | + |
| 150 | +def _select_attached_cloud_entry( |
| 151 | + cloud_entries: tuple[WorkspaceProjectEntry, ...], |
| 152 | + *, |
| 153 | + config_entry: ProjectEntry | None, |
| 154 | + config: BasicMemoryConfig | None, |
| 155 | +) -> WorkspaceProjectEntry | None: |
| 156 | + """Choose the single cloud row that should inherit local project state.""" |
| 157 | + if not cloud_entries: |
| 158 | + return None |
| 159 | + |
| 160 | + preferred_workspace_ids: list[str] = [] |
| 161 | + if config_entry and config_entry.workspace_id: |
| 162 | + preferred_workspace_ids.append(config_entry.workspace_id) |
| 163 | + if ( |
| 164 | + config |
| 165 | + and config.default_workspace |
| 166 | + and config.default_workspace not in preferred_workspace_ids |
| 167 | + ): |
| 168 | + preferred_workspace_ids.append(config.default_workspace) |
| 169 | + |
| 170 | + default_workspace_entry = next( |
| 171 | + (entry for entry in cloud_entries if entry.workspace.is_default), |
| 172 | + None, |
| 173 | + ) |
| 174 | + if ( |
| 175 | + default_workspace_entry is not None |
| 176 | + and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids |
| 177 | + ): |
| 178 | + preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id) |
| 179 | + |
| 180 | + for workspace_id in preferred_workspace_ids: |
| 181 | + for entry in cloud_entries: |
| 182 | + if entry.workspace.tenant_id == workspace_id: |
| 183 | + return entry |
| 184 | + |
| 185 | + if len(cloud_entries) == 1: |
| 186 | + return cloud_entries[0] |
| 187 | + |
| 188 | + return sorted(cloud_entries, key=_workspace_entry_priority)[0] |
| 189 | + |
| 190 | + |
134 | 191 | def _merge_workspace_projects( |
135 | 192 | local_list: ProjectList | None, |
136 | 193 | cloud_entries: tuple[WorkspaceProjectEntry, ...], |
| 194 | + *, |
| 195 | + config: BasicMemoryConfig | None = None, |
137 | 196 | ) -> list[dict]: |
138 | 197 | """Merge local projects with cloud projects from every accessible workspace.""" |
139 | 198 | local_by_permalink: dict[str, ProjectItem] = {} |
140 | 199 | if local_list: |
141 | 200 | for project in local_list.projects: |
142 | 201 | local_by_permalink[project.permalink] = project |
143 | 202 |
|
| 203 | + config_by_permalink: dict[str, ProjectEntry] = {} |
| 204 | + if config: |
| 205 | + config_by_permalink = { |
| 206 | + generate_permalink(project_name): entry |
| 207 | + for project_name, entry in config.projects.items() |
| 208 | + } |
| 209 | + |
| 210 | + cloud_entries_by_permalink: dict[str, list[WorkspaceProjectEntry]] = {} |
| 211 | + for entry in cloud_entries: |
| 212 | + cloud_entries_by_permalink.setdefault(entry.project.permalink, []).append(entry) |
| 213 | + |
| 214 | + attached_entry_by_permalink: dict[str, WorkspaceProjectEntry | None] = {} |
| 215 | + for permalink in local_by_permalink: |
| 216 | + attached_entry_by_permalink[permalink] = _select_attached_cloud_entry( |
| 217 | + tuple(cloud_entries_by_permalink.get(permalink, ())), |
| 218 | + config_entry=config_by_permalink.get(permalink), |
| 219 | + config=config, |
| 220 | + ) |
| 221 | + |
144 | 222 | cloud_permalinks = {entry.project.permalink for entry in cloud_entries} |
145 | 223 | merged: list[dict] = [] |
146 | 224 |
|
147 | 225 | for entry in sorted( |
148 | 226 | cloud_entries, |
149 | | - key=lambda item: ( |
150 | | - not item.workspace.is_default, |
151 | | - item.workspace.workspace_type != "personal", |
152 | | - item.workspace.name.casefold(), |
153 | | - item.project.permalink, |
154 | | - ), |
| 227 | + key=lambda item: (*_workspace_entry_priority(item), item.project.permalink), |
155 | 228 | ): |
156 | 229 | permalink = entry.project.permalink |
157 | | - local_proj = local_by_permalink.get(permalink) |
| 230 | + local_proj = ( |
| 231 | + local_by_permalink.get(permalink) |
| 232 | + if attached_entry_by_permalink.get(permalink) == entry |
| 233 | + else None |
| 234 | + ) |
158 | 235 | cloud_proj = entry.project |
159 | 236 | source = "local+cloud" if local_proj else "cloud" |
160 | 237 | local_path = local_proj.path if local_proj else None |
@@ -339,7 +416,7 @@ async def list_memory_projects( |
339 | 416 | ) |
340 | 417 |
|
341 | 418 | if cloud_entries: |
342 | | - merged = _merge_workspace_projects(local_list, cloud_entries) |
| 419 | + merged = _merge_workspace_projects(local_list, cloud_entries, config=config) |
343 | 420 | else: |
344 | 421 | merged = _merge_projects( |
345 | 422 | local_list, |
|
0 commit comments