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

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

Potentially problematic release.


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

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/deps.py CHANGED
@@ -1,411 +1,16 @@
1
- """Dependency injection functions for basic-memory services."""
2
-
3
- from typing import Annotated
4
- from loguru import logger
5
-
6
- from fastapi import Depends, HTTPException, Path, status, Request
7
- from sqlalchemy.ext.asyncio import (
8
- AsyncSession,
9
- AsyncEngine,
10
- async_sessionmaker,
11
- )
12
- import pathlib
13
-
14
- from basic_memory import db
15
- from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
16
- from basic_memory.importers import (
17
- ChatGPTImporter,
18
- ClaudeConversationsImporter,
19
- ClaudeProjectsImporter,
20
- MemoryJsonImporter,
21
- )
22
- from basic_memory.markdown import EntityParser
23
- from basic_memory.markdown.markdown_processor import MarkdownProcessor
24
- from basic_memory.repository.entity_repository import EntityRepository
25
- from basic_memory.repository.observation_repository import ObservationRepository
26
- from basic_memory.repository.project_repository import ProjectRepository
27
- from basic_memory.repository.relation_repository import RelationRepository
28
- from basic_memory.repository.search_repository import SearchRepository
29
- from basic_memory.services import EntityService, ProjectService
30
- from basic_memory.services.context_service import ContextService
31
- from basic_memory.services.directory_service import DirectoryService
32
- from basic_memory.services.file_service import FileService
33
- from basic_memory.services.link_resolver import LinkResolver
34
- from basic_memory.services.search_service import SearchService
35
- from basic_memory.sync import SyncService
36
- from basic_memory.utils import generate_permalink
37
-
38
-
39
- def get_app_config() -> BasicMemoryConfig: # pragma: no cover
40
- app_config = ConfigManager().config
41
- return app_config
42
-
43
-
44
- AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma: no cover
45
-
46
-
47
- ## project
48
-
49
-
50
- async def get_project_config(
51
- project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
52
- ) -> ProjectConfig: # pragma: no cover
53
- """Get the current project referenced from request state.
54
-
55
- Args:
56
- request: The current request object
57
- project_repository: Repository for project operations
58
-
59
- Returns:
60
- The resolved project config
61
-
62
- Raises:
63
- HTTPException: If project is not found
64
- """
65
- # Convert project name to permalink for lookup
66
- project_permalink = generate_permalink(str(project))
67
- project_obj = await project_repository.get_by_permalink(project_permalink)
68
- if project_obj:
69
- return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
70
-
71
- # Not found
72
- raise HTTPException( # pragma: no cover
73
- status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
74
- )
75
-
76
-
77
- ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
78
-
79
- ## sqlalchemy
80
-
81
-
82
- async def get_engine_factory(
83
- request: Request,
84
- ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
85
- """Get cached engine and session maker from app state.
86
-
87
- For API requests, returns cached connections from app.state for optimal performance.
88
- For non-API contexts (CLI), falls back to direct database connection.
89
- """
90
- # Try to get cached connections from app state (API context)
91
- if (
92
- hasattr(request, "app")
93
- and hasattr(request.app.state, "engine")
94
- and hasattr(request.app.state, "session_maker")
95
- ):
96
- return request.app.state.engine, request.app.state.session_maker
97
-
98
- # Fallback for non-API contexts (CLI)
99
- logger.debug("Using fallback database connection for non-API context")
100
- app_config = get_app_config()
101
- engine, session_maker = await db.get_or_create_db(app_config.database_path)
102
- return engine, session_maker
103
-
104
-
105
- EngineFactoryDep = Annotated[
106
- tuple[AsyncEngine, async_sessionmaker[AsyncSession]], Depends(get_engine_factory)
107
- ]
108
-
109
-
110
- async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionmaker[AsyncSession]:
111
- """Get session maker."""
112
- _, session_maker = engine_factory
113
- return session_maker
114
-
115
-
116
- SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
117
-
118
-
119
- ## repositories
120
-
121
-
122
- async def get_project_repository(
123
- session_maker: SessionMakerDep,
124
- ) -> ProjectRepository:
125
- """Get the project repository."""
126
- return ProjectRepository(session_maker)
127
-
128
-
129
- ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_repository)]
130
- ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
131
-
132
-
133
- async def get_project_id(
134
- project_repository: ProjectRepositoryDep,
135
- project: ProjectPathDep,
136
- ) -> int:
137
- """Get the current project ID from request state.
138
-
139
- When using sub-applications with /{project} mounting, the project value
140
- is stored in request.state by middleware.
141
-
142
- Args:
143
- request: The current request object
144
- project_repository: Repository for project operations
145
-
146
- Returns:
147
- The resolved project ID
148
-
149
- Raises:
150
- HTTPException: If project is not found
151
- """
152
- # Convert project name to permalink for lookup
153
- project_permalink = generate_permalink(str(project))
154
- project_obj = await project_repository.get_by_permalink(project_permalink)
155
- if project_obj:
156
- return project_obj.id
157
-
158
- # Try by name if permalink lookup fails
159
- project_obj = await project_repository.get_by_name(str(project)) # pragma: no cover
160
- if project_obj: # pragma: no cover
161
- return project_obj.id
162
-
163
- # Not found
164
- raise HTTPException( # pragma: no cover
165
- status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
166
- )
167
-
168
-
169
- """
170
- The project_id dependency is used in the following:
171
- - EntityRepository
172
- - ObservationRepository
173
- - RelationRepository
174
- - SearchRepository
175
- - ProjectInfoRepository
1
+ """Dependency injection functions for basic-memory services.
2
+
3
+ DEPRECATED: This module is a backwards-compatibility shim.
4
+ Import from basic_memory.deps package submodules instead:
5
+ - basic_memory.deps.config for configuration
6
+ - basic_memory.deps.db for database/session
7
+ - basic_memory.deps.projects for project resolution
8
+ - basic_memory.deps.repositories for data access
9
+ - basic_memory.deps.services for business logic
10
+ - basic_memory.deps.importers for import functionality
11
+
12
+ This file will be removed once all callers are migrated.
176
13
  """
177
- ProjectIdDep = Annotated[int, Depends(get_project_id)]
178
-
179
-
180
- async def get_entity_repository(
181
- session_maker: SessionMakerDep,
182
- project_id: ProjectIdDep,
183
- ) -> EntityRepository:
184
- """Create an EntityRepository instance for the current project."""
185
- return EntityRepository(session_maker, project_id=project_id)
186
-
187
-
188
- EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
189
-
190
-
191
- async def get_observation_repository(
192
- session_maker: SessionMakerDep,
193
- project_id: ProjectIdDep,
194
- ) -> ObservationRepository:
195
- """Create an ObservationRepository instance for the current project."""
196
- return ObservationRepository(session_maker, project_id=project_id)
197
-
198
-
199
- ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
200
-
201
-
202
- async def get_relation_repository(
203
- session_maker: SessionMakerDep,
204
- project_id: ProjectIdDep,
205
- ) -> RelationRepository:
206
- """Create a RelationRepository instance for the current project."""
207
- return RelationRepository(session_maker, project_id=project_id)
208
-
209
-
210
- RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
211
-
212
-
213
- async def get_search_repository(
214
- session_maker: SessionMakerDep,
215
- project_id: ProjectIdDep,
216
- ) -> SearchRepository:
217
- """Create a SearchRepository instance for the current project."""
218
- return SearchRepository(session_maker, project_id=project_id)
219
-
220
-
221
- SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
222
-
223
-
224
- # ProjectInfoRepository is deprecated and will be removed in a future version.
225
- # Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
226
-
227
- ## services
228
-
229
-
230
- async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
231
- return EntityParser(project_config.home)
232
-
233
-
234
- EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
235
-
236
-
237
- async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
238
- return MarkdownProcessor(entity_parser)
239
-
240
-
241
- MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
242
-
243
-
244
- async def get_file_service(
245
- project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
246
- ) -> FileService:
247
- logger.debug(
248
- f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
249
- )
250
- file_service = FileService(project_config.home, markdown_processor)
251
- logger.debug(f"Created FileService for project: {file_service} ")
252
- return file_service
253
-
254
-
255
- FileServiceDep = Annotated[FileService, Depends(get_file_service)]
256
-
257
-
258
- async def get_entity_service(
259
- entity_repository: EntityRepositoryDep,
260
- observation_repository: ObservationRepositoryDep,
261
- relation_repository: RelationRepositoryDep,
262
- entity_parser: EntityParserDep,
263
- file_service: FileServiceDep,
264
- link_resolver: "LinkResolverDep",
265
- app_config: AppConfigDep,
266
- ) -> EntityService:
267
- """Create EntityService with repository."""
268
- return EntityService(
269
- entity_repository=entity_repository,
270
- observation_repository=observation_repository,
271
- relation_repository=relation_repository,
272
- entity_parser=entity_parser,
273
- file_service=file_service,
274
- link_resolver=link_resolver,
275
- app_config=app_config,
276
- )
277
-
278
-
279
- EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
280
-
281
-
282
- async def get_search_service(
283
- search_repository: SearchRepositoryDep,
284
- entity_repository: EntityRepositoryDep,
285
- file_service: FileServiceDep,
286
- ) -> SearchService:
287
- """Create SearchService with dependencies."""
288
- return SearchService(search_repository, entity_repository, file_service)
289
-
290
-
291
- SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
292
-
293
-
294
- async def get_link_resolver(
295
- entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
296
- ) -> LinkResolver:
297
- return LinkResolver(entity_repository=entity_repository, search_service=search_service)
298
-
299
-
300
- LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
301
-
302
-
303
- async def get_context_service(
304
- search_repository: SearchRepositoryDep,
305
- entity_repository: EntityRepositoryDep,
306
- observation_repository: ObservationRepositoryDep,
307
- ) -> ContextService:
308
- return ContextService(
309
- search_repository=search_repository,
310
- entity_repository=entity_repository,
311
- observation_repository=observation_repository,
312
- )
313
-
314
-
315
- ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
316
-
317
-
318
- async def get_sync_service(
319
- app_config: AppConfigDep,
320
- entity_service: EntityServiceDep,
321
- entity_parser: EntityParserDep,
322
- entity_repository: EntityRepositoryDep,
323
- relation_repository: RelationRepositoryDep,
324
- project_repository: ProjectRepositoryDep,
325
- search_service: SearchServiceDep,
326
- file_service: FileServiceDep,
327
- ) -> SyncService: # pragma: no cover
328
- """
329
-
330
- :rtype: object
331
- """
332
- return SyncService(
333
- app_config=app_config,
334
- entity_service=entity_service,
335
- entity_parser=entity_parser,
336
- entity_repository=entity_repository,
337
- relation_repository=relation_repository,
338
- project_repository=project_repository,
339
- search_service=search_service,
340
- file_service=file_service,
341
- )
342
-
343
-
344
- SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
345
-
346
-
347
- async def get_project_service(
348
- project_repository: ProjectRepositoryDep,
349
- ) -> ProjectService:
350
- """Create ProjectService with repository."""
351
- return ProjectService(repository=project_repository)
352
-
353
-
354
- ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
355
-
356
-
357
- async def get_directory_service(
358
- entity_repository: EntityRepositoryDep,
359
- ) -> DirectoryService:
360
- """Create DirectoryService with dependencies."""
361
- return DirectoryService(
362
- entity_repository=entity_repository,
363
- )
364
-
365
-
366
- DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
367
-
368
-
369
- # Import
370
-
371
-
372
- async def get_chatgpt_importer(
373
- project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
374
- ) -> ChatGPTImporter:
375
- """Create ChatGPTImporter with dependencies."""
376
- return ChatGPTImporter(project_config.home, markdown_processor)
377
-
378
-
379
- ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
380
-
381
-
382
- async def get_claude_conversations_importer(
383
- project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
384
- ) -> ClaudeConversationsImporter:
385
- """Create ChatGPTImporter with dependencies."""
386
- return ClaudeConversationsImporter(project_config.home, markdown_processor)
387
-
388
-
389
- ClaudeConversationsImporterDep = Annotated[
390
- ClaudeConversationsImporter, Depends(get_claude_conversations_importer)
391
- ]
392
-
393
-
394
- async def get_claude_projects_importer(
395
- project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
396
- ) -> ClaudeProjectsImporter:
397
- """Create ChatGPTImporter with dependencies."""
398
- return ClaudeProjectsImporter(project_config.home, markdown_processor)
399
-
400
-
401
- ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
402
-
403
-
404
- async def get_memory_json_importer(
405
- project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
406
- ) -> MemoryJsonImporter:
407
- """Create ChatGPTImporter with dependencies."""
408
- return MemoryJsonImporter(project_config.home, markdown_processor)
409
-
410
14
 
411
- MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
15
+ # Re-export everything from the deps package for backwards compatibility
16
+ from basic_memory.deps import * # noqa: F401, F403 # pragma: no cover
@@ -1,9 +1,13 @@
1
1
  """Utilities for file operations."""
2
2
 
3
+ import asyncio
3
4
  import hashlib
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
4
8
  from pathlib import Path
5
9
  import re
6
- from typing import Any, Dict, Union
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Union
7
11
 
8
12
  import aiofiles
9
13
  import yaml
@@ -12,6 +16,23 @@ from loguru import logger
12
16
 
13
17
  from basic_memory.utils import FilePath
14
18
 
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from basic_memory.config import BasicMemoryConfig
21
+
22
+
23
+ @dataclass
24
+ class FileMetadata:
25
+ """File metadata for cloud-compatible file operations.
26
+
27
+ This dataclass provides a cloud-agnostic way to represent file metadata,
28
+ enabling S3FileService to return metadata from head_object responses
29
+ instead of mock stat_result with zeros.
30
+ """
31
+
32
+ size: int
33
+ created_at: datetime
34
+ modified_at: datetime
35
+
15
36
 
16
37
  class FileError(Exception):
17
38
  """Base exception for file operations."""
@@ -53,6 +74,28 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
53
74
  raise FileError(f"Failed to compute checksum: {e}")
54
75
 
55
76
 
77
+ # UTF-8 BOM character that can appear at the start of files
78
+ UTF8_BOM = "\ufeff"
79
+
80
+
81
+ def strip_bom(content: str) -> str:
82
+ """Strip UTF-8 BOM from the start of content if present.
83
+
84
+ BOM (Byte Order Mark) characters can be present in files created on Windows
85
+ or copied from certain sources. They should be stripped before processing
86
+ frontmatter. See issue #452.
87
+
88
+ Args:
89
+ content: Content that may start with BOM
90
+
91
+ Returns:
92
+ Content with BOM removed if present
93
+ """
94
+ if content and content.startswith(UTF8_BOM):
95
+ return content[1:]
96
+ return content
97
+
98
+
56
99
  async def write_file_atomic(path: FilePath, content: str) -> None:
57
100
  """
