basic-memory 0.12.3__py3-none-any.whl → 0.13.0__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 +2 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +26 -7
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.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,
|
|
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.
|
|
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(
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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,
|
|
285
|
+
search_repository: SearchRepositoryDep,
|
|
286
|
+
entity_repository: EntityRepositoryDep,
|
|
287
|
+
observation_repository: ObservationRepositoryDep,
|
|
188
288
|
) -> ContextService:
|
|
189
|
-
return ContextService(
|
|
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
|