Skip to content

Commit 9035913

Browse files
phernandezgithub-actions[bot]claude
authored
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>
1 parent 33ee1e0 commit 9035913

12 files changed

Lines changed: 432 additions & 26 deletions

File tree

src/basic_memory/cli/commands/cloud/api_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ async def make_api_request(
9090
# Handle both FastAPI HTTPException format (nested under "detail")
9191
# and direct format
9292
detail_obj = error_detail.get("detail", error_detail)
93-
if isinstance(detail_obj, dict) and detail_obj.get("error") == "subscription_required":
93+
if (
94+
isinstance(detail_obj, dict)
95+
and detail_obj.get("error") == "subscription_required"
96+
):
9497
message = detail_obj.get("message", "Active subscription required")
9598
subscribe_url = detail_obj.get(
9699
"subscribe_url", "https://basicmemory.com/subscribe"

src/basic_memory/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,22 @@ class BasicMemoryConfig(BaseSettings):
9393
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
9494
)
9595

96+
disable_permalinks: bool = Field(
97+
default=False,
98+
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.",
99+
)
100+
96101
skip_initialization_sync: bool = Field(
97102
default=False,
98103
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
99104
)
100105

106+
# API connection configuration
107+
api_url: Optional[str] = Field(
108+
default=None,
109+
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
110+
)
111+
101112
# Cloud configuration
102113
cloud_client_id: str = Field(
103114
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",

src/basic_memory/deps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ async def get_entity_service(
260260
entity_parser: EntityParserDep,
261261
file_service: FileServiceDep,
262262
link_resolver: "LinkResolverDep",
263+
app_config: AppConfigDep,
263264
) -> EntityService:
264265
"""Create EntityService with repository."""
265266
return EntityService(
@@ -269,6 +270,7 @@ async def get_entity_service(
269270
entity_parser=entity_parser,
270271
file_service=file_service,
271272
link_resolver=link_resolver,
273+
app_config=app_config,
272274
)
273275

274276

src/basic_memory/schemas/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ class Entity(BaseModel):
197197
"""
198198

199199
# private field to override permalink
200+
# Use empty string "" as sentinel to indicate permalinks are explicitly disabled
200201
_permalink: Optional[str] = None
201202

202203
title: str
@@ -247,8 +248,11 @@ def file_path(self):
247248
return os.path.join(self.folder, safe_title) if self.folder else safe_title
248249

249250
@property
250-
def permalink(self) -> Permalink:
251+
def permalink(self) -> Optional[Permalink]:
251252
"""Get a url friendly path}."""
253+
# Empty string is a sentinel value indicating permalinks are disabled
254+
if self._permalink == "":
255+
return None
252256
return self._permalink or generate_permalink(self.file_path)
253257

254258
@model_validator(mode="after")

src/basic_memory/services/entity_service.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ def __init__(
4242
relation_repository: RelationRepository,
4343
file_service: FileService,
4444
link_resolver: LinkResolver,
45+
app_config: Optional[BasicMemoryConfig] = None,
4546
):
4647
super().__init__(entity_repository)
4748
self.observation_repository = observation_repository
4849
self.relation_repository = relation_repository
4950
self.entity_parser = entity_parser
5051
self.file_service = file_service
5152
self.link_resolver = link_resolver
53+
self.app_config = app_config
5254

5355
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
5456
"""Detect potential file path conflicts for a given file path.
@@ -145,9 +147,9 @@ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityMod
145147
)
146148

147149
# Try to find existing entity using smart resolution
148-
existing = await self.link_resolver.resolve_link(
149-
schema.file_path
150-
) or await self.link_resolver.resolve_link(schema.permalink)
150+
existing = await self.link_resolver.resolve_link(schema.file_path)
151+
if not existing and schema.permalink:
152+
existing = await self.link_resolver.resolve_link(schema.permalink)
151153

152154
if existing:
153155
logger.debug(f"Found existing entity: {existing.file_path}")
@@ -194,9 +196,15 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel:
194196
relations=[],
195197
)
196198

197-
# Get unique permalink (prioritizing content frontmatter)
198-
permalink = await self.resolve_permalink(file_path, content_markdown)
199-
schema._permalink = permalink
199+
# Get unique permalink (prioritizing content frontmatter) unless disabled
200+
if self.app_config and self.app_config.disable_permalinks:
201+
# Use empty string as sentinel to indicate permalinks are disabled
202+
# The permalink property will return None when it sees empty string
203+
schema._permalink = ""
204+
else:
205+
# Generate and set permalink
206+
permalink = await self.resolve_permalink(file_path, content_markdown)
207+
schema._permalink = permalink
200208

201209
post = await schema_to_markdown(schema)
202210

@@ -254,15 +262,16 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti
254262
relations=[],
255263
)
256264

