basic-memory 0.16.1__py3-none-any.whl → 0.17.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -161,13 +161,13 @@ def load_bmignore_patterns() -> Set[str]:
161
161
  # Skip empty lines and comments
162
162
  if line and not line.startswith("#"):
163
163
  patterns.add(line)
164
- except Exception:
164
+ except Exception: # pragma: no cover
165
165
  # If we can't read .bmignore, fall back to defaults
166
- return set(DEFAULT_IGNORE_PATTERNS)
166
+ return set(DEFAULT_IGNORE_PATTERNS) # pragma: no cover
167
167
 
168
168
  # If no patterns were loaded, use defaults
169
- if not patterns:
170
- return set(DEFAULT_IGNORE_PATTERNS)
169
+ if not patterns: # pragma: no cover
170
+ return set(DEFAULT_IGNORE_PATTERNS) # pragma: no cover
171
171
 
172
172
  return patterns
173
173
 
@@ -261,7 +261,7 @@ def should_ignore_path(file_path: Path, base_path: Path, ignore_patterns: Set[st
261
261
 
262
262
  # Glob pattern match on full path
263
263
  if fnmatch.fnmatch(relative_posix, pattern) or fnmatch.fnmatch(relative_str, pattern):
264
- return True
264
+ return True # pragma: no cover
265
265
 
266
266
  return False
267
267
  except ValueError:
@@ -3,28 +3,43 @@
3
3
  import logging
4
4
  from abc import abstractmethod
5
5
  from pathlib import Path
6
- from typing import Any, Optional, TypeVar
6
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar
7
7
 
8
8
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
9
9
  from basic_memory.markdown.schemas import EntityMarkdown
10
10
  from basic_memory.schemas.importer import ImportResult
11
11
 
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from basic_memory.services.file_service import FileService
14
+
12
15
  logger = logging.getLogger(__name__)
13
16
 
14
17
  T = TypeVar("T", bound=ImportResult)
15
18
 
16
19
 
17
20
  class Importer[T: ImportResult]:
18
- """Base class for all import services."""
19
-
20
- def __init__(self, base_path: Path, markdown_processor: MarkdownProcessor):
21
+ """Base class for all import services.
22
+
23
+ All file operations are delegated to FileService, which can be overridden
24
+ in cloud environments to use S3 or other storage backends.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_path: Path,
30
+ markdown_processor: MarkdownProcessor,
31
+ file_service: "FileService",
32
+ ):
21
33
  """Initialize the import service.
22
34
 
23
35
  Args:
24
- markdown_processor: MarkdownProcessor instance for writing markdown files.
36
+ base_path: Base path for the project.
37
+ markdown_processor: MarkdownProcessor instance for markdown serialization.
38
+ file_service: FileService instance for all file operations.
25
39
  """
26
40
  self.base_path = base_path.resolve() # Get absolute path
27
41
  self.markdown_processor = markdown_processor
42
+ self.file_service = file_service
28
43
 
29
44
  @abstractmethod
30
45
  async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
@@ -40,28 +55,34 @@ class Importer[T: ImportResult]:
40
55
  """
41
56
  pass # pragma: no cover
42
57
 
43
- async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> None:
44
- """Write entity to file using markdown processor.
58
+ async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> str:
59
+ """Write entity to file using FileService.
60
+
61
+ This method serializes the entity to markdown and writes it using
62
+ FileService, which handles directory creation and storage backend
63
+ abstraction (local filesystem vs cloud storage).
45
64
 
46
65
  Args:
47
66
  entity: EntityMarkdown instance to write.
48
- file_path: Path to write the entity to.
67
+ file_path: Relative path to write the entity to. FileService handles base_path.
68
+
69
+ Returns:
70
+ Checksum of written file.
49
71
  """
50
- await self.markdown_processor.write_file(file_path, entity)
72
+ content = self.markdown_processor.to_markdown_string(entity)
73
+ # FileService.write_file handles directory creation and returns checksum
74
+ return await self.file_service.write_file(file_path, content)
51
75
 
52
- def ensure_folder_exists(self, folder: str) -> Path:
53
- """Ensure folder exists, create if it doesn't.
76
+ async def ensure_folder_exists(self, folder: str) -> None:
77
+ """Ensure folder exists using FileService.
54
78
 
55
- Args:
56
- base_path: Base path of the project.
57
- folder: Folder name or path within the project.
79
+ For cloud storage (S3), this is essentially a no-op since S3 doesn't
80
+ have actual folders - they're just key prefixes.
58
81
 
59
- Returns:
60
- Path to the folder.
82
+ Args:
83
+ folder: Relative folder path within the project. FileService handles base_path.
61
84
  """
62
- folder_path = self.base_path / folder
63
- folder_path.mkdir(parents=True, exist_ok=True)
64
- return folder_path
85
+ await self.file_service.ensure_directory(folder)
65
86
 
66
87
  @abstractmethod
67
88
  def handle_error(
@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
15
15
  class ChatGPTImporter(Importer[ChatImportResult]):
16
16
  """Service for importing ChatGPT conversations."""
17
17
 
18
+ def handle_error( # pragma: no cover
19
+ self, message: str, error: Optional[Exception] = None
20
+ ) -> ChatImportResult:
21
+ """Return a failed ChatImportResult with an error message."""
22
+ error_msg = f"{message}: {error}" if error else message
23
+ return ChatImportResult(
24
+ import_count={},
25
+ success=False,
26
+ error_message=error_msg,
27
+ conversations=0,
28
+ messages=0,
29
+ )
30
+
18
31
  async def import_data(
19
32
  self, source_data, destination_folder: str, **kwargs: Any
20
33
  ) -> ChatImportResult:
@@ -30,7 +43,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
30
43
  """
31
44
  try: # pragma: no cover
32
45
  # Ensure the destination folder exists
33
- self.ensure_folder_exists(destination_folder)
46
+ await self.ensure_folder_exists(destination_folder)
34
47
  conversations = source_data
35
48
 
36
49
  # Process each conversation
@@ -41,8 +54,8 @@ class ChatGPTImporter(Importer[ChatImportResult]):
41
54
  # Convert to entity
42
55
  entity = self._format_chat_content(destination_folder, chat)
43
56
 
44
- # Write file
45
- file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
57
+ # Write file using relative path - FileService handles base_path
58
+ file_path = f"{entity.frontmatter.metadata['permalink']}.md"
46
59
  await self.write_entity(entity, file_path)
47
60
 
48
61
  # Count messages
@@ -67,7 +80,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
67
80
 
68
81
  except Exception as e: # pragma: no cover
69
82
  logger.exception("Failed to import ChatGPT conversations")
70
- return self.handle_error("Failed to import ChatGPT conversations", e) # pyright: ignore [reportReturnType]
83
+ return self.handle_error("Failed to import ChatGPT conversations", e)
71
84
 
72
85
  def _format_chat_content(
73
86
  self, folder: str, conversation: Dict[str, Any]
@@ -2,8 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from datetime import datetime
5
- from pathlib import Path
6
- from typing import Any, Dict, List
5
+ from typing import Any, Dict, List, Optional
7
6
 
8
7
  from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
9
8
  from basic_memory.importers.base import Importer
@@ -16,6 +15,19 @@ logger = logging.getLogger(__name__)
16
15
  class ClaudeConversationsImporter(Importer[ChatImportResult]):
17
16
  """Service for importing Claude conversations."""
18
17
 
18
+ def handle_error( # pragma: no cover
19
+ self, message: str, error: Optional[Exception] = None
20
+ ) -> ChatImportResult:
21
+ """Return a failed ChatImportResult with an error message."""
22
+ error_msg = f"{message}: {error}" if error else message
23
+ return ChatImportResult(
24
+ import_count={},
25
+ success=False,
26
+ error_message=error_msg,
27
+ conversations=0,
28
+ messages=0,
29
+ )
30
+
19
31
  async def import_data(
20
32
  self, source_data, destination_folder: str, **kwargs: Any
21
33
  ) -> ChatImportResult:
@@ -31,7 +43,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
31
43
  """
32
44
  try:
33
45
  # Ensure the destination folder exists
34
- folder_path = self.ensure_folder_exists(destination_folder)
46
+ await self.ensure_folder_exists(destination_folder)
35
47
 
36
48
  conversations = source_data
37
49
 
@@ -40,17 +52,20 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
40
52
  chats_imported = 0
41
53
 
42
54
  for chat in conversations:
55
+ # Get name, providing default for unnamed conversations
56
+ chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
57
+
43
58
  # Convert to entity
44
59
  entity = self._format_chat_content(
45
- base_path=folder_path,
46
- name=chat["name"],
60
+ folder=destination_folder,
61
+ name=chat_name,
47
62
  messages=chat["chat_messages"],
48
63
  created_at=chat["created_at"],
49
64
  modified_at=chat["updated_at"],
50
65
  )
51
66
 
52
- # Write file
53
- file_path = self.base_path / Path(f"{entity.frontmatter.metadata['permalink']}.md")
67
+ # Write file using relative path - FileService handles base_path
68
+ file_path = f"{entity.frontmatter.metadata['permalink']}.md"
54
69
  await self.write_entity(entity, file_path)
55
70
 
56
71
  chats_imported += 1
@@ -65,11 +80,11 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
65
80
 
66
81
  except Exception as e: # pragma: no cover
67
82
  logger.exception("Failed to import Claude conversations")
68
- return self.handle_error("Failed to import Claude conversations", e) # pyright: ignore [reportReturnType]
83
+ return self.handle_error("Failed to import Claude conversations", e)
69
84
 
70
85
  def _format_chat_content(
71
86
  self,
72
- base_path: Path,
87
+ folder: str,
73
88
  name: str,
74
89
  messages: List[Dict[str, Any]],
75
90
  created_at: str,
@@ -78,7 +93,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
78
93
  """Convert chat messages to Basic Memory entity format.
79
94
 
80
95
  Args:
81
- base_path: Base path for the entity.
96
+ folder: Destination folder name (relative path).
82
97
  name: Chat name.
83
98
  messages: List of chat messages.
84
99
  created_at: Creation timestamp.
@@ -87,10 +102,10 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
87
102
  Returns:
88
103
  EntityMarkdown instance representing the conversation.
89
104
  """
90
- # Generate permalink
105
+ # Generate permalink using folder name (relative path)
91
106
  date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
92
107
  clean_title = clean_filename(name)
93
- permalink = f"{base_path.name}/{date_prefix}-{clean_title}"
108
+ permalink = f"{folder}/{date_prefix}-{clean_title}"
94
109
 
95
110
  # Format content
96
111
  content = self._format_chat_markdown(
@@ -14,6 +14,19 @@ logger = logging.getLogger(__name__)
14
14
  class ClaudeProjectsImporter(Importer[ProjectImportResult]):
15
15
  """Service for importing Claude projects."""
16
16
 
17
+ def handle_error( # pragma: no cover
18
+ self, message: str, error: Optional[Exception] = None
19
+ ) -> ProjectImportResult:
20
+ """Return a failed ProjectImportResult with an error message."""
21
+ error_msg = f"{message}: {error}" if error else message
22
+ return ProjectImportResult(
23
+ import_count={},
24
+ success=False,
25
+ error_message=error_msg,
26
+ documents=0,
27
+ prompts=0,
28
+ )
29
+
17
30
  async def import_data(
18
31
  self, source_data, destination_folder: str, **kwargs: Any
19
32
  ) -> ProjectImportResult:
@@ -29,9 +42,8 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
29
42
  """
30
43
  try:
31
44
  # Ensure the base folder exists
32
- base_path = self.base_path
33
45
  if destination_folder:
34
- base_path = self.ensure_folder_exists(destination_folder)
46
+ await self.ensure_folder_exists(destination_folder)
35
47
 
36
48
  projects = source_data
37
49
 
@@ -42,20 +54,26 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
42
54
  for project in projects:
43
55
  project_dir = clean_filename(project["name"])
44
56
 
45
- # Create project directories
46
- docs_dir = base_path / project_dir / "docs"
47
- docs_dir.mkdir(parents=True, exist_ok=True)
57
+ # Create project directories using FileService with relative path
58
+ docs_dir = (
59
+ f"{destination_folder}/{project_dir}/docs"
60
+ if destination_folder
61
+ else f"{project_dir}/docs"
62
+ )
63
+ await self.file_service.ensure_directory(docs_dir)
48
64
 
49
65
  # Import prompt template if it exists
50
- if prompt_entity := self._format_prompt_markdown(project):
51
- file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
66
+ if prompt_entity := self._format_prompt_markdown(project, destination_folder):
67
+ # Write file using relative path - FileService handles base_path
68
+ file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
52
69
  await self.write_entity(prompt_entity, file_path)
53
70
  prompts_imported += 1
54
71
 
55
72
  # Import project documents
56
73
  for doc in project.get("docs", []):
57
- entity = self._format_project_markdown(project, doc)
58
- file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
74
+ entity = self._format_project_markdown(project, doc, destination_folder)
75
+ # Write file using relative path - FileService handles base_path
76
+ file_path = f"{entity.frontmatter.metadata['permalink']}.md"
59
77
  await self.write_entity(entity, file_path)
60
78
  docs_imported += 1
61
79
 
@@ -68,16 +86,17 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
68
86
 
69
87
  except Exception as e: # pragma: no cover
70
88
  logger.exception("Failed to import Claude projects")
71
- return self.handle_error("Failed to import Claude projects", e) # pyright: ignore [reportReturnType]
89
+ return self.handle_error("Failed to import Claude projects", e)
72
90
 
73
91
  def _format_project_markdown(
74
- self, project: Dict[str, Any], doc: Dict[str, Any]
92
+ self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
75
93
  ) -> EntityMarkdown:
76
94
  """Format a project document as a Basic Memory entity.
77
95
 
78
96
  Args:
79
97
  project: Project data.
80
98
  doc: Document data.
99
+ destination_folder: Optional destination folder prefix.
81
100
 
82
101
  Returns:
83
102
  EntityMarkdown instance representing the document.
@@ -90,6 +109,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
90
109
  project_dir = clean_filename(project["name"])
91
110
  doc_file = clean_filename(doc["filename"])
92
111
 
112
+ # Build permalink with optional destination folder prefix
113
+ permalink = (
114
+ f"{destination_folder}/{project_dir}/docs/{doc_file}"
115
+ if destination_folder
116
+ else f"{project_dir}/docs/{doc_file}"
117
+ )
118
+
93
119
  # Create entity
94
120
  entity = EntityMarkdown(
95
121
  frontmatter=EntityFrontmatter(
@@ -98,7 +124,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
98
124
  "title": doc["filename"],
99
125
  "created": created_at,
100
126
  "modified": modified_at,
101
- "permalink": f"{project_dir}/docs/{doc_file}",
127
+ "permalink": permalink,
102
128
  "project_name": project["name"],
103
129
  "project_uuid": project["uuid"],
104
130
  "doc_uuid": doc["uuid"],
@@ -109,11 +135,14 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
109
135
 
110
136
  return entity
111
137
 
112
- def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMarkdown]:
138
+ def _format_prompt_markdown(
139
+ self, project: Dict[str, Any], destination_folder: str = ""
140
+ ) -> Optional[EntityMarkdown]:
113
141
  """Format project prompt template as a Basic Memory entity.
114
142
 
115
143
  Args:
116
144
  project: Project data.
145
+ destination_folder: Optional destination folder prefix.
117
146
 
118
147
  Returns:
119
148
  EntityMarkdown instance representing the prompt template, or None if
@@ -129,6 +158,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
129
158
  # Generate clean project directory name
130
159
  project_dir = clean_filename(project["name"])
131
160
 
161
+ # Build permalink with optional destination folder prefix
162
+ permalink = (
163
+ f"{destination_folder}/{project_dir}/prompt-template"
164
+ if destination_folder
165
+ else f"{project_dir}/prompt-template"
166
+ )
167
+
132
168
  # Create entity
133
169
  entity = EntityMarkdown(
134
170
  frontmatter=EntityFrontmatter(
@@ -137,7 +173,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
137
173
  "title": f"Prompt Template: {project['name']}",
138
174
  "created": created_at,
139
175
  "modified": modified_at,
140
- "permalink": f"{project_dir}/prompt-template",
176
+ "permalink": permalink,
141
177
  "project_name": project["name"],
142
178
  "project_uuid": project["uuid"],
143
179
  }
@@ -1,9 +1,8 @@
1
1
  """Memory JSON import service for Basic Memory."""
2
2
 
3
3
  import logging
4
- from typing import Any, Dict, List
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
- from basic_memory.config import get_project_config
7
6
  from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
8
7
  from basic_memory.importers.base import Importer
9
8
  from basic_memory.schemas.importer import EntityImportResult
@@ -14,6 +13,20 @@ logger = logging.getLogger(__name__)
14
13
  class MemoryJsonImporter(Importer[EntityImportResult]):
15
14
  """Service for importing memory.json format data."""
16
15
 
16
+ def handle_error( # pragma: no cover
17
+ self, message: str, error: Optional[Exception] = None
18
+ ) -> EntityImportResult:
19
+ """Return a failed EntityImportResult with an error message."""
20
+ error_msg = f"{message}: {error}" if error else message
21
+ return EntityImportResult(
22
+ import_count={},
23
+ success=False,
24
+ error_message=error_msg,
25
+ entities=0,
26
+ relations=0,
27
+ skipped_entities=0,
28
+ )
29
+
17
30
  async def import_data(
18
31
  self, source_data, destination_folder: str = "", **kwargs: Any
19
32
  ) -> EntityImportResult:
@@ -27,17 +40,15 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
27
40
  Returns:
28
41
  EntityImportResult containing statistics and status of the import.
29
42
  """
30
- config = get_project_config()
31
43
  try:
32
44
  # First pass - collect all relations by source entity
33
45
  entity_relations: Dict[str, List[Relation]] = {}
34
46
  entities: Dict[str, Dict[str, Any]] = {}
35
47
  skipped_entities: int = 0
36
48
 
37
- # Ensure the base path exists
38
- base_path = config.home # pragma: no cover
49
+ # Ensure the destination folder exists if provided
39
50
  if destination_folder: # pragma: no cover
40
- base_path = self.ensure_folder_exists(destination_folder)
51
+ await self.ensure_folder_exists(destination_folder)
41
52
 
42
53
  # First pass - collect entities and relations
43
54
  for line in source_data:
@@ -46,9 +57,9 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
46
57
  # Handle different possible name keys
47
58
  entity_name = data.get("name") or data.get("entityName") or data.get("id")
48
59
  if not entity_name:
49
- logger.warning(f"Entity missing name field: {data}")
50
- skipped_entities += 1
51
- continue
60
+ logger.warning(f"Entity missing name field: {data}") # pragma: no cover
61
+ skipped_entities += 1 # pragma: no cover
62
+ continue # pragma: no cover
52
63
  entities[entity_name] = data
53
64
  elif data["type"] == "relation":
54
65
  # Store relation with its source entity
@@ -68,9 +79,18 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
68
79
  # Get entity type with fallback
69
80
  entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
70
81
 
71
- # Ensure entity type directory exists
72
- entity_type_dir = base_path / entity_type
73
- entity_type_dir.mkdir(parents=True, exist_ok=True)
82
+ # Build permalink with optional destination folder prefix
83
+ permalink = (
84
+ f"{destination_folder}/{entity_type}/{name}"
85
+ if destination_folder
86
+ else f"{entity_type}/{name}"
87
+ )
88
+
89
+ # Ensure entity type directory exists using FileService with relative path
90
+ entity_type_dir = (
91
+ f"{destination_folder}/{entity_type}" if destination_folder else entity_type
92
+ )
93
+ await self.file_service.ensure_directory(entity_type_dir)
74
94
 
75
95
  # Get observations with fallback to empty list
76
96
  observations = entity_data.get("observations", [])
@@ -80,7 +100,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
80
100
  metadata={
81
101
  "type": entity_type,
82
102
  "title": name,
83
- "permalink": f"{entity_type}/{name}",
103
+ "permalink": permalink,
84
104
  }
85
105
  ),
86
106
  content=f"# {name}\n",
@@ -88,8 +108,8 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
88
108
  relations=entity_relations.get(name, []),
89
109
  )
90
110
 
91
- # Write entity file
92
- file_path = base_path / f"{entity_type}/{name}.md"
111
+ # Write file using relative path - FileService handles base_path
112
+ file_path = f"{entity.frontmatter.metadata['permalink']}.md"
93
113
  await self.write_entity(entity, file_path)
94
114
  entities_created += 1
95
115
 
@@ -105,4 +125,4 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
105
125
 
106
126
  except Exception as e: # pragma: no cover
107
127
  logger.exception("Failed to import memory.json")
108
- return self.handle_error("Failed to import memory.json", e) # pyright: ignore [reportReturnType]
128
+ return self.handle_error("Failed to import memory.json", e)
@@ -5,15 +5,18 @@ from datetime import datetime
5
5
  from typing import Any
6
6
 
7
7
 
8
- def clean_filename(name: str) -> str: # pragma: no cover
8
+ def clean_filename(name: str | None) -> str: # pragma: no cover
9
9
  """Clean a string to be used as a filename.
10
10
 
11
11
  Args:
12
- name: The string to clean.
12
+ name: The string to clean (can be None).
13
13
 
14
14
  Returns:
15
15
  A cleaned string suitable for use as a filename.
16
16
  """
17
+ # Handle None or empty input
18
+ if not name:
19
+ return "untitled"
17
20
  # Replace common punctuation and whitespace with underscores
18
21
  name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
19
22
  # Remove any non-alphanumeric or underscore characters