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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/ignore_utils.py
CHANGED
|
@@ -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:
|
basic_memory/importers/base.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) ->
|
|
44
|
-
"""Write entity to file using
|
|
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:
|
|
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
|
-
|
|
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) ->
|
|
53
|
-
"""Ensure folder exists
|
|
76
|
+
async def ensure_folder_exists(self, folder: str) -> None:
|
|
77
|
+
"""Ensure folder exists using FileService.
|
|
54
78
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
82
|
+
Args:
|
|
83
|
+
folder: Relative folder path within the project. FileService handles base_path.
|
|
61
84
|
"""
|
|
62
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
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 =
|
|
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)
|
|
83
|
+
return self.handle_error("Failed to import Claude conversations", e)
|
|
69
84
|
|
|
70
85
|
def _format_chat_content(
|
|
71
86
|
self,
|
|
72
|
-
|
|
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
|
-
|
|
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"{
|
|
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
|
-
|
|
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 =
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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":
|
|
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(
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
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":
|
|
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
|
|
92
|
-
file_path =
|
|
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)
|
|
128
|
+
return self.handle_error("Failed to import memory.json", e)
|
basic_memory/importers/utils.py
CHANGED
|
@@ -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
|