|
9 | 9 |
|
10 | 10 | from basic_memory.config import ConfigManager |
11 | 11 | from basic_memory.mcp.project_context import ( |
| 12 | + _cloud_workspace_discovery_available, |
12 | 13 | detect_project_from_memory_url_prefix, |
13 | 14 | get_project_client, |
14 | 15 | add_project_metadata, |
|
17 | 18 | from basic_memory.mcp.server import mcp |
18 | 19 | from basic_memory.schemas.base import Entity |
19 | 20 | from basic_memory.schemas.response import EntityResponse |
20 | | -from basic_memory.utils import validate_project_path |
| 21 | +from basic_memory.services.link_resolver import ( |
| 22 | + detect_project_from_workspace_identifier_prefix, |
| 23 | + is_workspace_qualified_plain_identifier, |
| 24 | +) |
| 25 | +from basic_memory.utils import normalize_project_reference, validate_project_path |
21 | 26 |
|
22 | 27 |
|
23 | 28 | def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]: |
@@ -47,6 +52,58 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str] |
47 | 52 | return title, directory |
48 | 53 |
|
49 | 54 |
|
| 55 | +def _compose_workspace_project_route( |
| 56 | + *, |
| 57 | + workspace: Optional[str], |
| 58 | + project: Optional[str], |
| 59 | + project_id: Optional[str], |
| 60 | +) -> Optional[str]: |
| 61 | + """Return the explicit project route requested by workspace/project args.""" |
| 62 | + if workspace is None: |
| 63 | + return project |
| 64 | + |
| 65 | + cleaned_workspace = workspace.strip().strip("/") |
| 66 | + if not cleaned_workspace: |
| 67 | + raise ValueError("workspace must not be empty when provided") |
| 68 | + if "/" in cleaned_workspace: |
| 69 | + raise ValueError("workspace must be a single workspace slug, name, or tenant_id") |
| 70 | + if project_id is not None: |
| 71 | + raise ValueError("workspace cannot be combined with project_id; use project_id alone") |
| 72 | + if project is None or not project.strip().strip("/"): |
| 73 | + raise ValueError("workspace requires an explicit project argument") |
| 74 | + |
| 75 | + cleaned_project = project.strip().strip("/") |
| 76 | + if "/" in cleaned_project: |
| 77 | + raise ValueError( |
| 78 | + "Use either workspace='workspace' with project='project', " |
| 79 | + "or project='workspace/project', not both" |
| 80 | + ) |
| 81 | + return f"{cleaned_workspace}/{cleaned_project}" |
| 82 | + |
| 83 | + |
| 84 | +def _format_ambiguous_workspace_identifier_response( |
| 85 | + *, |
| 86 | + identifier: str, |
| 87 | + detected_project: str, |
| 88 | +) -> str: |
| 89 | + """Format the safe-stop response for ambiguous plain write identifiers.""" |
| 90 | + cleaned_identifier = identifier.strip() |
| 91 | + normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/") |
| 92 | + workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2) |
| 93 | + |
| 94 | + return f"""# Edit Failed - Ambiguous Identifier |
| 95 | +
|
| 96 | +`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`. |
| 97 | +
|
| 98 | +Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path. |
| 99 | +
|
| 100 | +Retry with one of these explicit routes: |
| 101 | +- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)` |
| 102 | +- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)` |
| 103 | +- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)` |
| 104 | +- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`""" |
| 105 | + |
| 106 | + |
50 | 107 | def _format_error_response( |
51 | 108 | error_message: str, |
52 | 109 | operation: str, |
@@ -181,6 +238,7 @@ async def edit_note( |
181 | 238 | ), |
182 | 239 | ], |
183 | 240 | project: Optional[str] = None, |
| 241 | + workspace: Optional[str] = None, |
184 | 242 | project_id: Optional[str] = None, |
185 | 243 | # Section/heading naming varies across tools; accept the descriptive forms. |
186 | 244 | section: Annotated[ |
@@ -224,7 +282,10 @@ async def edit_note( |
224 | 282 | - "insert_after_section": Insert content after a section heading without consuming it (note must exist) |
225 | 283 | content: The content to add or use for replacement |
226 | 284 | project: Project name to edit in. Optional - server will resolve using hierarchy. |
| 285 | + Use "workspace/project" to route to a project in a specific cloud workspace. |
227 | 286 | If unknown, use list_memory_projects() to discover available projects. |
| 287 | + workspace: Workspace slug, name, or tenant_id. When provided with `project`, |
| 288 | + routes as `workspace/project`. Cannot be combined with `project_id`. |
228 | 289 | project_id: Project external_id (UUID). Prefer this over `project` when known — |
229 | 290 | it routes to the exact project regardless of name collisions across cloud |
230 | 291 | workspaces. Takes precedence over `project`. Get from list_memory_projects(). |
@@ -287,18 +348,52 @@ async def edit_note( |
287 | 348 | """ |
288 | 349 | # Resolve effective default: allow MCP clients to send null for optional int field |
289 | 350 | effective_replacements = expected_replacements if expected_replacements is not None else 1 |
290 | | - |
291 | | - # Detect project from memory URL prefix before routing |
292 | | - # Trigger: identifier starts with memory:// and no explicit project/project_id was provided |
293 | | - # Why: only gate on memory:// to avoid misrouting plain paths like "research/note" |
294 | | - # where "research" is a directory, not a project name |
295 | | - # Outcome: project is set from the URL prefix, routing goes to the correct project |
296 | | - if project is None and project_id is None and identifier.strip().startswith("memory://"): |
297 | | - detected = await detect_project_from_memory_url_prefix( |
298 | | - identifier, |
299 | | - ConfigManager().config, |
300 | | - context=context, |
301 | | - ) |
| 351 | + project = _compose_workspace_project_route( |
| 352 | + workspace=workspace, |
| 353 | + project=project, |
| 354 | + project_id=project_id, |
| 355 | + ) |
| 356 | + |
| 357 | + # Resolve or reject routable identifier prefixes before selecting a client. |
| 358 | + # Trigger: no explicit project/project_id was provided. |
| 359 | + # Why: memory:// URLs are explicit routes, but plain three-segment identifiers |
| 360 | + # are ambiguous for a mutating tool. |
| 361 | + # Outcome: memory:// can route; plain workspace/project/path matches stop with |
| 362 | + # guidance instead of silently editing another project. |
| 363 | + if project is None and project_id is None: |
| 364 | + config = ConfigManager().config |
| 365 | + if identifier.strip().startswith("memory://"): |
| 366 | + detected = await detect_project_from_memory_url_prefix( |
| 367 | + identifier, |
| 368 | + config, |
| 369 | + context=context, |
| 370 | + ) |
| 371 | + elif _cloud_workspace_discovery_available( |
| 372 | + config |
| 373 | + ) and is_workspace_qualified_plain_identifier(identifier): |
| 374 | + detected = await detect_project_from_workspace_identifier_prefix( |
| 375 | + identifier, |
| 376 | + config, |
| 377 | + context=context, |
| 378 | + ) |
| 379 | + if detected: |
| 380 | + if output_format == "json": |
| 381 | + return { |
| 382 | + "title": None, |
| 383 | + "permalink": None, |
| 384 | + "file_path": None, |
| 385 | + "checksum": None, |
| 386 | + "operation": operation, |
| 387 | + "fileCreated": False, |
| 388 | + "error": "AMBIGUOUS_IDENTIFIER", |
| 389 | + "project": detected, |
| 390 | + } |
| 391 | + return _format_ambiguous_workspace_identifier_response( |
| 392 | + identifier=identifier, |
| 393 | + detected_project=detected, |
| 394 | + ) |
| 395 | + else: |
| 396 | + detected = None |
302 | 397 | if detected: |
303 | 398 | project = detected |
304 | 399 |
|
|
0 commit comments