58
101
  Write file with atomic operation using temporary file.
@@ -84,6 +127,168 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
84
127
  raise FileWriteError(f"Failed to write file {path}: {e}")
85
128
 
86
129
 
130
+ async def format_markdown_builtin(path: Path) -> Optional[str]:
131
+ """
132
+ Format a markdown file using the built-in mdformat formatter.
133
+
134
+ Uses mdformat with GFM (GitHub Flavored Markdown) support for consistent
135
+ formatting without requiring Node.js or external tools.
136
+
137
+ Args:
138
+ path: Path to the markdown file to format
139
+
140
+ Returns:
141
+ Formatted content if successful, None if formatting failed.
142
+ """
143
+ try:
144
+ import mdformat
145
+ except ImportError: # pragma: no cover
146
+ logger.warning(
147
+ "mdformat not installed, skipping built-in formatting",
148
+ path=str(path),
149
+ )
150
+ return None
151
+
152
+ try:
153
+ # Read original content
154
+ async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
155
+ content = await f.read()
156
+
157
+ # Format using mdformat with GFM and frontmatter extensions
158
+ # mdformat is synchronous, so we run it in a thread executor
159
+ loop = asyncio.get_event_loop()
160
+ formatted_content = await loop.run_in_executor(
161
+ None,
162
+ lambda: mdformat.text(
163
+ content,
164
+ extensions={"gfm", "frontmatter"}, # GFM + YAML frontmatter support
165
+ options={"wrap": "no"}, # Don't wrap lines
166
+ ),
167
+ )
168
+
169
+ # Only write if content changed
170
+ if formatted_content != content:
171
+ async with aiofiles.open(path, mode="w", encoding="utf-8") as f:
172
+ await f.write(formatted_content)
173
+
174
+ logger.debug(
175
+ "Formatted file with mdformat",
176
+ path=str(path),
177
+ changed=formatted_content != content,
178
+ )
179
+ return formatted_content
180
+
181
+ except Exception as e: # pragma: no cover
182
+ logger.warning(
183
+ "mdformat formatting failed",
184
+ path=str(path),
185
+ error=str(e),
186
+ )
187
+ return None
188
+
189
+
190
+ async def format_file(
191
+ path: Path,
192
+ config: "BasicMemoryConfig",
193
+ is_markdown: bool = False,
194
+ ) -> Optional[str]:
195
+ """
196
+ Format a file using configured formatter.
197
+
198
+ By default, uses the built-in mdformat formatter for markdown files (pure Python,
199
+ no Node.js required). External formatters like Prettier can be configured via
200
+ formatter_command or per-extension formatters.
201
+
202
+ Args:
203
+ path: File to format
204
+ config: Configuration with formatter settings
205
+ is_markdown: Whether this is a markdown file (caller should use FileService.is_markdown)
206
+
207
+ Returns:
208
+ Formatted content if successful, None if formatting was skipped or failed.
209
+ Failures are logged as warnings but don't raise exceptions.
210
+ """
211
+ if not config.format_on_save:
212
+ return None
213
+
214
+ extension = path.suffix.lstrip(".")
215
+ formatter = config.formatters.get(extension) or config.formatter_command
216
+
217
+ # Use built-in mdformat for markdown files when no external formatter configured
218
+ if not formatter:
219
+ if is_markdown:
220
+ return await format_markdown_builtin(path)
221
+ else:
222
+ logger.debug("No formatter configured for extension", extension=extension)
223
+ return None
224
+
225
+ # Use external formatter
226
+ # Replace {file} placeholder with the actual path
227
+ cmd = formatter.replace("{file}", str(path))
228
+
229
+ try:
230
+ # Parse command into args list for safer execution (no shell=True)
231
+ args = shlex.split(cmd)
232
+
233
+ proc = await asyncio.create_subprocess_exec(
234
+ *args,
235
+ stdout=asyncio.subprocess.PIPE,
236
+ stderr=asyncio.subprocess.PIPE,
237
+ )
238
+
239
+ try:
240
+ stdout, stderr = await asyncio.wait_for(
241
+ proc.communicate(),
242
+ timeout=config.formatter_timeout,
243
+ )
244
+ except asyncio.TimeoutError:
245
+ proc.kill()
246
+ await proc.wait()
247
+ logger.warning(
248
+ "Formatter timed out",
249
+ path=str(path),
250
+ timeout=config.formatter_timeout,
251
+ )
252
+ return None
253
+
254
+ if proc.returncode != 0:
255
+ logger.warning(
256
+ "Formatter exited with non-zero status",
257
+ path=str(path),
258
+ returncode=proc.returncode,
259
+ stderr=stderr.decode("utf-8", errors="replace") if stderr else "",
260
+ )
261
+ # Still try to read the file - formatter may have partially worked
262
+ # or the file may be unchanged
263
+
264
+ # Read formatted content
265
+ async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
266
+ formatted_content = await f.read()
267
+
268
+ logger.debug(
269
+ "Formatted file successfully",
270
+ path=str(path),
271
+ formatter=args[0] if args else formatter,
272
+ )
273
+ return formatted_content
274
+
275
+ except FileNotFoundError:
276
+ # Formatter executable not found
277
+ logger.warning(
278
+ "Formatter executable not found",
279
+ command=cmd.split()[0] if cmd else "",
280
+ path=str(path),
281
+ )
282
+ return None
283
+ except Exception as e: # pragma: no cover
284
+ logger.warning(
285
+ "Formatter failed",
286
+ path=str(path),
287
+ error=str(e),
288
+ )
289
+ return None
290
+
291
+
87
292
  def has_frontmatter(content: str) -> bool:
88
293
  """
89
294
  Check if content contains valid YAML frontmatter.
@@ -97,7 +302,8 @@ def has_frontmatter(content: str) -> bool:
97
302
  if not content:
98
303
  return False
99
304
 
100
- content = content.strip()
305
+ # Strip BOM before checking for frontmatter markers
306
+ content = strip_bom(content).strip()
101
307
  if not content.startswith("---"):
102
308
  return False
103
309
 
@@ -118,6 +324,8 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
118
324
  ParseError: If frontmatter is invalid or parsing fails
119
325
  """
120
326
  try:
327
+ # Strip BOM before parsing frontmatter
328
+ content = strip_bom(content)
121
329
  if not content.strip().startswith("---"):
122
330
  raise ParseError("Content has no frontmatter")
123
331
 
@@ -159,7 +367,8 @@ def remove_frontmatter(content: str) -> str:
159
367
  Raises:
160
368
  ParseError: If content starts with frontmatter marker but is malformed
161
369
  """
162
- content = content.strip()
370
+ # Strip BOM before processing
371
+ content = strip_bom(content).strip()
163
372
 
164
373
  # Return as-is if no frontmatter marker
165
374
  if not content.startswith("---"):