257-
# Check if we need to update the permalink based on content frontmatter
265+
# Check if we need to update the permalink based on content frontmatter (unless disabled)
258266
new_permalink = entity.permalink # Default to existing
259-
if content_markdown and content_markdown.frontmatter.permalink:
260-
# Resolve permalink with the new content frontmatter
261-
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
262-
if resolved_permalink != entity.permalink:
263-
new_permalink = resolved_permalink
264-
# Update the schema to use the new permalink
265-
schema._permalink = new_permalink
267+
if self.app_config and not self.app_config.disable_permalinks:
268+
if content_markdown and content_markdown.frontmatter.permalink:
269+
# Resolve permalink with the new content frontmatter
270+
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
271+
if resolved_permalink != entity.permalink:
272+
new_permalink = resolved_permalink
273+
# Update the schema to use the new permalink
274+
schema._permalink = new_permalink
266275

267276
# Create post with new content from schema
268277
post = await schema_to_markdown(schema)
@@ -746,8 +755,10 @@ async def move_entity(
746755
# 6. Prepare database updates
747756
updates = {"file_path": destination_path}
748757

749-
# 7. Update permalink if configured or if entity has null permalink
750-
if app_config.update_permalinks_on_move or old_permalink is None:
758+
# 7. Update permalink if configured or if entity has null permalink (unless disabled)
759+
if not app_config.disable_permalinks and (
760+
app_config.update_permalinks_on_move or old_permalink is None
761+
):
751762
# Generate new permalink from destination path
752763
new_permalink = await self.resolve_permalink(destination_path)
753764

src/basic_memory/sync/sync_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,8 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
338338
# entity markdown will always contain front matter, so it can be used up create/update the entity
339339
entity_markdown = await self.entity_parser.parse_file(path)
340340

341-
# if the file contains frontmatter, resolve a permalink
342-
if file_contains_frontmatter:
341+
# if the file contains frontmatter, resolve a permalink (unless disabled)
342+
if file_contains_frontmatter and not self.app_config.disable_permalinks:
343343
# Resolve permalink - this handles all the cases including conflicts
344344
permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown)
345345

@@ -530,8 +530,10 @@ async def handle_move(self, old_path, new_path):
530530
updates = {"file_path": new_path}
531531

