basic-memory 0.16.1__py3-none-any.whl → 0.17.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/deps.py
CHANGED
|
@@ -1,411 +1,16 @@
|
|
|
1
|
-
"""Dependency injection functions for basic-memory services.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
+
# Re-export everything from the deps package for backwards compatibility
|
|
16
|
+
from basic_memory.deps import * # noqa: F401, F403 # pragma: no cover
|
basic_memory/file_utils.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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("---"):
|