Skip to content

Commit 545804f

Browse files
phernandezclaude
andauthored
feat: Add project-prefixed permalinks and memory URL routing (#544)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8bc03d1 commit 545804f

47 files changed

Lines changed: 1092 additions & 412 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,7 @@ jobs:
3737
run: |
3838
pip install uv
3939
40-
- name: Install just (Linux)
41-
if: runner.os != 'Windows'
42-
run: |
43-
sudo apt-get update
44-
sudo apt-get install -y just
45-
46-
- name: Install just (Windows)
47-
if: runner.os == 'Windows'
48-
run: |
49-
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
50-
choco install just --yes
51-
shell: pwsh
40+
- uses: extractions/setup-just@v3
5241

5342
- name: Create virtual env
5443
run: |
@@ -96,10 +85,7 @@ jobs:
9685
run: |
9786
pip install uv
9887
99-
- name: Install just
100-
run: |
101-
sudo apt-get update
102-
sudo apt-get install -y just
88+
- uses: extractions/setup-just@v3
10389

10490
- name: Create virtual env
10591
run: |
@@ -133,10 +119,7 @@ jobs:
133119
run: |
134120
pip install uv
135121
136-
- name: Install just
137-
run: |
138-
sudo apt-get update
139-
sudo apt-get install -y just
122+
- uses: extractions/setup-just@v3
140123

141124
- name: Create virtual env
142125
run: |

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ def import_chatgpt(
6060
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
6161

6262
# Create importer and run import
63-
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
63+
importer = ChatGPTImporter(
64+
config.home, markdown_processor, file_service, project_name=config.name
65+
)
6466
with conversations_json.open("r", encoding="utf-8") as file:
6567
json_data = json.load(file)
6668
result = run_with_cleanup(importer.import_data(json_data, folder))

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def import_claude(
5757
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5858

5959
# Create the importer
60-
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
60+
importer = ClaudeConversationsImporter(
61+
config.home, markdown_processor, file_service, project_name=config.name
62+
)
6163

6264
# Process the file
6365
base_path = config.home / folder

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def import_projects(
5656
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5757

5858
# Create the importer
59-
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
59+
importer = ClaudeProjectsImporter(
60+
config.home, markdown_processor, file_service, project_name=config.name
61+
)
6062

6163
# Process the file
6264
base_path = config.home / base_folder if base_folder else config.home

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def memory_json(
5555
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5656

5757
# Create the importer
58-
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
58+
importer = MemoryJsonImporter(
59+
config.home, markdown_processor, file_service, project_name=config.name
60+
)
5961

6062
# Process the file
6163
base_path = config.home if not destination_folder else config.home / destination_folder

src/basic_memory/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ class BasicMemoryConfig(BaseSettings):
196196
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.",
197197
)
198198

199+
permalinks_include_project: bool = Field(
200+
default=True,
201+
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",
202+
)
203+
199204
skip_initialization_sync: bool = Field(
200205
default=False,
201206
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",

src/basic_memory/deps/importers.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ async def get_chatgpt_importer(
4141
file_service: FileServiceDep,
4242
) -> ChatGPTImporter:
4343
"""Create ChatGPTImporter with dependencies."""
44-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
44+
return ChatGPTImporter(
45+
project_config.home,
46+
markdown_processor,
47+
file_service,
48+
project_name=project_config.name,
49+
)
4550

4651

4752
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
@@ -53,7 +58,12 @@ async def get_chatgpt_importer_v2( # pragma: no cover
5358
file_service: FileServiceV2Dep,
5459
) -> ChatGPTImporter:
5560
"""Create ChatGPTImporter with v2 dependencies."""
56-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
61+
return ChatGPTImporter(
62+
project_config.home,
63+
markdown_processor,
64+
file_service,
65+
project_name=project_config.name,
66+
)
5767

5868

5969
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
@@ -65,7 +75,12 @@ async def get_chatgpt_importer_v2_external(
6575
file_service: FileServiceV2ExternalDep,
6676
) -> ChatGPTImporter:
6777
"""Create ChatGPTImporter with v2 external_id dependencies."""
68-
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
78+
return ChatGPTImporter(
79+
project_config.home,
80+
markdown_processor,
81+
file_service,
82+
project_name=project_config.name,
83+
)
6984

7085

7186
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
@@ -80,7 +95,12 @@ async def get_claude_conversations_importer(
8095
file_service: FileServiceDep,
8196
) -> ClaudeConversationsImporter:
8297
"""Create ClaudeConversationsImporter with dependencies."""
83-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
98+
return ClaudeConversationsImporter(
99+
project_config.home,
100+
markdown_processor,
101+
file_service,
102+
project_name=project_config.name,
103+
)
84104

85105

86106
ClaudeConversationsImporterDep = Annotated[
@@ -94,7 +114,12 @@ async def get_claude_conversations_importer_v2( # pragma: no cover
94114
file_service: FileServiceV2Dep,
95115
) -> ClaudeConversationsImporter:
96116
"""Create ClaudeConversationsImporter with v2 dependencies."""
97-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
117+
return ClaudeConversationsImporter(
118+
project_config.home,
119+
markdown_processor,
120+
file_service,
121+
project_name=project_config.name,
122+
)
98123

99124

100125
ClaudeConversationsImporterV2Dep = Annotated[
@@ -108,7 +133,12 @@ async def get_claude_conversations_importer_v2_external(
108133
file_service: FileServiceV2ExternalDep,
109134
) -> ClaudeConversationsImporter:
110135
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
111-
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
136+
return ClaudeConversationsImporter(
137+
project_config.home,
138+
markdown_processor,
139+
file_service,
140+
project_name=project_config.name,
141+
)
112142

113143

114144
ClaudeConversationsImporterV2ExternalDep = Annotated[
@@ -125,7 +155,12 @@ async def get_claude_projects_importer(
125155
file_service: FileServiceDep,
126156
) -> ClaudeProjectsImporter:
127157
"""Create ClaudeProjectsImporter with dependencies."""
128-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
158+
return ClaudeProjectsImporter(
159+
project_config.home,
160+
markdown_processor,
161+
file_service,
162+
project_name=project_config.name,
163+
)
129164

130165

131166
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
@@ -137,7 +172,12 @@ async def get_claude_projects_importer_v2( # pragma: no cover
137172
file_service: FileServiceV2Dep,
138173
) -> ClaudeProjectsImporter:
139174
"""Create ClaudeProjectsImporter with v2 dependencies."""
140-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
175+
return ClaudeProjectsImporter(
176+
project_config.home,
177+
markdown_processor,
178+
file_service,
179+
project_name=project_config.name,
180+
)
141181

142182

143183
ClaudeProjectsImporterV2Dep = Annotated[
@@ -151,7 +191,12 @@ async def get_claude_projects_importer_v2_external(
151191
file_service: FileServiceV2ExternalDep,
152192
) -> ClaudeProjectsImporter:
153193
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
154-
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
194+
return ClaudeProjectsImporter(
195+
project_config.home,
196+
markdown_processor,
197+
file_service,
198+
project_name=project_config.name,
199+
)
155200

156201

157202
ClaudeProjectsImporterV2ExternalDep = Annotated[
@@ -168,7 +213,12 @@ async def get_memory_json_importer(
168213
file_service: FileServiceDep,
169214
) -> MemoryJsonImporter:
170215
"""Create MemoryJsonImporter with dependencies."""
171-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
216+
return MemoryJsonImporter(
217+
project_config.home,
218+
markdown_processor,
219+
file_service,
220+
project_name=project_config.name,
221+
)
172222

173223

174224
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
@@ -180,7 +230,12 @@ async def get_memory_json_importer_v2( # pragma: no cover
180230
file_service: FileServiceV2Dep,
181231
) -> MemoryJsonImporter:
182232
"""Create MemoryJsonImporter with v2 dependencies."""
183-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
233+
return MemoryJsonImporter(
234+
project_config.home,
235+
markdown_processor,
236+
file_service,
237+
project_name=project_config.name,
238+
)
184239

185240

186241
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
@@ -192,7 +247,12 @@ async def get_memory_json_importer_v2_external(
192247
file_service: FileServiceV2ExternalDep,
193248
) -> MemoryJsonImporter:
194249
"""Create MemoryJsonImporter with v2 external_id dependencies."""
195-
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
250+
return MemoryJsonImporter(
251+
project_config.home,
252+
markdown_processor,
253+
file_service,
254+
project_name=project_config.name,
255+
)
196256

197257

198258
MemoryJsonImporterV2ExternalDep = Annotated[

src/basic_memory/importers/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from basic_memory.markdown.markdown_processor import MarkdownProcessor
99
from basic_memory.markdown.schemas import EntityMarkdown
1010
from basic_memory.schemas.importer import ImportResult
11+
from basic_memory.utils import build_canonical_permalink, generate_permalink
1112

1213
if TYPE_CHECKING: # pragma: no cover
1314
from basic_memory.services.file_service import FileService
@@ -29,6 +30,7 @@ def __init__(
2930
base_path: Path,
3031
markdown_processor: MarkdownProcessor,
3132
file_service: "FileService",
33+
project_name: Optional[str] = None,
3234
):
3335
"""Initialize the import service.
3436
@@ -40,6 +42,8 @@ def __init__(
4042
self.base_path = base_path.resolve() # Get absolute path
4143
self.markdown_processor = markdown_processor
4244
self.file_service = file_service
45+
self.project_name = project_name
46+
self.project_permalink = generate_permalink(project_name) if project_name else None
4347

4448
@abstractmethod
4549
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
@@ -73,6 +77,26 @@ async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> s
7377
# FileService.write_file handles directory creation and returns checksum
7478
return await self.file_service.write_file(file_path, content)
7579

80+
def canonical_permalink(self, path: str) -> str:
81+
"""Build a canonical permalink for imported content."""
82+
include_project = True
83+
# Trigger: importer has app config with permalink prefixing flag
84+
# Why: imported notes should align with canonical permalink format
85+
# Outcome: include project prefix when enabled
86+
if self.file_service.app_config is not None:
87+
include_project = self.file_service.app_config.permalinks_include_project
88+
89+
return build_canonical_permalink(
90+
self.project_permalink,
91+
path,
92+
include_project=include_project,
93+
)
94+
95+
def build_import_paths(self, path: str) -> tuple[str, str]:
96+
"""Return (permalink, file_path) for an imported entity."""
97+
permalink = self.canonical_permalink(path)
98+
return permalink, f"{path}.md"
99+
76100
async def ensure_folder_exists(self, folder: str) -> None:
77101
"""Ensure folder exists using FileService.
78102

src/basic_memory/importers/chatgpt_importer.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,20 @@ async def import_data(
5151
chats_imported = 0
5252

5353
for chat in conversations:
54+
created_at = chat["create_time"]
55+
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
56+
clean_title = clean_filename(chat["title"])
57+
relative_path = (
58+
f"{destination_folder}/{date_prefix}-{clean_title}"
59+
if destination_folder
60+
else f"{date_prefix}-{clean_title}"
61+
)
62+
permalink, file_path = self.build_import_paths(relative_path)
63+
5464
# Convert to entity
55-
entity = self._format_chat_content(destination_folder, chat)
65+
entity = self._format_chat_content(chat, permalink)
5666

5767
# Write file using relative path - FileService handles base_path
58-
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
5968
await self.write_entity(entity, file_path)
6069

6170
# Count messages
@@ -83,7 +92,7 @@ async def import_data(
8392
return self.handle_error("Failed to import ChatGPT conversations", e)
8493

8594
def _format_chat_content(
86-
self, folder: str, conversation: Dict[str, Any]
95+
self, conversation: Dict[str, Any], permalink: str
8796
) -> EntityMarkdown: # pragma: no cover
8897
"""Convert chat conversation to Basic Memory entity.
8998
@@ -105,10 +114,6 @@ def _format_chat_content(
105114
root_id = node_id
106115
break
107116

108-
# Generate permalink
109-
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
110-
clean_title = clean_filename(conversation["title"])
111-
112117
# Format content
113118
content = self._format_chat_markdown(
114119
title=conversation["title"],
@@ -126,7 +131,7 @@ def _format_chat_content(
126131
"title": conversation["title"],
127132
"created": format_timestamp(created_at),
128133
"modified": format_timestamp(modified_at),
129-
"permalink": f"{folder}/{date_prefix}-{clean_title}",
134+
"permalink": permalink,
130135
}
131136
),
132137
content=content,

0 commit comments

Comments
 (0)