532532
# If configured, also update permalink to match new path
533-
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(
534-
new_path
533+
if (
534+
self.app_config.update_permalinks_on_move
535+
and not self.app_config.disable_permalinks
536+
and self.file_service.is_markdown(new_path)
535537
):
536538
# generate new permalink value
537539
new_permalink = await self.entity_service.resolve_permalink(new_path)

src/basic_memory/sync/watch_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: a
121121
ignore_patterns = self._get_ignore_patterns(project_path)
122122

123123
if should_ignore_path(file_path, project_path, ignore_patterns):
124-
logger.trace(f"Ignoring watched file change: {file_path.relative_to(project_path)}")
124+
logger.trace(
125+
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
126+
)
125127
continue
126128

127129
project_changes[project].append((change, path))
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Integration tests for the disable_permalinks configuration."""
2+
3+
import pytest
4+
from pathlib import Path
5+
from textwrap import dedent
6+
7+
from basic_memory.config import BasicMemoryConfig
8+
from basic_memory.markdown import EntityParser, MarkdownProcessor
9+
from basic_memory.models import Project
10+
from basic_memory.repository import (
11+
EntityRepository,
12+
ObservationRepository,
13+
RelationRepository,
14+
)
15+
from basic_memory.repository.search_repository import SearchRepository
16+
from basic_memory.schemas import Entity as EntitySchema
17+
from basic_memory.services import FileService
18+
from basic_memory.services.entity_service import EntityService
19+
from basic_memory.services.link_resolver import LinkResolver
20+
from basic_memory.services.search_service import SearchService
21+
from basic_memory.sync.sync_service import SyncService
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_disable_permalinks_create_entity(tmp_path, engine_factory):
26+
"""Test that entities created with disable_permalinks=True don't have permalinks."""
27+
engine, session_maker = engine_factory
28+
29+
# Create app config with disable_permalinks=True
30+
app_config = BasicMemoryConfig(disable_permalinks=True)
31+
32+
# Setup repositories
33+
entity_repository = EntityRepository(session_maker, project_id=1)
34+
observation_repository = ObservationRepository(session_maker, project_id=1)
35+
relation_repository = RelationRepository(session_maker, project_id=1)
36+
search_repository = SearchRepository(session_maker, project_id=1)
37+
38+
# Setup services
39+
entity_parser = EntityParser(tmp_path)
40+
markdown_processor = MarkdownProcessor(entity_parser)
41+
file_service = FileService(tmp_path, markdown_processor)
42+
search_service = SearchService(search_repository, entity_repository, file_service)
43+
await search_service.init_search_index()
44+
link_resolver = LinkResolver(entity_repository, search_service)
45+
46+
entity_service = EntityService(
47+
entity_parser=entity_parser,
48+
entity_repository=entity_repository,
49+
observation_repository=observation_repository,
50+
relation_repository=relation_repository,
51+
file_service=file_service,
52+
link_resolver=link_resolver,
53+
app_config=app_config,
54+
)
55+
56+
# Create entity via API
57+
entity_data = EntitySchema(
58+
title="Test Note",
59+
folder="test",
60+
entity_type="note",
61+
content="Test content",
62+
)
63+
64+
created = await entity_service.create_entity(entity_data)
65+
66+
# Verify entity has no permalink
67+
assert created.permalink is None
68+
69+
# Verify file has no permalink in frontmatter
70+
file_path = tmp_path / "test" / "Test Note.md"
71+
assert file_path.exists()
72+
content = file_path.read_text()
73+
assert "permalink:" not in content
74+
assert "Test content" in content
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory):
79+
"""Test full sync workflow with disable_permalinks enabled."""
80+
engine, session_maker = engine_factory
81+
82+
# Create app config with disable_permalinks=True
83+
app_config = BasicMemoryConfig(disable_permalinks=True)
84+
85+
# Create a test markdown file without frontmatter
86+
test_file = tmp_path / "test_note.md"
87+
test_file.write_text("# Test Note\nThis is test content.")
88+
89+
# Setup repositories
90+
entity_repository = EntityRepository(session_maker, project_id=1)
91+
observation_repository = ObservationRepository(session_maker, project_id=1)
92+
relation_repository = RelationRepository(session_maker, project_id=1)
93+
search_repository = SearchRepository(session_maker, project_id=1)
94+
95+
# Setup services
96+
entity_parser = EntityParser(tmp_path)
97+
markdown_processor = MarkdownProcessor(entity_parser)
98+
file_service = FileService(tmp_path, markdown_processor)
99+
search_service = SearchService(search_repository, entity_repository, file_service)
100+
await search_service.init_search_index()
101+
link_resolver = LinkResolver(entity_repository, search_service)
102+
103+
entity_service = EntityService(
104+
entity_parser=entity_parser,
105+
entity_repository=entity_repository,
106+
observation_repository=observation_repository,
107+
relation_repository=relation_repository,
108+
file_service=file_service,
109+
link_resolver=link_resolver,
110+
app_config=app_config,
111+
)
112+
113+
sync_service = SyncService(
114+
app_config=app_config,
115+
entity_service=entity_service,
116+
entity_parser=entity_parser,
117+
entity_repository=entity_repository,
118+
relation_repository=relation_repository,
119+
search_service=search_service,
120+
file_service=file_service,
121+
)
122+
123+
# Run sync
124+
report = await sync_service.scan(tmp_path)
125+
# Note: scan may pick up database files too, so just check our file is there
126+
assert "test_note.md" in report.new
127+
128+
# Sync the file
129+
await sync_service.sync_file("test_note.md", new=True)
130+
131+
# Verify file has no permalink added
132+
content = test_file.read_text()
133+
assert "permalink:" not in content
134+
assert "# Test Note" in content
135+
136+
# Verify entity in database has no permalink
137+
entities = await entity_repository.find_all()
138+
assert len(entities) == 1
139+
assert entities[0].permalink is None
140+
# Title is extracted from filename when no frontmatter, or from frontmatter when present
141+
assert entities[0].title in ("test_note", "Test Note")

tests/cli/test_cloud_authentication.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,7 @@ async def test_parse_subscription_required_error_flat_format(self):
7070
mock_response.headers = {}
7171

7272
# Create HTTPStatusError with the mock response
73-
http_error = httpx.HTTPStatusError(
74-
"403 Forbidden", request=Mock(), response=mock_response
75-
)
73+
http_error = httpx.HTTPStatusError("403 Forbidden", request=Mock(), response=mock_response)
7674

7775
# Mock httpx client to raise the error
7876
with patch("basic_memory.cli.commands.cloud.api_client.httpx.AsyncClient") as mock_client:

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ async def entity_service(
200200
entity_parser: EntityParser,
201201
file_service: FileService,
202202
link_resolver: LinkResolver,
203+
app_config: BasicMemoryConfig,
203204
) -> EntityService:
204205
"""Create EntityService."""
205206
return EntityService(
@@ -209,6 +210,7 @@ async def entity_service(
209210
relation_repository=relation_repository,
210211
file_service=file_service,
211212
link_resolver=link_resolver,
213+
app_config=app_config,
212214
)
213215

214216

0 commit comments

Comments
 (0)