feat: enable project-prefixed permalinks

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-06 14:00:12 -06:00
parent 8bc03d1357
commit c6511aa745
40 changed files with 1055 additions and 389 deletions
@@ -60,7 +60,9 @@ def import_chatgpt(
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
# Create importer and run import
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
importer = ChatGPTImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
with conversations_json.open("r", encoding="utf-8") as file:
json_data = json.load(file)
result = run_with_cleanup(importer.import_data(json_data, folder))
@@ -57,7 +57,9 @@ def import_claude(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
importer = ClaudeConversationsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home / folder
@@ -56,7 +56,9 @@ def import_projects(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
importer = ClaudeProjectsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home / base_folder if base_folder else config.home
@@ -55,7 +55,9 @@ def memory_json(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
importer = MemoryJsonImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
# Process the file
base_path = config.home if not destination_folder else config.home / destination_folder
+5
View File
@@ -196,6 +196,11 @@ class BasicMemoryConfig(BaseSettings):
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
)
permalinks_include_project: bool = Field(
default=True,
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",
)
skip_initialization_sync: bool = Field(
default=False,
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
+72 -12
View File
@@ -41,7 +41,12 @@ async def get_chatgpt_importer(
file_service: FileServiceDep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
@@ -53,7 +58,12 @@ async def get_chatgpt_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with v2 dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
@@ -65,7 +75,12 @@ async def get_chatgpt_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ChatGPTImporter:
"""Create ChatGPTImporter with v2 external_id dependencies."""
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
return ChatGPTImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
@@ -80,7 +95,12 @@ async def get_claude_conversations_importer(
file_service: FileServiceDep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterDep = Annotated[
@@ -94,7 +114,12 @@ async def get_claude_conversations_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with v2 dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterV2Dep = Annotated[
@@ -108,7 +133,12 @@ async def get_claude_conversations_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ClaudeConversationsImporter:
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
return ClaudeConversationsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeConversationsImporterV2ExternalDep = Annotated[
@@ -125,7 +155,12 @@ async def get_claude_projects_importer(
file_service: FileServiceDep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
@@ -137,7 +172,12 @@ async def get_claude_projects_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with v2 dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterV2Dep = Annotated[
@@ -151,7 +191,12 @@ async def get_claude_projects_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> ClaudeProjectsImporter:
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
return ClaudeProjectsImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
ClaudeProjectsImporterV2ExternalDep = Annotated[
@@ -168,7 +213,12 @@ async def get_memory_json_importer(
file_service: FileServiceDep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
@@ -180,7 +230,12 @@ async def get_memory_json_importer_v2( # pragma: no cover
file_service: FileServiceV2Dep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with v2 dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
@@ -192,7 +247,12 @@ async def get_memory_json_importer_v2_external(
file_service: FileServiceV2ExternalDep,
) -> MemoryJsonImporter:
"""Create MemoryJsonImporter with v2 external_id dependencies."""
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
return MemoryJsonImporter(
project_config.home,
markdown_processor,
file_service,
project_name=project_config.name,
)
MemoryJsonImporterV2ExternalDep = Annotated[
+24
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityMarkdown
from basic_memory.schemas.importer import ImportResult
from basic_memory.utils import build_canonical_permalink, generate_permalink
if TYPE_CHECKING: # pragma: no cover
from basic_memory.services.file_service import FileService
@@ -29,6 +30,7 @@ class Importer[T: ImportResult]:
base_path: Path,
markdown_processor: MarkdownProcessor,
file_service: "FileService",
project_name: Optional[str] = None,
):
"""Initialize the import service.
@@ -40,6 +42,8 @@ class Importer[T: ImportResult]:
self.base_path = base_path.resolve() # Get absolute path
self.markdown_processor = markdown_processor
self.file_service = file_service
self.project_name = project_name
self.project_permalink = generate_permalink(project_name) if project_name else None
@abstractmethod
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
@@ -73,6 +77,26 @@ class Importer[T: ImportResult]:
# FileService.write_file handles directory creation and returns checksum
return await self.file_service.write_file(file_path, content)
def canonical_permalink(self, path: str) -> str:
"""Build a canonical permalink for imported content."""
include_project = True
# Trigger: importer has app config with permalink prefixing flag
# Why: imported notes should align with canonical permalink format
# Outcome: include project prefix when enabled
if self.file_service.app_config is not None:
include_project = self.file_service.app_config.permalinks_include_project
return build_canonical_permalink(
self.project_permalink,
path,
include_project=include_project,
)
def build_import_paths(self, path: str) -> tuple[str, str]:
"""Return (permalink, file_path) for an imported entity."""
permalink = self.canonical_permalink(path)
return permalink, f"{path}.md"
async def ensure_folder_exists(self, folder: str) -> None:
"""Ensure folder exists using FileService.
+13 -8
View File
@@ -51,11 +51,20 @@ class ChatGPTImporter(Importer[ChatImportResult]):
chats_imported = 0
for chat in conversations:
created_at = chat["create_time"]
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
clean_title = clean_filename(chat["title"])
relative_path = (
f"{destination_folder}/{date_prefix}-{clean_title}"
if destination_folder
else f"{date_prefix}-{clean_title}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Convert to entity
entity = self._format_chat_content(destination_folder, chat)
entity = self._format_chat_content(chat, permalink)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
# Count messages
@@ -83,7 +92,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
return self.handle_error("Failed to import ChatGPT conversations", e)
def _format_chat_content(
self, folder: str, conversation: Dict[str, Any]
self, conversation: Dict[str, Any], permalink: str
) -> EntityMarkdown: # pragma: no cover
"""Convert chat conversation to Basic Memory entity.
@@ -105,10 +114,6 @@ class ChatGPTImporter(Importer[ChatImportResult]):
root_id = node_id
break
# Generate permalink
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
clean_title = clean_filename(conversation["title"])
# Format content
content = self._format_chat_markdown(
title=conversation["title"],
@@ -126,7 +131,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
"title": conversation["title"],
"created": format_timestamp(created_at),
"modified": format_timestamp(modified_at),
"permalink": f"{folder}/{date_prefix}-{clean_title}",
"permalink": permalink,
}
),
content=content,
@@ -54,18 +54,27 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
for chat in conversations:
# Get name, providing default for unnamed conversations
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
date_prefix = datetime.fromisoformat(chat["created_at"].replace("Z", "+00:00")).strftime(
"%Y%m%d"
)
clean_title = clean_filename(chat_name)
relative_path = (
f"{destination_folder}/{date_prefix}-{clean_title}"
if destination_folder
else f"{date_prefix}-{clean_title}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Convert to entity
entity = self._format_chat_content(
folder=destination_folder,
name=chat_name,
messages=chat["chat_messages"],
created_at=chat["created_at"],
modified_at=chat["updated_at"],
permalink=permalink,
)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
chats_imported += 1
@@ -84,11 +93,11 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
def _format_chat_content(
self,
folder: str,
name: str,
messages: List[Dict[str, Any]],
created_at: str,
modified_at: str,
permalink: str,
) -> EntityMarkdown:
"""Convert chat messages to Basic Memory entity format.
@@ -102,11 +111,6 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
Returns:
EntityMarkdown instance representing the conversation.
"""
# Generate permalink using folder name (relative path)
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
clean_title = clean_filename(name)
permalink = f"{folder}/{date_prefix}-{clean_title}"
# Format content
content = self._format_chat_markdown(
name=name,
@@ -63,17 +63,27 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
await self.file_service.ensure_directory(docs_dir)
# Import prompt template if it exists
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
# Write file using relative path - FileService handles base_path
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
if project.get("prompt_template"):
prompt_path = (
f"{destination_folder}/{project_dir}/prompt-template"
if destination_folder
else f"{project_dir}/prompt-template"
)
permalink, file_path = self.build_import_paths(prompt_path)
prompt_entity = self._format_prompt_markdown(project, permalink)
await self.write_entity(prompt_entity, file_path)
prompts_imported += 1
# Import project documents
for doc in project.get("docs", []):
entity = self._format_project_markdown(project, doc, destination_folder)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
doc_file = clean_filename(doc["filename"])
doc_path = (
f"{destination_folder}/{project_dir}/docs/{doc_file}"
if destination_folder
else f"{project_dir}/docs/{doc_file}"
)
permalink, file_path = self.build_import_paths(doc_path)
entity = self._format_project_markdown(project, doc, permalink)
await self.write_entity(entity, file_path)
docs_imported += 1
@@ -89,7 +99,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
return self.handle_error("Failed to import Claude projects", e)
def _format_project_markdown(
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
self, project: Dict[str, Any], doc: Dict[str, Any], permalink: str
) -> EntityMarkdown:
"""Format a project document as a Basic Memory entity.
@@ -105,17 +115,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
created_at = doc.get("created_at") or project["created_at"]
modified_at = project["updated_at"]
# Generate clean names for organization
project_dir = clean_filename(project["name"])
doc_file = clean_filename(doc["filename"])
# Build permalink with optional destination folder prefix
permalink = (
f"{destination_folder}/{project_dir}/docs/{doc_file}"
if destination_folder
else f"{project_dir}/docs/{doc_file}"
)
# Create entity
entity = EntityMarkdown(
frontmatter=EntityFrontmatter(
@@ -136,7 +135,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
return entity
def _format_prompt_markdown(
self, project: Dict[str, Any], destination_folder: str = ""
self, project: Dict[str, Any], permalink: str
) -> Optional[EntityMarkdown]:
"""Format project prompt template as a Basic Memory entity.
@@ -155,16 +154,6 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
created_at = project["created_at"]
modified_at = project["updated_at"]
# Generate clean project directory name
project_dir = clean_filename(project["name"])
# Build permalink with optional destination folder prefix
permalink = (
f"{destination_folder}/{project_dir}/prompt-template"
if destination_folder
else f"{project_dir}/prompt-template"
)
# Create entity
entity = EntityMarkdown(
frontmatter=EntityFrontmatter(
@@ -80,11 +80,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
# Build permalink with optional destination folder prefix
permalink = (
relative_path = (
f"{destination_folder}/{entity_type}/{name}"
if destination_folder
else f"{entity_type}/{name}"
)
permalink, file_path = self.build_import_paths(relative_path)
# Ensure entity type directory exists using FileService with relative path
entity_type_dir = (
@@ -109,7 +110,6 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
)
# Write file using relative path - FileService handles base_path
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
await self.write_entity(entity, file_path)
entities_created += 1
+4 -2
View File
@@ -1,6 +1,8 @@
"""Markdown-it plugins for Basic Memory markdown parsing."""
from typing import List, Any, Dict
from basic_memory.utils import normalize_project_reference
from markdown_it import MarkdownIt
from markdown_it.token import Token
@@ -114,7 +116,7 @@ def parse_relation(token: Token) -> Dict[str, Any] | None:
rel_type = before
# Get target
target = content[start + 2 : end].strip()
target = normalize_project_reference(content[start + 2 : end].strip())
# Look for context after
after = content[end + 2 :].strip()
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
# No matching ]] found
break
target = content[start + 2 : end].strip()
target = normalize_project_reference(content[start + 2 : end].strip())
if target:
relations.append({"type": "links_to", "target": target, "context": None})
+95
View File
@@ -17,11 +17,14 @@ from httpx._types import (
)
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.config import ConfigManager
from basic_memory.project_resolver import ProjectResolver
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
async def resolve_project_parameter(
@@ -150,6 +153,98 @@ async def get_active_project(
return active_project
def _split_project_prefix(path: str) -> tuple[Optional[str], str]:
"""Split a possible project prefix from a memory URL path."""
if "/" not in path:
return None, path
project_prefix, remainder = path.split("/", 1)
if not project_prefix or not remainder:
return None, path
if "*" in project_prefix:
return None, path
return project_prefix, remainder
async def resolve_project_and_path(
client: AsyncClient,
identifier: str,
project: Optional[str] = None,
context: Optional[Context] = None,
headers: HeaderTypes | None = None,
) -> tuple[ProjectItem, str, bool]:
"""Resolve project and normalized path for memory:// identifiers.
Returns:
Tuple of (active_project, normalized_path, is_memory_url)
"""
is_memory_url = identifier.strip().startswith("memory://")
if not is_memory_url:
active_project = await get_active_project(client, project, context, headers)
return active_project, identifier, False
normalized_path = normalize_project_reference(memory_url_path(identifier))
project_prefix, remainder = _split_project_prefix(normalized_path)
include_project = ConfigManager().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:
try:
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
client,
"/v2/projects/resolve",
json={"identifier": project_prefix},
headers=headers,
)
resolved = ProjectResolveResponse.model_validate(response.json())
except ToolError as exc:
if "project not found" not in str(exc).lower():
raise
else:
resolved_project = await resolve_project_parameter(project_prefix)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
project_prefix
):
raise ValueError(
f"Project is constrained to '{resolved_project}', cannot use '{project_prefix}'."
)
active_project = ProjectItem(
id=resolved.project_id,
external_id=resolved.external_id,
name=resolved.name,
path=resolved.path,
is_default=resolved.is_default,
)
if context:
context.set_state("active_project", active_project)
resolved_path = (
f"{resolved.permalink}/{remainder}" if include_project else remainder
)
return active_project, resolved_path, True
# Trigger: no resolvable project prefix in the memory URL
# Why: preserve existing memory URL behavior within the active project
# Outcome: use the active project and normalize the path for lookup
active_project = await get_active_project(client, project, context, headers)
resolved_path = normalized_path
if include_project:
# Trigger: project-prefixed permalinks are enabled and the path lacks a prefix
# Why: ensure memory URL lookups align with canonical permalinks
# Outcome: prefix the path with the active project's permalink
project_prefix = active_project.permalink
if resolved_path != project_prefix and not resolved_path.startswith(f"{project_prefix}/"):
resolved_path = f"{project_prefix}/{resolved_path}"
return active_project, resolved_path, True
def add_project_metadata(result: str, project_name: str) -> str:
"""Add project context as metadata footer for assistant session tracking.
+8 -7
View File
@@ -5,14 +5,10 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
MemoryUrl,
memory_url_path,
)
from basic_memory.schemas.memory import GraphContext, MemoryUrl
@mcp.tool(
@@ -100,13 +96,18 @@ async def build_context(
# URL is already validated and normalized by MemoryUrl type annotation
async with get_project_client(project, context) as (client, active_project):
# Resolve memory:// identifier with project-prefix awareness
_, resolved_path, _ = await resolve_project_and_path(
client, url, project, context
)
# Import here to avoid circular import
from basic_memory.mcp.clients import MemoryClient
# Use typed MemoryClient for API calls
memory_client = MemoryClient(client, active_project.external_id)
return await memory_client.build_context(
memory_url_path(url),
resolved_path,
depth=depth or 1,
timeframe=timeframe,
page=page,
+3 -3
View File
@@ -15,10 +15,9 @@ from PIL import Image as PILImage
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -202,7 +201,8 @@ async def read_content(
logger.info("Reading file", path=path, project=project)
async with get_project_client(project, context) as (client, active_project):
url = memory_url_path(path)
# Resolve path with project-prefix awareness for memory:// URLs
_, url, _ = await resolve_project_and_path(client, path, project, context)
# Validate path to prevent path traversal attacks
project_path = active_project.home
+15 -6
View File
@@ -6,11 +6,10 @@ from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.mcp.server import mcp
from basic_memory.mcp.formatting import format_note_preview_ascii
from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -82,9 +81,14 @@ async def read_note(
including related notes, search commands, and note creation templates.
"""
async with get_project_client(project, context) as (client, active_project):
# Resolve identifier with project-prefix awareness for memory:// URLs
_, entity_path, _ = await resolve_project_and_path(
client, identifier, project, context
)
# Validate identifier to prevent path traversal attacks
# We need to check both the raw identifier and the processed path
processed_path = memory_url_path(identifier)
processed_path = entity_path
project_path = active_project.home
if not validate_project_path(identifier, project_path) or not validate_project_path(
@@ -99,7 +103,6 @@ async def read_note(
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
# Get the file via REST API - first try direct identifier resolution
entity_path = memory_url_path(identifier)
logger.info(
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
)
@@ -135,7 +138,10 @@ async def read_note(
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes.fn(
query=identifier, search_type="title", project=project, context=context
query=identifier,
search_type="title",
project=active_project.name,
context=context,
)
# Handle both SearchResponse object and error strings
@@ -170,7 +176,10 @@ async def read_note(
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes.fn(
query=identifier, search_type="text", project=project, context=context
query=identifier,
search_type="text",
project=active_project.name,
context=context,
)
# We didn't find a direct match, construct a helpful error message
+44 -36
View File
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict, Any, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.mcp.formatting import format_search_results_ascii
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import (
@@ -399,42 +399,50 @@ async def search_notes(
types = types or []
entity_types = entity_types or []
# Create a SearchQuery object based on the parameters
search_query = SearchQuery()
# Set the appropriate search field based on search_type
if search_type == "text":
search_query.text = query
elif search_type == "vector":
search_query.text = query
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
elif search_type == "hybrid":
search_query.text = query
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
elif search_type == "title":
search_query.title = query
elif search_type == "permalink" and "*" in query:
search_query.permalink_match = query
elif search_type == "permalink":
search_query.permalink = query
else: # pragma: no cover
search_query.text = query # Default to text search
# Add optional filters if provided (empty lists are treated as no filter)
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if types:
search_query.types = types
if after_date:
search_query.after_date = after_date
if metadata_filters:
search_query.metadata_filters = metadata_filters
if tags:
search_query.tags = tags
if status:
search_query.status = status
async with get_project_client(project, context) as (client, active_project):
# Handle memory:// URLs by resolving to permalink search
_, resolved_query, is_memory_url = await resolve_project_and_path(
client, query, project, context
)
if is_memory_url:
query = resolved_query
search_type = "permalink"
# Create a SearchQuery object based on the parameters
search_query = SearchQuery()
# Set the appropriate search field based on search_type
if search_type == "text":
search_query.text = query
elif search_type == "vector":
search_query.text = query
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
elif search_type == "hybrid":
search_query.text = query
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
elif search_type == "title":
search_query.title = query
elif search_type == "permalink" and "*" in query:
search_query.permalink_match = query
elif search_type == "permalink":
search_query.permalink = query
else: # pragma: no cover
search_query.text = query # Default to text search
# Add optional filters if provided (empty lists are treated as no filter)
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if types:
search_query.types = types
if after_date:
search_query.after_date = after_date
if metadata_filters:
search_query.metadata_filters = metadata_filters
if tags:
search_query.tags = tags
if status:
search_query.status = status
logger.info(f"Searching for {search_query} in project {active_project.name}")
try:
@@ -539,9 +539,7 @@ class ContextService:
{relation_date_filter}
{relation_project_filter}
)
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
WHERE eg.depth < :max_depth
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
UNION ALL
+35 -2
View File
@@ -24,6 +24,7 @@ from basic_memory.models import Entity as EntityModel
from basic_memory.models import Observation, Relation
from basic_memory.models.knowledge import Entity
from basic_memory.repository import ObservationRepository, RelationRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.schemas.base import Permalink
@@ -41,7 +42,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 generate_permalink
from basic_memory.utils import build_canonical_permalink, generate_permalink
class EntityService(BaseService[EntityModel]):
@@ -66,6 +67,7 @@ class EntityService(BaseService[EntityModel]):
self.link_resolver = link_resolver
self.search_service = search_service
self.app_config = app_config
self._project_permalink: Optional[str] = None
async def detect_file_path_conflicts(
self, file_path: str, skip_check: bool = False
@@ -159,7 +161,23 @@ class EntityService(BaseService[EntityModel]):
if markdown and markdown.frontmatter.permalink:
desired_permalink = markdown.frontmatter.permalink
else:
desired_permalink = generate_permalink(file_path_str)
# Trigger: generating a permalink for a new file
# Why: canonical permalinks may require project prefix for global addressing
# Outcome: include project slug when enabled in config
include_project = True
if self.app_config:
include_project = self.app_config.permalinks_include_project
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:
project_permalink = await self._get_project_permalink()
desired_permalink = build_canonical_permalink(
project_permalink, file_path_str, include_project=include_project
)
# Make unique if needed - enhanced to handle character conflicts
# Use lightweight existence check instead of loading full entity
@@ -172,6 +190,21 @@ class EntityService(BaseService[EntityModel]):
return permalink
async def _get_project_permalink(self) -> Optional[str]:
"""Get and cache the current project's permalink."""
if self._project_permalink is not None:
return self._project_permalink
project_id = self.repository.project_id
if project_id is None: # pragma: no cover
return None # pragma: no cover
project_repository = ProjectRepository(self.repository.session_maker)
project = await project_repository.get_by_id(project_id)
if project:
self._project_permalink = project.permalink
return self._project_permalink
def _build_frontmatter_markdown(
self, title: str, entity_type: str, permalink: str
) -> EntityMarkdown:
+295 -102
View File
@@ -1,14 +1,21 @@
"""Service for resolving markdown links to permalinks."""
from typing import Optional, Tuple
from typing import Optional, Tuple, Dict
from loguru import logger
from basic_memory.models import Entity
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.models import Entity, Project
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
from basic_memory.utils import (
build_canonical_permalink,
generate_permalink,
normalize_project_reference,
)
class LinkResolver:
@@ -26,6 +33,12 @@ class LinkResolver:
"""Initialize with repositories."""
self.entity_repository = entity_repository
self.search_service = search_service
self._project_repository = ProjectRepository(entity_repository.session_maker)
self._app_config: BasicMemoryConfig = ConfigManager().config
self._project_permalink: Optional[str] = None
self._project_cache_by_identifier: Dict[str, Project] = {}
self._entity_repository_cache: Dict[int, EntityRepository] = {}
self._search_service_cache: Dict[int, SearchService] = {}
async def resolve_link(
self,
@@ -47,111 +60,69 @@ class LinkResolver:
# Clean link text and extract any alias
clean_text, alias = self._normalize_link_text(link_text)
explicit_project_reference = "::" in clean_text
clean_text = normalize_project_reference(clean_text)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
# for cross-platform compatibility. See entity_repository.py which normalizes
# paths using Path().as_posix(). This allows consistent path operations here.
# Trigger: link uses project namespace syntax (project::note)
# Why: treat it as an explicit cross-project reference
# Outcome: resolve only within the referenced project scope
if explicit_project_reference:
project_prefix, remainder = self._split_project_prefix(clean_text)
if not project_prefix:
return None
# --- Relative Path Resolution ---
# Trigger: source_path is provided AND link contains "/"
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
if source_path and "/" in clean_text:
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
if source_folder:
# Construct relative path from source folder
relative_path = f"{source_folder}/{clean_text}"
project_resources = await self._get_project_resources(project_prefix)
if not project_resources:
return None
# Try with .md extension
if not relative_path.endswith(".md"):
relative_path_md = f"{relative_path}.md"
entity = await self.entity_repository.get_by_file_path(relative_path_md)
if entity:
return entity
# Try as-is (already has extension or is a permalink)
entity = await self.entity_repository.get_by_file_path(relative_path)
if entity:
return entity
# When source_path is provided, use context-aware resolution:
# Check both permalink and title matches, prefer closest to source.
# Example: [[testing]] from folder/note.md prefers folder/testing.md
# over a root testing.md with permalink "testing".
if source_path:
# Gather all potential matches
candidates: list[Entity] = []
# Check permalink match
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
if permalink_entity:
candidates.append(permalink_entity)
# Check title matches
title_entities = await self.entity_repository.get_by_title(clean_text)
for entity in title_entities:
# Avoid duplicates (permalink match might also be in title matches)
if entity.id not in [c.id for c in candidates]:
candidates.append(entity)
if candidates:
if len(candidates) == 1:
return candidates[0]
else:
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)
# Standard resolution (no source context): permalink first, then title
# 1. Try exact permalink match first (most efficient)
entity = await self.entity_repository.get_by_permalink(clean_text)
if entity:
logger.debug(f"Found exact permalink match: {entity.permalink}")
return entity
# 2. Try exact title match
found = await self.entity_repository.get_by_title(clean_text)
if found:
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
# 3. Try file path
found_path = await self.entity_repository.get_by_file_path(clean_text)
if found_path:
logger.debug(f"Found entity with path: {found_path.file_path}")
return found_path
# 4. Try file path with .md extension if not already present
if not clean_text.endswith(".md") and "/" in clean_text:
file_path_with_md = f"{clean_text}.md"
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
if found_path_md:
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md
# In strict mode, don't try fuzzy search - return None if no exact match found
if strict:
return None
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
if use_search and "*" not in clean_text:
results = await self.search_service.search(
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
project, entity_repository, search_service = project_resources
return await self._resolve_in_project(
entity_repository=entity_repository,
search_service=search_service,
link_text=remainder,
use_search=use_search,
strict=strict,
source_path=None,
project_permalink=project.permalink,
)
if results:
# Look for best match
best_match = min(results, key=lambda x: x.score) # pyright: ignore
logger.trace(
f"Selected best match from {len(results)} results: {best_match.permalink}"
)
if best_match.permalink:
return await self.entity_repository.get_by_permalink(best_match.permalink)
current_project_permalink = await self._get_current_project_permalink()
resolved = await self._resolve_in_project(
entity_repository=self.entity_repository,
search_service=self.search_service,
link_text=clean_text,
use_search=use_search,
strict=strict,
source_path=source_path,
project_permalink=current_project_permalink,
)
if resolved:
return resolved
# if we couldn't find anything then return None
return None
# Trigger: local resolution failed and identifier looks like project/path
# Why: allow explicit project path references without namespace syntax
# Outcome: attempt resolution in the referenced project if it exists
project_prefix, remainder = self._split_project_prefix(clean_text)
if not project_prefix:
return None
project_resources = await self._get_project_resources(project_prefix)
if not project_resources:
return None
project, entity_repository, search_service = project_resources
if project.id == self.entity_repository.project_id:
return None
return await self._resolve_in_project(
entity_repository=entity_repository,
search_service=search_service,
link_text=remainder,
use_search=use_search,
strict=strict,
source_path=None,
project_permalink=project.permalink,
)
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
"""Normalize link text and extract alias if present.
@@ -181,6 +152,228 @@ class LinkResolver:
return text, alias
async def _resolve_in_project(
self,
*,
entity_repository: EntityRepository,
search_service: SearchService,
link_text: str,
use_search: bool,
strict: bool,
source_path: Optional[str],
project_permalink: Optional[str],
) -> Optional[Entity]:
"""Resolve a link within a specific project scope."""
clean_text = link_text
include_project = self._include_project_permalinks()
canonical_permalink: Optional[str] = None
legacy_permalink: Optional[str] = None
# Trigger: permalinks include project slug and project permalink is known
# Why: support globally addressable permalinks while keeping legacy links resolvable
# Outcome: include canonical and legacy candidates for resolution
if include_project and project_permalink:
canonical_permalink = build_canonical_permalink(
project_permalink, clean_text, include_project=True
)
if clean_text.startswith(f"{project_permalink}/"):
legacy_candidate = clean_text.removeprefix(f"{project_permalink}/")
if legacy_candidate:
legacy_permalink = legacy_candidate
permalink_candidates = []
for candidate in (clean_text, canonical_permalink, legacy_permalink):
if candidate and candidate not in permalink_candidates:
permalink_candidates.append(candidate)
# --- Path Resolution ---
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
# for cross-platform compatibility. See entity_repository.py which normalizes
# paths using Path().as_posix(). This allows consistent path operations here.
# --- Relative Path Resolution ---
# Trigger: source_path is provided AND link contains "/"
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
if source_path and "/" in clean_text:
if not (
include_project
and project_permalink
and clean_text.startswith(f"{project_permalink}/")
):
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
if source_folder:
# Construct relative path from source folder
relative_path = f"{source_folder}/{clean_text}"
# Try with .md extension
if not relative_path.endswith(".md"):
relative_path_md = f"{relative_path}.md"
entity = await entity_repository.get_by_file_path(relative_path_md)
if entity:
return entity
# Try as-is (already has extension or is a permalink)
entity = await entity_repository.get_by_file_path(relative_path)
if entity:
return entity
# When source_path is provided, use context-aware resolution:
# Check both permalink and title matches, prefer closest to source.
# Example: [[testing]] from folder/note.md prefers folder/testing.md
# over a root testing.md with permalink "testing".
if source_path:
# Gather all potential matches
candidates: list[Entity] = []
# Check permalink match
for candidate_permalink in permalink_candidates:
permalink_entity = await entity_repository.get_by_permalink(candidate_permalink)
if permalink_entity and permalink_entity.id not in [c.id for c in candidates]:
candidates.append(permalink_entity)
# Check title matches
title_entities = await entity_repository.get_by_title(clean_text)
for entity in title_entities:
# Avoid duplicates (permalink match might also be in title matches)
if entity.id not in [c.id for c in candidates]:
candidates.append(entity)
if candidates:
if len(candidates) == 1:
return candidates[0]
else:
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)
# Standard resolution (no source context): permalink first, then title
# 1. Try exact permalink match first (most efficient)
for candidate_permalink in permalink_candidates:
entity = await entity_repository.get_by_permalink(candidate_permalink)
if entity:
logger.debug(f"Found exact permalink match: {entity.permalink}")
return entity
# 2. Try exact title match
found = await entity_repository.get_by_title(clean_text)
if found:
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
# 3. Try file path
found_path = await entity_repository.get_by_file_path(clean_text)
if found_path:
logger.debug(f"Found entity with path: {found_path.file_path}")
return found_path
# 4. Try file path with .md extension if not already present
if not clean_text.endswith(".md") and "/" in clean_text:
file_path_with_md = f"{clean_text}.md"
found_path_md = await entity_repository.get_by_file_path(file_path_with_md)
if found_path_md:
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md
# In strict mode, don't try fuzzy search - return None if no exact match found
if strict:
return None
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
if use_search and "*" not in clean_text:
results = await search_service.search(
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
)
if results:
# Look for best match
best_match = min(results, key=lambda x: x.score) # pyright: ignore
logger.trace(
f"Selected best match from {len(results)} results: {best_match.permalink}"
)
if best_match.permalink:
return await entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
return None
def _include_project_permalinks(self) -> bool:
"""Return True when permalinks should include the project slug."""
return self._app_config.permalinks_include_project
async def _get_current_project_permalink(self) -> Optional[str]:
"""Get and cache the current project's permalink."""
if self._project_permalink is not None:
return self._project_permalink
project_id = self.entity_repository.project_id
if project_id is None: # pragma: no cover
return None # pragma: no cover
project = await self._project_repository.get_by_id(project_id)
if project:
self._project_permalink = project.permalink
return self._project_permalink
async def _get_project_by_identifier(self, identifier: str) -> Optional[Project]:
"""Resolve project by name or permalink."""
cache_key = identifier.strip().lower()
if cache_key in self._project_cache_by_identifier:
return self._project_cache_by_identifier[cache_key]
project = await self._project_repository.get_by_name(identifier)
if not project:
project = await self._project_repository.get_by_name_case_insensitive(identifier)
if not project:
project = await self._project_repository.get_by_permalink(generate_permalink(identifier))
if project:
self._project_cache_by_identifier[cache_key] = project
return project
async def _get_project_resources(
self, project_identifier: str
) -> Optional[Tuple[Project, EntityRepository, SearchService]]:
"""Fetch repositories and services scoped to a project."""
project = await self._get_project_by_identifier(project_identifier)
if not project:
return None
entity_repository = self._entity_repository_cache.get(project.id)
if not entity_repository:
entity_repository = EntityRepository(
self.entity_repository.session_maker, project_id=project.id
)
self._entity_repository_cache[project.id] = entity_repository
search_service = self._search_service_cache.get(project.id)
if not search_service:
search_repository = create_search_repository(
self.entity_repository.session_maker,
project_id=project.id,
database_backend=self._app_config.database_backend,
)
search_service = SearchService(
search_repository,
entity_repository,
self.search_service.file_service,
)
self._search_service_cache[project.id] = search_service
return project, entity_repository, search_service
def _split_project_prefix(self, identifier: str) -> Tuple[Optional[str], str]:
"""Split project prefix from a path-like identifier."""
if "/" not in identifier:
return None, identifier
project_prefix, remainder = identifier.split("/", 1)
if not project_prefix or not remainder:
return None, identifier
return project_prefix, remainder
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
"""Find the entity closest to the source file path.
+44 -1
View File
@@ -7,7 +7,7 @@ import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Protocol, Union, runtime_checkable, List
from typing import Protocol, Union, runtime_checkable, List, Optional
from loguru import logger
from unidecode import unidecode
@@ -202,6 +202,49 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
return return_val
def normalize_project_reference(identifier: str) -> str:
"""Normalize project-prefixed references.
Converts project namespace syntax ("project::note") to path syntax ("project/note").
Leaves non-namespaced identifiers unchanged.
"""
if "::" not in identifier:
return identifier
project, remainder = identifier.split("::", 1)
remainder = remainder.lstrip("/")
return f"{project}/{remainder}"
def build_canonical_permalink(
project_permalink: Optional[str],
file_path: Union[Path, str, PathLike],
include_project: bool = True,
) -> str:
"""Build a canonical permalink, optionally prefixed with project slug.
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.
Returns:
Canonical permalink string.
"""
normalized_path = generate_permalink(file_path)
if not include_project or not project_permalink:
return normalized_path
normalized_project = generate_permalink(project_permalink)
if normalized_path == normalized_project or normalized_path.startswith(
f"{normalized_project}/"
):
return normalized_path
return f"{normalized_project}/{normalized_path}"
def setup_logging(
log_level: str = "INFO",
log_to_file: bool = False,