basic-memory 0.12.3__py3-none-any.whl → 0.13.0b1__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 (107) hide show
  1. basic_memory/__init__.py +7 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
  5. basic_memory/api/app.py +43 -13
  6. basic_memory/api/routers/__init__.py +4 -2
  7. basic_memory/api/routers/directory_router.py +63 -0
  8. basic_memory/api/routers/importer_router.py +152 -0
  9. basic_memory/api/routers/knowledge_router.py +127 -38
  10. basic_memory/api/routers/management_router.py +78 -0
  11. basic_memory/api/routers/memory_router.py +4 -59
  12. basic_memory/api/routers/project_router.py +230 -0
  13. basic_memory/api/routers/prompt_router.py +260 -0
  14. basic_memory/api/routers/search_router.py +3 -21
  15. basic_memory/api/routers/utils.py +130 -0
  16. basic_memory/api/template_loader.py +292 -0
  17. basic_memory/cli/app.py +20 -21
  18. basic_memory/cli/commands/__init__.py +2 -1
  19. basic_memory/cli/commands/auth.py +136 -0
  20. basic_memory/cli/commands/db.py +3 -3
  21. basic_memory/cli/commands/import_chatgpt.py +31 -207
  22. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  23. basic_memory/cli/commands/import_claude_projects.py +33 -143
  24. basic_memory/cli/commands/import_memory_json.py +26 -83
  25. basic_memory/cli/commands/mcp.py +71 -18
  26. basic_memory/cli/commands/project.py +99 -67
  27. basic_memory/cli/commands/status.py +19 -9
  28. basic_memory/cli/commands/sync.py +44 -58
  29. basic_memory/cli/main.py +1 -5
  30. basic_memory/config.py +145 -88
  31. basic_memory/db.py +6 -4
  32. basic_memory/deps.py +227 -30
  33. basic_memory/importers/__init__.py +27 -0
  34. basic_memory/importers/base.py +79 -0
  35. basic_memory/importers/chatgpt_importer.py +222 -0
  36. basic_memory/importers/claude_conversations_importer.py +172 -0
  37. basic_memory/importers/claude_projects_importer.py +148 -0
  38. basic_memory/importers/memory_json_importer.py +93 -0
  39. basic_memory/importers/utils.py +58 -0
  40. basic_memory/markdown/entity_parser.py +5 -2
  41. basic_memory/mcp/auth_provider.py +270 -0
  42. basic_memory/mcp/external_auth_provider.py +321 -0
  43. basic_memory/mcp/project_session.py +103 -0
  44. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  45. basic_memory/mcp/prompts/recent_activity.py +19 -3
  46. basic_memory/mcp/prompts/search.py +14 -140
  47. basic_memory/mcp/prompts/utils.py +3 -3
  48. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  49. basic_memory/mcp/server.py +82 -8
  50. basic_memory/mcp/supabase_auth_provider.py +463 -0
  51. basic_memory/mcp/tools/__init__.py +20 -0
  52. basic_memory/mcp/tools/build_context.py +11 -1
  53. basic_memory/mcp/tools/canvas.py +15 -2
  54. basic_memory/mcp/tools/delete_note.py +12 -4
  55. basic_memory/mcp/tools/edit_note.py +297 -0
  56. basic_memory/mcp/tools/list_directory.py +154 -0
  57. basic_memory/mcp/tools/move_note.py +87 -0
  58. basic_memory/mcp/tools/project_management.py +300 -0
  59. basic_memory/mcp/tools/read_content.py +15 -6
  60. basic_memory/mcp/tools/read_note.py +17 -5
  61. basic_memory/mcp/tools/recent_activity.py +11 -2
  62. basic_memory/mcp/tools/search.py +10 -1
  63. basic_memory/mcp/tools/utils.py +137 -12
  64. basic_memory/mcp/tools/write_note.py +11 -15
  65. basic_memory/models/__init__.py +3 -2
  66. basic_memory/models/knowledge.py +16 -4
  67. basic_memory/models/project.py +80 -0
  68. basic_memory/models/search.py +8 -5
  69. basic_memory/repository/__init__.py +2 -0
  70. basic_memory/repository/entity_repository.py +8 -3
  71. basic_memory/repository/observation_repository.py +35 -3
  72. basic_memory/repository/project_info_repository.py +3 -2
  73. basic_memory/repository/project_repository.py +85 -0
  74. basic_memory/repository/relation_repository.py +8 -2
  75. basic_memory/repository/repository.py +107 -15
  76. basic_memory/repository/search_repository.py +87 -27
  77. basic_memory/schemas/__init__.py +6 -0
  78. basic_memory/schemas/directory.py +30 -0
  79. basic_memory/schemas/importer.py +34 -0
  80. basic_memory/schemas/memory.py +26 -12
  81. basic_memory/schemas/project_info.py +112 -2
  82. basic_memory/schemas/prompt.py +90 -0
  83. basic_memory/schemas/request.py +56 -2
  84. basic_memory/schemas/search.py +1 -1
  85. basic_memory/services/__init__.py +2 -1
  86. basic_memory/services/context_service.py +208 -95
  87. basic_memory/services/directory_service.py +167 -0
  88. basic_memory/services/entity_service.py +385 -5
  89. basic_memory/services/exceptions.py +6 -0
  90. basic_memory/services/file_service.py +14 -15
  91. basic_memory/services/initialization.py +144 -67
  92. basic_memory/services/link_resolver.py +16 -8
  93. basic_memory/services/project_service.py +548 -0
  94. basic_memory/services/search_service.py +77 -2
  95. basic_memory/sync/background_sync.py +25 -0
  96. basic_memory/sync/sync_service.py +10 -9
  97. basic_memory/sync/watch_service.py +63 -39
  98. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  99. basic_memory/templates/prompts/search.hbs +101 -0
  100. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/METADATA +23 -1
  101. basic_memory-0.13.0b1.dist-info/RECORD +132 -0
  102. basic_memory/api/routers/project_info_router.py +0 -274
  103. basic_memory/mcp/main.py +0 -24
  104. basic_memory-0.12.3.dist-info/RECORD +0 -100
  105. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/WHEEL +0 -0
  106. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/entry_points.txt +0 -0
  107. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/licenses/LICENSE +0 -0
