feat: Add disable_permalinks config flag (#313)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-10-03 22:11:47 -05:00
committed by GitHub
parent 33ee1e0831
commit 903591384d
12 changed files with 432 additions and 26 deletions
@@ -90,7 +90,10 @@ async def make_api_request(
# Handle both FastAPI HTTPException format (nested under "detail")
# and direct format
detail_obj = error_detail.get("detail", error_detail)
if isinstance(detail_obj, dict) and detail_obj.get("error") == "subscription_required":
if (
isinstance(detail_obj, dict)
and detail_obj.get("error") == "subscription_required"
):
message = detail_obj.get("message", "Active subscription required")
subscribe_url = detail_obj.get(
"subscribe_url", "https://basicmemory.com/subscribe"
+11
View File
@@ -93,11 +93,22 @@ class BasicMemoryConfig(BaseSettings):
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
)
disable_permalinks: bool = Field(
default=False,
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.",
)
skip_initialization_sync: bool = Field(
default=False,
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
)
# API connection configuration
api_url: Optional[str] = Field(
default=None,
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
)
# Cloud configuration
cloud_client_id: str = Field(
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
+2
View File
@@ -260,6 +260,7 @@ async def get_entity_service(
entity_parser: EntityParserDep,
file_service: FileServiceDep,
link_resolver: "LinkResolverDep",
app_config: AppConfigDep,
) -> EntityService:
"""Create EntityService with repository."""
return EntityService(
@@ -269,6 +270,7 @@ async def get_entity_service(
entity_parser=entity_parser,
file_service=file_service,
link_resolver=link_resolver,
app_config=app_config,
)
+5 -1
View File
@@ -197,6 +197,7 @@ class Entity(BaseModel):
"""
# private field to override permalink
# Use empty string "" as sentinel to indicate permalinks are explicitly disabled
_permalink: Optional[str] = None
title: str
@@ -247,8 +248,11 @@ class Entity(BaseModel):
return os.path.join(self.folder, safe_title) if self.folder else safe_title
@property
def permalink(self) -> Permalink:
def permalink(self) -> Optional[Permalink]:
"""Get a url friendly path}."""
# Empty string is a sentinel value indicating permalinks are disabled
if self._permalink == "":
return None
return self._permalink or generate_permalink(self.file_path)
@model_validator(mode="after")
+27 -16
View File
@@ -42,6 +42,7 @@ class EntityService(BaseService[EntityModel]):
relation_repository: RelationRepository,
file_service: FileService,
link_resolver: LinkResolver,
app_config: Optional[BasicMemoryConfig] = None,
):
super().__init__(entity_repository)
self.observation_repository = observation_repository
@@ -49,6 +50,7 @@ class EntityService(BaseService[EntityModel]):
self.entity_parser = entity_parser
self.file_service = file_service
self.link_resolver = link_resolver
self.app_config = app_config
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
"""Detect potential file path conflicts for a given file path.
@@ -145,9 +147,9 @@ class EntityService(BaseService[EntityModel]):
)
# Try to find existing entity using smart resolution
existing = await self.link_resolver.resolve_link(
schema.file_path
) or await self.link_resolver.resolve_link(schema.permalink)
existing = await self.link_resolver.resolve_link(schema.file_path)
if not existing and schema.permalink:
existing = await self.link_resolver.resolve_link(schema.permalink)
if existing:
logger.debug(f"Found existing entity: {existing.file_path}")
@@ -194,9 +196,15 @@ class EntityService(BaseService[EntityModel]):
relations=[],
)
# Get unique permalink (prioritizing content frontmatter)
permalink = await self.resolve_permalink(file_path, content_markdown)
schema._permalink = permalink
# Get unique permalink (prioritizing content frontmatter) unless disabled
if self.app_config and self.app_config.disable_permalinks:
# Use empty string as sentinel to indicate permalinks are disabled
# The permalink property will return None when it sees empty string
schema._permalink = ""
else:
# Generate and set permalink
permalink = await self.resolve_permalink(file_path, content_markdown)
schema._permalink = permalink
post = await schema_to_markdown(schema)
@@ -254,15 +262,16 @@ class EntityService(BaseService[EntityModel]):
relations=[],
)
# Check if we need to update the permalink based on content frontmatter
# Check if we need to update the permalink based on content frontmatter (unless disabled)
new_permalink = entity.permalink # Default to existing
if content_markdown and content_markdown.frontmatter.permalink:
# Resolve permalink with the new content frontmatter
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
if resolved_permalink != entity.permalink:
new_permalink = resolved_permalink
# Update the schema to use the new permalink
schema._permalink = new_permalink
if self.app_config and not self.app_config.disable_permalinks:
if content_markdown and content_markdown.frontmatter.permalink:
# Resolve permalink with the new content frontmatter
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
if resolved_permalink != entity.permalink:
new_permalink = resolved_permalink
# Update the schema to use the new permalink
schema._permalink = new_permalink
# Create post with new content from schema
post = await schema_to_markdown(schema)
@@ -746,8 +755,10 @@ class EntityService(BaseService[EntityModel]):
# 6. Prepare database updates
updates = {"file_path": destination_path}
# 7. Update permalink if configured or if entity has null permalink
if app_config.update_permalinks_on_move or old_permalink is None:
# 7. Update permalink if configured or if entity has null permalink (unless disabled)
if not app_config.disable_permalinks and (
app_config.update_permalinks_on_move or old_permalink is None
):
# Generate new permalink from destination path
new_permalink = await self.resolve_permalink(destination_path)
+6 -4
View File
@@ -338,8 +338,8 @@ class SyncService:
# entity markdown will always contain front matter, so it can be used up create/update the entity
entity_markdown = await self.entity_parser.parse_file(path)
# if the file contains frontmatter, resolve a permalink
if file_contains_frontmatter:
# if the file contains frontmatter, resolve a permalink (unless disabled)
if file_contains_frontmatter and not self.app_config.disable_permalinks:
# Resolve permalink - this handles all the cases including conflicts
permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown)
@@ -530,8 +530,10 @@ class SyncService:
updates = {"file_path": new_path}
# If configured, also update permalink to match new path
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(
new_path
if (
self.app_config.update_permalinks_on_move
and not self.app_config.disable_permalinks
and self.file_service.is_markdown(new_path)
):
# generate new permalink value
new_permalink = await self.entity_service.resolve_permalink(new_path)
+3 -1
View File
@@ -121,7 +121,9 @@ class WatchService:
ignore_patterns = self._get_ignore_patterns(project_path)
if should_ignore_path(file_path, project_path, ignore_patterns):
logger.trace(f"Ignoring watched file change: {file_path.relative_to(project_path)}")
logger.trace(
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
)
continue
project_changes[project].append((change, path))