fix(mcp): route workspace-qualified memory urls (#790)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-03 18:18:10 -05:00
committed by GitHub
parent 0a72d81bb3
commit 05adda1502
20 changed files with 1931 additions and 59 deletions
+37 -1
View File
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import JSONResponse
from fastapi.routing import APIRouter
from loguru import logger
@@ -29,6 +30,12 @@ import logfire
from basic_memory.config import init_api_logging
from basic_memory.services.exceptions import EntityAlreadyExistsError
from basic_memory.services.initialization import initialize_app
from basic_memory.workspace_context import (
WORKSPACE_SLUG_HEADER,
WORKSPACE_TYPE_HEADER,
workspace_permalink_context_validation_error,
workspace_permalink_context,
)
@asynccontextmanager
@@ -87,6 +94,32 @@ app = FastAPI(
lifespan=lifespan,
)
@app.middleware("http")
async def workspace_permalink_context_middleware(request: Request, call_next):
"""Populate workspace permalink context from request headers."""
workspace_slug = request.headers.get(WORKSPACE_SLUG_HEADER)
workspace_type = request.headers.get(WORKSPACE_TYPE_HEADER)
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
if validation_error is not None:
return JSONResponse(
status_code=400,
content={"detail": validation_error},
)
if not workspace_slug:
return await call_next(request)
# ContextVar state remains active across the awaited downstream handler while
# this context manager is open, so entity creation can see request metadata.
with workspace_permalink_context(
workspace_slug=workspace_slug,
workspace_type=workspace_type,
):
return await call_next(request)
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
@@ -146,4 +179,7 @@ async def exception_handler(request, exc): # pragma: no cover
error_type=type(exc).__name__,
error=str(exc),
)
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
return await http_exception_handler(
request,
HTTPException(status_code=500, detail="Internal server error"),
)
+3
View File
@@ -94,9 +94,12 @@ async def _cloud_client(
workspace: Optional[str] = None,
) -> AsyncIterator[AsyncClient]:
"""Create a cloud proxy client with resolved credentials."""
from basic_memory.workspace_context import workspace_permalink_headers
token = await _resolve_cloud_token(config)
proxy_base_url = f"{config.cloud_host}/proxy"
headers = {"Authorization": f"Bearer {token}"}
headers.update(workspace_permalink_headers())
if workspace:
headers["X-Workspace-ID"] = workspace
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
+282 -8
View File
@@ -9,7 +9,7 @@ compatibility with existing MCP tools.
"""
import asyncio
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, nullcontext
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
from uuid import UUID
@@ -30,6 +30,10 @@ from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.schemas.v2 import ProjectResolveResponse
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import generate_permalink, normalize_project_reference
from basic_memory.workspace_context import (
current_workspace_permalink_context,
workspace_permalink_context,
)
# --- Workspace provider injection ---
# Mirrors the set_client_factory() pattern in async_client.py.
@@ -62,6 +66,18 @@ class WorkspaceProjectIndex:
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
@dataclass(frozen=True)
class WorkspaceMemoryUrlResolution:
"""Resolved workspace/project route for a workspace-qualified memory URL."""
entry: WorkspaceProjectEntry
canonical_path: str
@property
def project_identifier(self) -> str:
return self.entry.qualified_name
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
"""Override workspace discovery (for cloud app, testing, etc)."""
global _workspace_provider
@@ -390,6 +406,130 @@ def _unqualified_project_identifier(identifier: str) -> str:
return project_identifier
def _split_workspace_memory_url_segments(identifier: str) -> tuple[str, str, str] | None:
"""Split ``memory://<workspace>/<project>/<path>`` into route segments."""
if not identifier.strip().startswith("memory://"):
return None
normalized = normalize_project_reference(memory_url_path(identifier))
parts = normalized.split("/", 2)
if len(parts) != 3:
return None
workspace_slug, project_identifier, remainder = parts
if not workspace_slug or not project_identifier or not remainder:
return None
return workspace_slug, project_identifier, remainder
def _canonical_memory_path_for_workspace(
*,
workspace_slug: str,
workspace_type: str,
project_permalink: str,
remainder: str,
include_project: bool,
) -> str:
"""Return the stored canonical path for a workspace-qualified memory URL."""
normalized_remainder = remainder.strip("/")
if workspace_type == "organization":
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
elif workspace_type == "personal":
prefix = project_permalink if include_project else ""
else:
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
if not prefix:
return normalized_remainder
if not normalized_remainder:
return prefix
return f"{prefix}/{normalized_remainder}"
def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
"""Return True when workspace discovery can be used without forcing local routing."""
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
is_factory_mode,
)
if _explicit_routing() and _force_local_mode():
return False
# Trigger: local project config is present even though cloud credentials are saved.
# Why: existing local `memory://...` URLs must not depend on workspace discovery.
# Outcome: only factory, explicit cloud, or cloud-only sessions attempt discovery here.
return (
is_factory_mode()
or (_explicit_routing() and not _force_local_mode())
or (not config.projects and has_cloud_credentials(config))
)
async def resolve_workspace_qualified_memory_url(
identifier: str,
context: Optional[Context] = None,
) -> WorkspaceMemoryUrlResolution | None:
"""Resolve a workspace-qualified memory URL against accessible workspaces."""
segments = _split_workspace_memory_url_segments(identifier)
if segments is None:
return None
workspace_slug, project_identifier, remainder = segments
index = await _ensure_workspace_project_index(context=context)
workspace = next(
(item for item in index.workspaces if item.slug.casefold() == workspace_slug.casefold()),
None,
)
if workspace is None:
return None
project_permalink = generate_permalink(project_identifier)
matches = [
entry
for entry in index.entries_by_permalink.get(project_permalink, ())
if entry.workspace.tenant_id == workspace.tenant_id
]
if not matches:
if any(
failed_workspace.tenant_id == workspace.tenant_id
for failed_workspace in index.failed_workspaces
):
raise ValueError(
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
"could not be loaded. Retry after workspace discovery recovers."
)
# Trigger: first segment matches a workspace slug but the second does not
# match a project in that workspace.
# Why: workspace-qualified URLs require both route segments to match; otherwise
# existing project-prefixed URLs like `memory://main/notes/foo` can collide
# with a workspace slug named `main`.
# Outcome: treat this as not workspace-qualified and let the caller use
# the existing project-prefix/default-project resolver.
return None
if len(matches) > 1:
details = ", ".join(
f"{entry.qualified_name} ({entry.project.external_id})" for entry in matches
)
raise ValueError(
f"Project '{project_identifier}' matched multiple projects in workspace "
f"'{workspace.name}' ({workspace.slug}). Project permalinks must be unique. "
f"Matches: {details}"
)
entry = matches[0]
canonical_path = _canonical_memory_path_for_workspace(
workspace_slug=entry.workspace.slug,
workspace_type=entry.workspace.workspace_type,
project_permalink=entry.project.permalink,
remainder=remainder,
include_project=ConfigManager().config.permalinks_include_project,
)
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
"""Format qualified project choices for collision errors."""
return " or ".join(entry.qualified_name for entry in entries)
@@ -608,6 +748,15 @@ async def resolve_workspace_project_identifier(
f"Project '{project_identifier}' was not found in workspace "
f"'{workspace.name}' ({workspace.slug}). Available projects: {available}"
)
if len(matches) > 1:
details = ", ".join(
f"{entry.qualified_name} ({entry.project.external_id})" for entry in matches
)
raise ValueError(
f"Project '{project_identifier}' matched multiple projects in workspace "
f"'{workspace.name}' ({workspace.slug}). Project permalinks must be unique. "
f"Matches: {details}"
)
return matches[0]
matches = index.entries_by_permalink.get(project_permalink, ())
@@ -680,6 +829,56 @@ async def _default_workspace_project_entry(
return default_entries[0] if default_entries else None
async def _workspace_metadata_by_tenant_id(
tenant_id: str,
context: Optional[Context] = None,
) -> WorkspaceInfo | None:
"""Return non-index workspace metadata for a configured tenant id."""
cached_workspace = await _get_cached_active_workspace(context)
if cached_workspace and cached_workspace.tenant_id == tenant_id:
return cached_workspace
if cached_workspace and context:
# Trigger: the configured workspace_id differs from cached workspace metadata.
# Why: tenant_id routes the request, but stale workspace slug/type would corrupt
# memory URL normalization and canonical permalink headers.
# Outcome: drop stale metadata and route without permalink decoration.
await context.set_state("active_workspace", None)
if context:
cached_raw = await context.get_state("available_workspaces")
if isinstance(cached_raw, list):
for item in cached_raw:
if not isinstance(item, dict):
continue
workspace = WorkspaceInfo.model_validate(item)
if workspace.tenant_id == tenant_id:
return workspace
if _workspace_provider is not None:
# Trigger: the hosting runtime can provide workspace metadata directly.
# Why: configured workspace_id is already sufficient for tenant routing, but
# canonical organization permalinks also need slug/type context.
# Outcome: use the injected runtime seam without loading the workspace project index.
workspace = next(
(
workspace
for workspace in await get_available_workspaces(context=context)
if workspace.tenant_id == tenant_id
),
None,
)
if workspace is None:
raise ValueError(
f"Configured workspace_id '{tenant_id}' was not returned by the workspace "
"metadata provider. Reconfigure the project workspace or retry after "
"workspace metadata recovers."
)
return workspace
return None
async def resolve_workspace_parameter(
workspace: Optional[str] = None,
context: Optional[Context] = None,
@@ -856,13 +1055,57 @@ async def resolve_project_and_path(
return active_project, identifier, False
normalized_path = normalize_project_reference(memory_url_path(identifier))
cached_project = await _get_cached_active_project(context)
cached_workspace = await _get_cached_active_workspace(context)
if cached_project and cached_workspace:
workspace_prefix = generate_permalink(cached_workspace.slug)
qualified_prefix = f"{workspace_prefix}/{cached_project.permalink}"
if normalized_path == qualified_prefix or normalized_path.startswith(
f"{qualified_prefix}/"
):
remainder = (
""
if normalized_path == qualified_prefix
else normalized_path.removeprefix(f"{qualified_prefix}/")
)
resolved_path = _canonical_memory_path_for_workspace(
workspace_slug=cached_workspace.slug,
workspace_type=cached_workspace.workspace_type,
project_permalink=cached_project.permalink,
remainder=remainder,
include_project=bool(include_project),
)
return cached_project, resolved_path, True
workspace_context = current_workspace_permalink_context()
if workspace_context and project:
workspace_prefix = generate_permalink(workspace_context.workspace_slug)
project_permalink = generate_permalink(_unqualified_project_identifier(project))
qualified_prefix = f"{workspace_prefix}/{project_permalink}"
if normalized_path == qualified_prefix or normalized_path.startswith(
f"{qualified_prefix}/"
):
active_project = await get_active_project(client, project, context, headers)
remainder = (
""
if normalized_path == qualified_prefix
else normalized_path.removeprefix(f"{qualified_prefix}/")
)
resolved_path = _canonical_memory_path_for_workspace(
workspace_slug=workspace_context.workspace_slug,
workspace_type=workspace_context.workspace_type,
project_permalink=project_permalink,
remainder=remainder,
include_project=bool(include_project),
)
return active_project, resolved_path, True
project_prefix, remainder = _split_project_prefix(normalized_path)
include_project = config.permalinks_include_project
# Trigger: memory URL begins with a potential project segment
# Why: allow project-scoped memory URLs without requiring a separate project parameter
# Outcome: attempt to resolve the prefix as a project and route to it
if project_prefix:
cached_project = await _get_cached_active_project(context)
if cached_project and _project_matches_identifier(cached_project, project_prefix):
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
@@ -975,6 +1218,30 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
return None
async def detect_project_from_memory_url_prefix(
identifier: str,
config: BasicMemoryConfig,
context: Optional[Context] = None,
) -> Optional[str]:
"""Resolve a project from a memory URL prefix, including workspace-qualified URLs."""
if not identifier.strip().startswith("memory://"):
return None
local_project = detect_project_from_url_prefix(identifier, config)
if local_project is not None:
return local_project
if _cloud_workspace_discovery_available(config):
resolution = await resolve_workspace_qualified_memory_url(
identifier,
context=context,
)
if resolution is not None:
return resolution.project_identifier
return None
@asynccontextmanager
async def get_project_client(
project: Optional[str] = None,
@@ -1091,6 +1358,7 @@ async def get_project_client(
if project_entry and project_entry.workspace_id:
# Per-project config stores the cloud tenant id directly
workspace_id = project_entry.workspace_id
active_ws = await _workspace_metadata_by_tenant_id(workspace_id, context=context)
else:
resolved_entry = cloud_default_entry
if resolved_entry is None or not _project_matches_identifier(
@@ -1113,12 +1381,18 @@ async def get_project_client(
workspace_id=workspace_id,
):
logger.debug("Using resolved workspace for cloud project routing")
async with get_client(
project_name=project_for_api,
workspace=workspace_id,
) as client:
active_project = await get_active_project(client, project_for_api, context)
yield client, active_project
permalink_context = (
workspace_permalink_context(active_ws.slug, active_ws.workspace_type)
if active_ws is not None
else nullcontext()
)
with permalink_context:
async with get_client(
project_name=project_for_api,
workspace=workspace_id,
) as client:
active_project = await get_active_project(client, project_for_api, context)
yield client, active_project
return
# Step 4: Local routing (default)
+9 -4
View File
@@ -9,7 +9,7 @@ from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -209,9 +209,14 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(url, ConfigManager().config)
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
detected = await detect_project_from_memory_url_prefix(
url,
ConfigManager().config,
context=context,
)
if detected:
project = detected
+62 -6
View File
@@ -7,8 +7,15 @@ from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
from basic_memory.mcp.project_context import (
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import generate_permalink, normalize_project_reference
from basic_memory.workspace_context import current_workspace_permalink_context
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -148,6 +155,32 @@ delete_note("{project}", "correct-identifier-from-search")
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
def _directory_path_for_delete(
target_identifier: str,
active_project: ProjectItem,
*,
include_project_prefix: bool,
) -> str:
"""Return the project-relative directory path expected by the delete API."""
directory = normalize_project_reference(target_identifier).strip("/")
project_permalink = active_project.permalink
route_prefixes: list[str] = []
workspace_context = current_workspace_permalink_context()
if workspace_context and workspace_context.should_prefix_permalinks:
route_prefixes.append(
f"{generate_permalink(workspace_context.workspace_slug)}/{project_permalink}"
)
if include_project_prefix:
route_prefixes.append(project_permalink)
for route_prefix in route_prefixes:
if directory.startswith(f"{route_prefix}/"):
return directory.removeprefix(f"{route_prefix}/")
return directory
@mcp.tool(
description="Delete a note or directory by title, permalink, or path",
annotations={"destructiveHint": True, "openWorldHint": False},
@@ -231,12 +264,16 @@ async def delete_note(
commands and alternative formats to try.
"""
# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project was provided
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and identifier.strip().startswith("memory://"):
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if project is None and project_id is None and identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
)
if detected:
project = detected
@@ -253,11 +290,30 @@ async def delete_note(
# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
_, target_identifier, is_memory_url = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
)
# Handle directory deletes
if is_directory:
try:
result = await knowledge_client.delete_directory(identifier)
# Trigger: directory input was routed from a memory:// URL.
# Why: resolve_project_and_path returns canonical permalinks, while
# delete_directory filters by project-relative file_path prefixes.
# Outcome: strip only the route prefix before calling the delete API.
directory_identifier = (
_directory_path_for_delete(
target_identifier,
active_project,
include_project_prefix=ConfigManager().config.permalinks_include_project,
)
if is_memory_url
else target_identifier
)
result = await knowledge_client.delete_directory(directory_identifier)
if output_format == "json":
return {
"deleted": result.failed_deletes == 0,
@@ -339,7 +395,7 @@ delete_note("path/to/file.md")
note_file_path = None
try:
# Resolve identifier to entity ID
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
if output_format == "json":
entity = await knowledge_client.get_entity(entity_id)
note_title = entity.title
+19 -5
View File
@@ -9,9 +9,10 @@ from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import Entity
@@ -288,12 +289,16 @@ async def edit_note(
effective_replacements = expected_replacements if expected_replacements is not None else 1
# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project was provided
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and identifier.strip().startswith("memory://"):
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
if project is None and project_id is None and identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
)
if detected:
project = detected
@@ -346,6 +351,12 @@ async def edit_note(
# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
_, entity_identifier, _ = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
)
file_created = False
entity_id = ""
@@ -353,7 +364,10 @@ async def edit_note(
# Try to resolve the entity; for append/prepend, create it if not found
try:
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
entity_id = await knowledge_client.resolve_entity(
entity_identifier,
strict=True,
)
except Exception as resolve_error:
# Trigger: entity does not exist yet
# Why: append/prepend can meaningfully create a new note from the content,
+9 -4
View File
@@ -18,7 +18,7 @@ from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -217,9 +217,14 @@ async def read_content(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal
"""
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(path, ConfigManager().config)
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
detected = await detect_project_from_memory_url_prefix(
path,
ConfigManager().config,
context=context,
)
if detected:
project = detected
+11 -6
View File
@@ -11,7 +11,7 @@ from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -33,10 +33,10 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
If parsing fails or frontmatter is not a mapping, returns body unchanged and None.
"""
original_content = content
if not content.startswith("---\n"):
lines = content.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
return original_content, None
lines = content.splitlines(keepends=True)
closing_index = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
@@ -130,9 +130,14 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
# Detect project from memory URL prefix before routing
if project is None:
detected = detect_project_from_url_prefix(identifier, ConfigManager().config)
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None:
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
)
if detected:
project = detected
+9 -4
View File
@@ -13,7 +13,7 @@ from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
detect_project_from_memory_url_prefix,
get_project_client,
resolve_project_and_path,
)
@@ -551,9 +551,14 @@ async def search_notes(
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
query = remainder or None
# Detect project from memory URL prefix before routing
if project is None and query is not None:
detected = detect_project_from_url_prefix(query, ConfigManager().config)
# Detect project from memory URL prefix before routing.
# project_id routes by external UUID, so it bypasses URL discovery entirely.
if project is None and project_id is None and query is not None:
detected = await detect_project_from_memory_url_prefix(
query,
ConfigManager().config,
context=context,
)
if detected:
project = detected
+11 -5
View File
@@ -48,6 +48,7 @@ from basic_memory.services.exceptions import (
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.utils import build_canonical_permalink
from basic_memory.workspace_context import workspace_slug_for_canonical_permalinks
@dataclass(frozen=True)
@@ -205,15 +206,20 @@ class EntityService(BaseService[EntityModel]):
if self.app_config:
include_project = self.app_config.permalinks_include_project
workspace_permalink = workspace_slug_for_canonical_permalinks()
project_permalink = None
# Trigger: project-prefixed permalinks are enabled
# Why: we need the project slug to build the canonical permalink
# Outcome: fetch and cache the project's permalink
if include_project:
# Trigger: project-prefixed permalinks are enabled, or organization workspace
# context requires a complete workspace/project canonical permalink.
# Why: project slug is the stable middle segment for globally addressable links.
# Outcome: fetch and cache the project's permalink before building the canonical URL.
if include_project or workspace_permalink:
project_permalink = await self._get_project_permalink()
desired_permalink = build_canonical_permalink(
project_permalink, file_path_str, include_project=include_project
project_permalink,
file_path_str,
include_project=include_project,
workspace_permalink=workspace_permalink,
)
# Make unique if needed - enhanced to handle character conflicts
+30 -3
View File
@@ -224,18 +224,43 @@ def build_canonical_permalink(
project_permalink: Optional[str],
file_path: Union[Path, str, PathLike],
include_project: bool = True,
*,
workspace_permalink: Optional[str] = None,
) -> str:
"""Build a canonical permalink, optionally prefixed with project slug.
"""Build a canonical permalink, optionally prefixed with workspace/project slugs.
Args:
project_permalink: URL-friendly project identifier (slug). If None, no prefix is added.
file_path: Original file path or permalink-like string.
include_project: When True, prefix with project slug.
workspace_permalink: Optional URL-friendly workspace identifier. When provided,
prefix the project-qualified permalink with this workspace slug.
Returns:
Canonical permalink string.
"""
normalized_path = generate_permalink(file_path)
normalized_workspace = generate_permalink(workspace_permalink) if workspace_permalink else None
if normalized_workspace:
if not project_permalink:
raise ValueError("workspace_permalink requires project_permalink")
normalized_project = generate_permalink(project_permalink)
workspace_project_prefix = f"{normalized_workspace}/{normalized_project}"
if normalized_path == workspace_project_prefix or normalized_path.startswith(
f"{workspace_project_prefix}/"
):
return normalized_path
if normalized_path == normalized_project or normalized_path.startswith(
f"{normalized_project}/"
):
project_path = normalized_path
else:
project_path = f"{normalized_project}/{normalized_path}"
return f"{normalized_workspace}/{project_path}"
if not include_project or not project_permalink:
return normalized_path
@@ -244,9 +269,11 @@ def build_canonical_permalink(
if normalized_path == normalized_project or normalized_path.startswith(
f"{normalized_project}/"
):
return normalized_path
project_path = normalized_path
else:
project_path = f"{normalized_project}/{normalized_path}"
return f"{normalized_project}/{normalized_path}"
return project_path
def setup_logging(
+114
View File
@@ -0,0 +1,114 @@
"""Request-local workspace context for canonical permalink generation."""
import re
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Iterator
WORKSPACE_SLUG_HEADER = "X-Basic-Memory-Workspace-Slug"
WORKSPACE_TYPE_HEADER = "X-Basic-Memory-Workspace-Type"
_WORKSPACE_SLUG_PATTERN = re.compile(r"^[a-z0-9_-]+$")
_WORKSPACE_TYPES = {"personal", "organization"}
@dataclass(frozen=True)
class WorkspacePermalinkContext:
"""Workspace metadata needed to build canonical organization permalinks."""
workspace_slug: str
workspace_type: str
@property
def should_prefix_permalinks(self) -> bool:
return self.workspace_type == "organization" and bool(self.workspace_slug)
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(
"basic_memory_workspace_permalink_context",
default=None,
)
def current_workspace_permalink_context() -> WorkspacePermalinkContext | None:
"""Return the active workspace permalink context, when one is set."""
return _workspace_permalink_context.get()
def validate_workspace_permalink_context_values(
workspace_slug: str | None,
workspace_type: str | None,
) -> None:
"""Validate workspace permalink metadata before it can affect stored permalinks."""
validation_error = workspace_permalink_context_validation_error(workspace_slug, workspace_type)
if validation_error is not None:
raise ValueError(validation_error)
def workspace_permalink_context_validation_error(
workspace_slug: str | None,
workspace_type: str | None,
) -> str | None:
"""Return the validation error for workspace permalink metadata, if any."""
if bool(workspace_slug) != bool(workspace_type):
return "workspace_slug and workspace_type must be provided together"
if not workspace_slug or not workspace_type:
return None
if _WORKSPACE_SLUG_PATTERN.fullmatch(workspace_slug) is None:
return f"{WORKSPACE_SLUG_HEADER} must match [a-z0-9_-]+"
if workspace_type not in _WORKSPACE_TYPES:
allowed = ", ".join(sorted(_WORKSPACE_TYPES))
return f"{WORKSPACE_TYPE_HEADER} must be one of: {allowed}"
return None
@contextmanager
def workspace_permalink_context(
workspace_slug: str | None,
workspace_type: str | None,
) -> Iterator[None]:
"""Set request-local workspace permalink metadata.
Cloud can populate this per request without storing workspace metadata in
local project config. The slug/type pair is all permalink generation needs.
"""
validate_workspace_permalink_context_values(workspace_slug, workspace_type)
if not workspace_slug or not workspace_type:
yield
return
token = _workspace_permalink_context.set(
WorkspacePermalinkContext(
workspace_slug=workspace_slug,
workspace_type=workspace_type,
)
)
try:
yield
finally:
_workspace_permalink_context.reset(token)
def workspace_permalink_headers() -> dict[str, str]:
"""Return HTTP headers for forwarding workspace permalink context."""
context = current_workspace_permalink_context()
if context is None:
return {}
return {
WORKSPACE_SLUG_HEADER: context.workspace_slug,
WORKSPACE_TYPE_HEADER: context.workspace_type,
}
def workspace_slug_for_canonical_permalinks() -> str | None:
"""Return the workspace slug when new permalinks should include it."""
context = current_workspace_permalink_context()
if context and context.should_prefix_permalinks:
return context.workspace_slug
return None