basic_memory/deps.py CHANGED
@@ -1,50 +1,87 @@
1
1
  """Dependency injection functions for basic-memory services."""
2
2
 
3
3
  from typing import Annotated
4
+ from loguru import logger
4
5
 
5
- from fastapi import Depends
6
+ from fastapi import Depends, HTTPException, Path, status
6
7
  from sqlalchemy.ext.asyncio import (
7
8
  AsyncSession,
8
9
  AsyncEngine,
9
10
  async_sessionmaker,
10
11
  )
12
+ import pathlib
11
13
 
12
14
  from basic_memory import db
13
- from basic_memory.config import ProjectConfig, config
15
+ from basic_memory.config import ProjectConfig, BasicMemoryConfig
16
+ from basic_memory.importers import (
17
+ ChatGPTImporter,
18
+ ClaudeConversationsImporter,
19
+ ClaudeProjectsImporter,
20
+ MemoryJsonImporter,
21
+ )
14
22
  from basic_memory.markdown import EntityParser
15
23
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
16
24
  from basic_memory.repository.entity_repository import EntityRepository
17
25
  from basic_memory.repository.observation_repository import ObservationRepository
18
- from basic_memory.repository.project_info_repository import ProjectInfoRepository
26
+ from basic_memory.repository.project_repository import ProjectRepository
19
27
  from basic_memory.repository.relation_repository import RelationRepository
20
28
  from basic_memory.repository.search_repository import SearchRepository
21
- from basic_memory.services import (
22
- EntityService,
23
- )
29
+ from basic_memory.services import EntityService, ProjectService
24
30
  from basic_memory.services.context_service import ContextService
31
+ from basic_memory.services.directory_service import DirectoryService
25
32
  from basic_memory.services.file_service import FileService
26
33
  from basic_memory.services.link_resolver import LinkResolver
27
34
  from basic_memory.services.search_service import SearchService
35
+ from basic_memory.sync import SyncService
36
+ from basic_memory.config import app_config
37
+
38
+
39
+ def get_app_config() -> BasicMemoryConfig: # pragma: no cover
40
+ return app_config
41
+
42
+
43
+ AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma: no cover
28
44
 
29
45
 
30
46
  ## project
31
47
 
32
48
 
33
- def get_project_config() -> ProjectConfig: # pragma: no cover
34
- return config
49
+ async def get_project_config(
50
+ project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
51
+ ) -> ProjectConfig: # pragma: no cover
52
+ """Get the current project referenced from request state.
35
53
 
54
+ Args:
55
+ request: The current request object
56
+ project_repository: Repository for project operations
36
57
 
37
- ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
58
+ Returns:
59
+ The resolved project config
60
+
61
+ Raises:
62
+ HTTPException: If project is not found
63
+ """
64
+
65
+ project_obj = await project_repository.get_by_permalink(str(project))
66
+ if project_obj:
67
+ return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
68
+
69
+ # Not found
70
+ raise HTTPException( # pragma: no cover
71
+ status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
72
+ )
38
73
 
39
74
 
75
+ ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
76
+
40
77
  ## sqlalchemy
41
78
 
42
79
 
43
80
  async def get_engine_factory(
44
- project_config: ProjectConfigDep,
81
+ app_config: AppConfigDep,
45
82
  ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
46
83
  """Get engine and session maker."""
47
- engine, session_maker = await db.get_or_create_db(project_config.database_path)
84
+ engine, session_maker = await db.get_or_create_db(app_config.database_path)
48
85
  return engine, session_maker
49
86
 
50
87
 
@@ -65,11 +102,70 @@ SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
65
102
  ## repositories
66
103
 
67
104
 
105
+ async def get_project_repository(
106
+ session_maker: SessionMakerDep,
107
+ ) -> ProjectRepository:
108
+ """Get the project repository."""
109
+ return ProjectRepository(session_maker)
110
+
111
+
112
+ ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_repository)]
113
+ ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
114
+
115
+
116
+ async def get_project_id(
117
+ project_repository: ProjectRepositoryDep,
118
+ project: ProjectPathDep,
119
+ ) -> int:
120
+ """Get the current project ID from request state.
121
+
122
+ When using sub-applications with /{project} mounting, the project value
123
+ is stored in request.state by middleware.
124
+
125
+ Args:
126
+ request: The current request object
127
+ project_repository: Repository for project operations
128
+
129
+ Returns:
130
+ The resolved project ID
131
+
132
+ Raises:
133
+ HTTPException: If project is not found
134
+ """
135
+
136
+ # Try by permalink first (most common case with URL paths)
137
+ project_obj = await project_repository.get_by_permalink(str(project))
138
+ if project_obj:
139
+ return project_obj.id
140
+
141
+ # Try by name if permalink lookup fails
142
+ project_obj = await project_repository.get_by_name(str(project)) # pragma: no cover
143
+ if project_obj: # pragma: no cover
144
+ return project_obj.id
145
+
146
+ # Not found
147
+ raise HTTPException( # pragma: no cover
148
+ status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
149
+ )
150
+
151
+
152
+ """
153
+ The project_id dependency is used in the following:
154
+ - EntityRepository
155
+ - ObservationRepository
156
+ - RelationRepository
157
+ - SearchRepository
158
+ - ProjectInfoRepository
159
+ """
160
+ ProjectIdDep = Annotated[int, Depends(get_project_id)]
161
+
162
+
68
163
  async def get_entity_repository(
69
164
  session_maker: SessionMakerDep,
165
+ project_id: ProjectIdDep,
70
166
  ) -> EntityRepository:
71
- """Create an EntityRepository instance."""
72
- return EntityRepository(session_maker)
167
+ """Create an EntityRepository instance for the current project."""
168
+ return EntityRepository(session_maker, project_id=project_id)
73
169
 
74
170
 
75
171
  EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
@@ -77,9 +173,10 @@ EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)
77
173
 
78
174
  async def get_observation_repository(
79
175
  session_maker: SessionMakerDep,
176
+ project_id: ProjectIdDep,
80
177
  ) -> ObservationRepository:
81
- """Create an ObservationRepository instance."""
82
- return ObservationRepository(session_maker)
178
+ """Create an ObservationRepository instance for the current project."""
179
+ return ObservationRepository(session_maker, project_id=project_id)
83
180
 
84
181
 
85
182
  ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
@@ -87,9 +184,10 @@ ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observat
87
184
 
88
185
  async def get_relation_repository(
89
186
  session_maker: SessionMakerDep,
187
+ project_id: ProjectIdDep,
90
188
  ) -> RelationRepository:
91
- """Create a RelationRepository instance."""
92
- return RelationRepository(session_maker)
189
+ """Create a RelationRepository instance for the current project."""
190
+ return RelationRepository(session_maker, project_id=project_id)
93
191
 
94
192
 
95
193
  RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
@@ -97,22 +195,17 @@ RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repos
97
195
 
98
196
  async def get_search_repository(
99
197
  session_maker: SessionMakerDep,
198
+ project_id: ProjectIdDep,
100
199
  ) -> SearchRepository:
101
- """Create a SearchRepository instance."""
102
- return SearchRepository(session_maker)
200
+ """Create a SearchRepository instance for the current project."""
201
+ return SearchRepository(session_maker, project_id=project_id)
103
202
 
104
203
 
105
204
  SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
106
205
 
107
206
 
108
- def get_project_info_repository(
109
- session_maker: SessionMakerDep,
110
- ):
111
- """Dependency for StatsRepository."""
112
- return ProjectInfoRepository(session_maker)
113
-
114
-
115
- ProjectInfoRepositoryDep = Annotated[ProjectInfoRepository, Depends(get_project_info_repository)]
207
+ # ProjectInfoRepository is deprecated and will be removed in a future version.
208
+ # Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
116
209
 
117
210
  ## services
118
211
 
@@ -134,7 +227,12 @@ MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_process
134
227
  async def get_file_service(
135
228
  project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
136
229
  ) -> FileService:
137
- return FileService(project_config.home, markdown_processor)
230
+ logger.debug(
231
+ f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
232
+ )
233
+ file_service = FileService(project_config.home, markdown_processor)
234
+ logger.debug(f"Created FileService for project: {file_service} ")
235
+ return file_service
138
236
 
139
237
 
140
238
  FileServiceDep = Annotated[FileService, Depends(get_file_service)]
@@ -184,9 +282,108 @@ LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
184
282
 
185
283
 
186
284
  async def get_context_service(
187
- search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep
285
+ search_repository: SearchRepositoryDep,
286
+ entity_repository: EntityRepositoryDep,
287
+ observation_repository: ObservationRepositoryDep,
188
288
  ) -> ContextService:
189
- return ContextService(search_repository, entity_repository)
289
+ return ContextService(
290
+ search_repository=search_repository,
291
+ entity_repository=entity_repository,
292
+ observation_repository=observation_repository,
293
+ )
190
294
 
191
295
 
192
296
  ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
297
+
298
+
299
+ async def get_sync_service(
300
+ entity_service: EntityServiceDep,
301
+ entity_parser: EntityParserDep,
302
+ entity_repository: EntityRepositoryDep,
303
+ relation_repository: RelationRepositoryDep,
304
+ search_service: SearchServiceDep,
305
+ file_service: FileServiceDep,
306
+ ) -> SyncService: # pragma: no cover
307
+ """
308
+
309
+ :rtype: object
310
+ """
311
+ return SyncService(
312
+ app_config=app_config,
313
+ entity_service=entity_service,
314
+ entity_parser=entity_parser,
315
+ entity_repository=entity_repository,
316
+ relation_repository=relation_repository,
317
+ search_service=search_service,
318
+ file_service=file_service,
319
+ )
320
+
321
+
322
+ SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
323
+
324
+
325
+ async def get_project_service(
326
+ project_repository: ProjectRepositoryDep,
327
+ ) -> ProjectService:
328
+ """Create ProjectService with repository."""
329
+ return ProjectService(repository=project_repository)
330
+
331
+
332
+ ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
333
+
334
+
335
+ async def get_directory_service(
336
+ entity_repository: EntityRepositoryDep,
337
+ ) -> DirectoryService:
338
+ """Create DirectoryService with dependencies."""
339
+ return DirectoryService(
340
+ entity_repository=entity_repository,
341
+ )
342
+
343
+
344
+ DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
345
+
346
+
347
+ # Import
348
+
349
+
350
+ async def get_chatgpt_importer(
351
+ project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
352
+ ) -> ChatGPTImporter:
353
+ """Create ChatGPTImporter with dependencies."""
354
+ return ChatGPTImporter(project_config.home, markdown_processor)
355
+
356
+
357
+ ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
358
+
359
+
360
+ async def get_claude_conversations_importer(
361
+ project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
362
+ ) -> ClaudeConversationsImporter:
363
+ """Create ChatGPTImporter with dependencies."""
364
+ return ClaudeConversationsImporter(project_config.home, markdown_processor)
365
+
366
+
367
+ ClaudeConversationsImporterDep = Annotated[
368
+ ClaudeConversationsImporter, Depends(get_claude_conversations_importer)
369
+ ]
370
+
371
+
372
+ async def get_claude_projects_importer(
373
+ project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
374
+ ) -> ClaudeProjectsImporter:
375
+ """Create ChatGPTImporter with dependencies."""
376
+ return ClaudeProjectsImporter(project_config.home, markdown_processor)
377
+
378
+
379
+ ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
380
+
381
+
382
+ async def get_memory_json_importer(
383
+ project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
384
+ ) -> MemoryJsonImporter:
385
+ """Create ChatGPTImporter with dependencies."""
386
+ return MemoryJsonImporter(project_config.home, markdown_processor)
387
+
388
+
389
+ MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
@@ -0,0 +1,27 @@
1
+ """Import services for Basic Memory."""
2
+
3
+ from basic_memory.importers.base import Importer
4
+ from basic_memory.importers.chatgpt_importer import ChatGPTImporter
5
+ from basic_memory.importers.claude_conversations_importer import (
6
+ ClaudeConversationsImporter,
7
+ )
8
+ from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
9
+ from basic_memory.importers.memory_json_importer import MemoryJsonImporter
10
+ from basic_memory.schemas.importer import (
11
+ ChatImportResult,
12
+ EntityImportResult,
13
+ ImportResult,
14
+ ProjectImportResult,
15
+ )
16
+
17
+ __all__ = [
18
+ "Importer",
19
+ "ChatGPTImporter",
20
+ "ClaudeConversationsImporter",
21
+ "ClaudeProjectsImporter",
22
+ "MemoryJsonImporter",
23
+ "ImportResult",
24
+ "ChatImportResult",
25
+ "EntityImportResult",
26
+ "ProjectImportResult",
27
+ ]
@@ -0,0 +1,79 @@
1
+ """Base import service for Basic Memory."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+ from pathlib import Path
6
+ from typing import Any, Optional, TypeVar
7
+
8
+ from basic_memory.markdown.markdown_processor import MarkdownProcessor
9
+ from basic_memory.markdown.schemas import EntityMarkdown
10
+ from basic_memory.schemas.importer import ImportResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ T = TypeVar("T", bound=ImportResult)
15
+
16
+
17
+ class Importer[T: ImportResult]:
18
+ """Base class for all import services."""
19
+
20
+ def __init__(self, base_path: Path, markdown_processor: MarkdownProcessor):
21
+ """Initialize the import service.
22
+
23
+ Args:
24
+ markdown_processor: MarkdownProcessor instance for writing markdown files.
25
+ """
26
+ self.base_path = base_path.resolve() # Get absolute path
27
+ self.markdown_processor = markdown_processor
28
+
29
+ @abstractmethod
30
+ async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
31
+ """Import data from source file to destination folder.
32
+
33
+ Args:
34
+ source_path: Path to the source file.
35
+ destination_folder: Destination folder within the project.
36
+ **kwargs: Additional keyword arguments for specific import types.
37
+
38
+ Returns:
39
+ ImportResult containing statistics and status of the import.
40
+ """
41
+ pass # pragma: no cover
42
+
43
+ async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> None:
44
+ """Write entity to file using markdown processor.
45
+
46
+ Args:
47
+ entity: EntityMarkdown instance to write.
48
+ file_path: Path to write the entity to.
49
+ """
50
+ await self.markdown_processor.write_file(file_path, entity)
51
+
52
+ def ensure_folder_exists(self, folder: str) -> Path:
53
+ """Ensure folder exists, create if it doesn't.
54
+
55
+ Args:
56
+ base_path: Base path of the project.
57
+ folder: Folder name or path within the project.
58
+
59
+ Returns:
60
+ Path to the folder.
61
+ """
62
+ folder_path = self.base_path / folder
63
+ folder_path.mkdir(parents=True, exist_ok=True)
64
+ return folder_path
65
+
66
+ @abstractmethod
67
+ def handle_error(
68
+ self, message: str, error: Optional[Exception] = None
69
+ ) -> T: # pragma: no cover
70
+ """Handle errors during import.
71
+
72
+ Args:
73
+ message: Error message.
74
+ error: Optional exception that caused the error.
75
+
76
+ Returns:
77
+ ImportResult with error information.
78
+ """
79
+ pass
@@ -0,0 +1,222 @@
1
+ """ChatGPT import service for Basic Memory."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional, Set
6
+
7
+ from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
8
+ from basic_memory.importers.base import Importer
9
+ from basic_memory.schemas.importer import ChatImportResult
10
+ from basic_memory.importers.utils import clean_filename, format_timestamp
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ChatGPTImporter(Importer[ChatImportResult]):
16
+ """Service for importing ChatGPT conversations."""
17
+
18
+ async def import_data(
19
+ self, source_data, destination_folder: str, **kwargs: Any
20
+ ) -> ChatImportResult:
21
+ """Import conversations from ChatGPT JSON export.
22
+
23
+ Args:
24
+ source_path: Path to the ChatGPT conversations.json file.
25
+ destination_folder: Destination folder within the project.
26
+ **kwargs: Additional keyword arguments.
27
+
28
+ Returns:
29
+ ChatImportResult containing statistics and status of the import.
30
+ """
31
+ try: # pragma: no cover
32
+ # Ensure the destination folder exists
33
+ self.ensure_folder_exists(destination_folder)
34
+ conversations = source_data
35
+
36
+ # Process each conversation
37
+ messages_imported = 0
38
+ chats_imported = 0
39
+
40
+ for chat in conversations:
41
+ # Convert to entity
42
+ entity = self._format_chat_content(destination_folder, chat)
43
+
44
+ # Write file
45
+ file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
46
+ await self.write_entity(entity, file_path)
47
+
48
+ # Count messages
49
+ msg_count = sum(
50
+ 1
51
+ for node in chat["mapping"].values()
52
+ if node.get("message")
53
+ and not node.get("message", {})
54
+ .get("metadata", {})
55
+ .get("is_visually_hidden_from_conversation")
56
+ )
57
+
58
+ chats_imported += 1
59
+ messages_imported += msg_count
60
+
61
+ return ChatImportResult(
62
+ import_count={"conversations": chats_imported, "messages": messages_imported},
63
+ success=True,
64
+ conversations=chats_imported,
65
+ messages=messages_imported,
66
+ )
67
+
68
+ except Exception as e: # pragma: no cover
69
+ logger.exception("Failed to import ChatGPT conversations")
70
+ return self.handle_error("Failed to import ChatGPT conversations", e) # pyright: ignore [reportReturnType]
71
+
72
+ def _format_chat_content(
73
+ self, folder: str, conversation: Dict[str, Any]
74
+ ) -> EntityMarkdown: # pragma: no cover
75
+ """Convert chat conversation to Basic Memory entity.
76
+
77
+ Args:
78
+ folder: Destination folder name.
79
+ conversation: ChatGPT conversation data.
80
+
81
+ Returns:
82
+ EntityMarkdown instance representing the conversation.
83
+ """
84
+ # Extract timestamps
85
+ created_at = conversation["create_time"]
86
+ modified_at = conversation["update_time"]
87
+
88
+ root_id = None
89
+ # Find root message
90
+ for node_id, node in conversation["mapping"].items():
91
+ if node.get("parent") is None:
92
+ root_id = node_id
93
+ break
94
+
95
+ # Generate permalink
96
+ date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
97
+ clean_title = clean_filename(conversation["title"])
98
+
99
+ # Format content
100
+ content = self._format_chat_markdown(
101
+ title=conversation["title"],
102
+ mapping=conversation["mapping"],
103
+ root_id=root_id,
104
+ created_at=created_at,
105
+ modified_at=modified_at,
106
+ )
107
+
108
+ # Create entity
109
+ entity = EntityMarkdown(
110
+ frontmatter=EntityFrontmatter(
111
+ metadata={
112
+ "type": "conversation",
113
+ "title": conversation["title"],
114
+ "created": format_timestamp(created_at),
115
+ "modified": format_timestamp(modified_at),
116
+ "permalink": f"{folder}/{date_prefix}-{clean_title}",
117
+ }
118
+ ),
119
+ content=content,
120
+ )
121
+
122
+ return entity
123
+
124
+ def _format_chat_markdown(
125
+ self,
126
+ title: str,
127
+ mapping: Dict[str, Any],
128
+ root_id: Optional[str],
129
+ created_at: float,
130
+ modified_at: float,
131
+ ) -> str: # pragma: no cover
132
+ """Format chat as clean markdown.
133
+
134
+ Args:
135
+ title: Chat title.
136
+ mapping: Message mapping.
137
+ root_id: Root message ID.
138
+ created_at: Creation timestamp.
139
+ modified_at: Modification timestamp.
140
+
141
+ Returns:
142
+ Formatted markdown content.
143
+ """
144
+ # Start with title
145
+ lines = [f"# {title}\n"]
146
+
147
+ # Traverse message tree
148
+ seen_msgs: Set[str] = set()
149
+ messages = self._traverse_messages(mapping, root_id, seen_msgs)
150
+
151
+ # Format each message
152
+ for msg in messages:
153
+ # Skip hidden messages
154
+ if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
155
+ continue
156
+
157
+ # Get author and timestamp
158
+ author = msg["author"]["role"].title()
159
+ ts = format_timestamp(msg["create_time"]) if msg.get("create_time") else ""
160
+
161
+ # Add message header
162
+ lines.append(f"### {author} ({ts})")
163
+
164
+ # Add message content
165
+ content = self._get_message_content(msg)
166
+ if content:
167
+ lines.append(content)
168
+
169
+ # Add spacing
170
+ lines.append("")
171
+
172
+ return "\n".join(lines)
173
+
174
+ def _get_message_content(self, message: Dict[str, Any]) -> str: # pragma: no cover
175
+ """Extract clean message content.
176
+
177
+ Args:
178
+ message: Message data.
179
+
180
+ Returns:
181
+ Cleaned message content.
182
+ """
183
+ if not message or "content" not in message:
184
+ return ""
185
+
186
+ content = message["content"]
187
+ if content.get("content_type") == "text":
188
+ return "\n".join(content.get("parts", []))
189
+ elif content.get("content_type") == "code":
190
+ return f"```{content.get('language', '')}\n{content.get('text', '')}\n```"
191
+ return ""
192
+
193
+ def _traverse_messages(
194
+ self, mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
195
+ ) -> List[Dict[str, Any]]: # pragma: no cover
196
+ """Traverse message tree and return messages in order.
197
+
198
+ Args:
199
+ mapping: Message mapping.
200
+ root_id: Root message ID.
201
+ seen: Set of seen message IDs.
202
+
203
+ Returns:
204
+ List of message data.
205
+ """
206
+ messages = []
207
+ node = mapping.get(root_id) if root_id else None
208
+
209
+ while node:
210
+ if node["id"] not in seen and node.get("message"):
211
+ seen.add(node["id"])
212
+ messages.append(node["message"])
213
+
214
+ # Follow children
215
+ children = node.get("children", [])
216
+ for child_id in children:
217
+ child_msgs = self._traverse_messages(mapping, child_id, seen)
218
+ messages.extend(child_msgs)
219
+
220
+ break # Don't follow siblings
221
+
222
+ return messages