basic-memory 0.7.0__py3-none-any.whl → 0.16.1__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 +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- 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/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +64 -18
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +166 -21
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +119 -4
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +43 -9
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +28 -12
- basic_memory/cli/commands/import_chatgpt.py +40 -220
- basic_memory/cli/commands/import_claude_conversations.py +41 -168
- basic_memory/cli/commands/import_claude_projects.py +46 -157
- basic_memory/cli/commands/import_memory_json.py +48 -108
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +50 -33
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +8 -7
- basic_memory/config.py +477 -23
- basic_memory/db.py +168 -17
- basic_memory/deps.py +251 -25
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -23
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +411 -62
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +187 -25
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +1 -1
- basic_memory/schemas/search.py +31 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +241 -104
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +590 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +49 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +168 -32
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1180 -109
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +383 -51
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,3 +14,24 @@ class EntityCreationError(Exception):
|
|
|
14
14
|
"""Raised when an entity cannot be created"""
|
|
15
15
|
|
|
16
16
|
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DirectoryOperationError(Exception):
|
|
20
|
+
"""Raised when directory operations fail"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SyncFatalError(Exception):
|
|
26
|
+
"""Raised when sync encounters a fatal error that prevents continuation.
|
|
27
|
+
|
|
28
|
+
Fatal errors include:
|
|
29
|
+
- Project deleted during sync (FOREIGN KEY constraint)
|
|
30
|
+
- Database corruption
|
|
31
|
+
- Critical system failures
|
|
32
|
+
|
|
33
|
+
When this exception is raised, the entire sync operation should be terminated
|
|
34
|
+
immediately rather than attempting to continue with remaining files.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
pass
|
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
"""Service for file operations with checksum tracking."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import mimetypes
|
|
6
|
+
from os import stat_result
|
|
3
7
|
from pathlib import Path
|
|
4
|
-
from typing import Tuple, Union
|
|
8
|
+
from typing import Any, Dict, Tuple, Union
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
import aiofiles
|
|
11
|
+
import yaml
|
|
7
12
|
|
|
8
13
|
from basic_memory import file_utils
|
|
14
|
+
from basic_memory.file_utils import FileError, ParseError
|
|
9
15
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
10
16
|
from basic_memory.models import Entity as EntityModel
|
|
11
17
|
from basic_memory.schemas import Entity as EntitySchema
|
|
12
18
|
from basic_memory.services.exceptions import FileOperationError
|
|
19
|
+
from basic_memory.utils import FilePath
|
|
20
|
+
from loguru import logger
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
class FileService:
|
|
16
|
-
"""Service for handling file operations.
|
|
24
|
+
"""Service for handling file operations with concurrency control.
|
|
17
25
|
|
|
18
26
|
All paths are handled as Path objects internally. Strings are converted to
|
|
19
27
|
Path objects when passed in. Relative paths are assumed to be relative to
|
|
20
28
|
base_path.
|
|
21
29
|
|
|
22
30
|
Features:
|
|
31
|
+
- True async I/O with aiofiles (non-blocking)
|
|
32
|
+
- Built-in concurrency limits (semaphore)
|
|
23
33
|
- Consistent file writing with checksums
|
|
24
34
|
- Frontmatter management
|
|
25
35
|
- Atomic operations
|
|
@@ -30,9 +40,13 @@ class FileService:
|
|
|
30
40
|
self,
|
|
31
41
|
base_path: Path,
|
|
32
42
|
markdown_processor: MarkdownProcessor,
|
|
43
|
+
max_concurrent_files: int = 10,
|
|
33
44
|
):
|
|
34
45
|
self.base_path = base_path.resolve() # Get absolute path
|
|
35
46
|
self.markdown_processor = markdown_processor
|
|
47
|
+
# Semaphore to limit concurrent file operations
|
|
48
|
+
# Prevents OOM on large projects by processing files in batches
|
|
49
|
+
self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
|
|
36
50
|
|
|
37
51
|
def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
|
|
38
52
|
"""Generate absolute filesystem path for entity.
|
|
@@ -57,7 +71,7 @@ class FileService:
|
|
|
57
71
|
Returns:
|
|
58
72
|
Raw content string without metadata sections
|
|
59
73
|
"""
|
|
60
|
-
logger.debug(f"Reading entity
|
|
74
|
+
logger.debug(f"Reading entity content, entity_id={entity.id}, permalink={entity.permalink}")
|
|
61
75
|
|
|
62
76
|
file_path = self.get_entity_path(entity)
|
|
63
77
|
markdown = await self.markdown_processor.read_file(file_path)
|
|
@@ -75,13 +89,13 @@ class FileService:
|
|
|
75
89
|
path = self.get_entity_path(entity)
|
|
76
90
|
await self.delete_file(path)
|
|
77
91
|
|
|
78
|
-
async def exists(self, path:
|
|
92
|
+
async def exists(self, path: FilePath) -> bool:
|
|
79
93
|
"""Check if file exists at the provided path.
|
|
80
94
|
|
|
81
95
|
If path is relative, it is assumed to be relative to base_path.
|
|
82
96
|
|
|
83
97
|
Args:
|
|
84
|
-
path: Path to check (Path
|
|
98
|
+
path: Path to check (Path or string)
|
|
85
99
|
|
|
86
100
|
Returns:
|
|
87
101
|
True if file exists, False otherwise
|
|
@@ -90,23 +104,52 @@ class FileService:
|
|
|
90
104
|
FileOperationError: If check fails
|
|
91
105
|
"""
|
|
92
106
|
try:
|
|
93
|
-
|
|
94
|
-
if path
|
|
95
|
-
|
|
107
|
+
# Convert string to Path if needed
|
|
108
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
109
|
+
logger.debug(f"Checking file existence: path={path_obj}")
|
|
110
|
+
if path_obj.is_absolute():
|
|
111
|
+
return path_obj.exists()
|
|
96
112
|
else:
|
|
97
|
-
return (self.base_path /
|
|
113
|
+
return (self.base_path / path_obj).exists()
|
|
98
114
|
except Exception as e:
|
|
99
|
-
logger.error(
|
|
115
|
+
logger.error("Failed to check file existence", path=str(path), error=str(e))
|
|
100
116
|
raise FileOperationError(f"Failed to check file existence: {e}")
|
|
101
117
|
|
|
102
|
-
async def
|
|
118
|
+
async def ensure_directory(self, path: FilePath) -> None:
|
|
119
|
+
"""Ensure directory exists, creating if necessary.
|
|
120
|
+
|
|
121
|
+
Uses semaphore to control concurrency for directory creation operations.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path: Directory path to ensure (Path or string)
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
FileOperationError: If directory creation fails
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
# Convert string to Path if needed
|
|
131
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
132
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
133
|
+
|
|
134
|
+
# Use semaphore for concurrency control
|
|
135
|
+
async with self._file_semaphore:
|
|
136
|
+
# Run blocking mkdir in thread pool
|
|
137
|
+
loop = asyncio.get_event_loop()
|
|
138
|
+
await loop.run_in_executor(
|
|
139
|
+
None, lambda: full_path.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
)
|
|
141
|
+
except Exception as e: # pragma: no cover
|
|
142
|
+
logger.error("Failed to create directory", path=str(path), error=str(e))
|
|
143
|
+
raise FileOperationError(f"Failed to create directory {path}: {e}")
|
|
144
|
+
|
|
145
|
+
async def write_file(self, path: FilePath, content: str) -> str:
|
|
103
146
|
"""Write content to file and return checksum.
|
|
104
147
|
|
|
105
148
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
106
149
|
against base_path.
|
|
107
150
|
|
|
108
151
|
Args:
|
|
109
|
-
path: Where to write (Path
|
|
152
|
+
path: Where to write (Path or string)
|
|
110
153
|
content: Content to write
|
|
111
154
|
|
|
112
155
|
Returns:
|
|
@@ -115,33 +158,78 @@ class FileService:
|
|
|
115
158
|
Raises:
|
|
116
159
|
FileOperationError: If write fails
|
|
117
160
|
"""
|
|
118
|
-
|
|
119
|
-
|
|
161
|
+
# Convert string to Path if needed
|
|
162
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
163
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
120
164
|
|
|
121
165
|
try:
|
|
122
166
|
# Ensure parent directory exists
|
|
123
|
-
await
|
|
167
|
+
await self.ensure_directory(full_path.parent)
|
|
124
168
|
|
|
125
169
|
# Write content atomically
|
|
170
|
+
logger.info(
|
|
171
|
+
"Writing file: "
|
|
172
|
+
f"path={path_obj}, "
|
|
173
|
+
f"content_length={len(content)}, "
|
|
174
|
+
f"is_markdown={full_path.suffix.lower() == '.md'}"
|
|
175
|
+
)
|
|
176
|
+
|
|
126
177
|
await file_utils.write_file_atomic(full_path, content)
|
|
127
178
|
|
|
128
179
|
# Compute and return checksum
|
|
129
180
|
checksum = await file_utils.compute_checksum(content)
|
|
130
|
-
logger.debug(f"
|
|
181
|
+
logger.debug(f"File write completed path={full_path}, {checksum=}")
|
|
131
182
|
return checksum
|
|
132
183
|
|
|
133
184
|
except Exception as e:
|
|
134
|
-
logger.
|
|
185
|
+
logger.exception("File write error", path=str(full_path), error=str(e))
|
|
135
186
|
raise FileOperationError(f"Failed to write file: {e}")
|
|
136
187
|
|
|
137
|
-
async def
|
|
138
|
-
"""Read file
|
|
188
|
+
async def read_file_content(self, path: FilePath) -> str:
|
|
189
|
+
"""Read file content using true async I/O with aiofiles.
|
|
139
190
|
|
|
140
191
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
141
192
|
against base_path.
|
|
142
193
|
|
|
143
194
|
Args:
|
|
144
|
-
path: Path to read (Path
|
|
195
|
+
path: Path to read (Path or string)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
File content as string
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
FileOperationError: If read fails
|
|
202
|
+
"""
|
|
203
|
+
# Convert string to Path if needed
|
|
204
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
205
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
logger.debug("Reading file content", operation="read_file_content", path=str(full_path))
|
|
209
|
+
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
|
|
210
|
+
content = await f.read()
|
|
211
|
+
|
|
212
|
+
logger.debug(
|
|
213
|
+
"File read completed",
|
|
214
|
+
path=str(full_path),
|
|
215
|
+
content_length=len(content),
|
|
216
|
+
)
|
|
217
|
+
return content
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.exception("File read error", path=str(full_path), error=str(e))
|
|
221
|
+
raise FileOperationError(f"Failed to read file: {e}")
|
|
222
|
+
|
|
223
|
+
async def read_file(self, path: FilePath) -> Tuple[str, str]:
|
|
224
|
+
"""Read file and compute checksum using true async I/O.
|
|
225
|
+
|
|
226
|
+
Uses aiofiles for non-blocking file reads.
|
|
227
|
+
|
|
228
|
+
Handles both absolute and relative paths. Relative paths are resolved
|
|
229
|
+
against base_path.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
path: Path to read (Path or string)
|
|
145
233
|
|
|
146
234
|
Returns:
|
|
147
235
|
Tuple of (content, checksum)
|
|
@@ -149,28 +237,194 @@ class FileService:
|
|
|
149
237
|
Raises:
|
|
150
238
|
FileOperationError: If read fails
|
|
151
239
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
240
|
+
# Convert string to Path if needed
|
|
241
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
242
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
154
243
|
|
|
155
244
|
try:
|
|
156
|
-
|
|
245
|
+
logger.debug("Reading file", operation="read_file", path=str(full_path))
|
|
246
|
+
|
|
247
|
+
# Use aiofiles for non-blocking read
|
|
248
|
+
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
|
|
249
|
+
content = await f.read()
|
|
250
|
+
|
|
157
251
|
checksum = await file_utils.compute_checksum(content)
|
|
158
|
-
|
|
252
|
+
|
|
253
|
+
logger.debug(
|
|
254
|
+
"File read completed",
|
|
255
|
+
path=str(full_path),
|
|
256
|
+
checksum=checksum,
|
|
257
|
+
content_length=len(content),
|
|
258
|
+
)
|
|
159
259
|
return content, checksum
|
|
160
260
|
|
|
161
261
|
except Exception as e:
|
|
162
|
-
logger.
|
|
262
|
+
logger.exception("File read error", path=str(full_path), error=str(e))
|
|
163
263
|
raise FileOperationError(f"Failed to read file: {e}")
|
|
164
264
|
|
|
165
|
-
async def delete_file(self, path:
|
|
265
|
+
async def delete_file(self, path: FilePath) -> None:
|
|
166
266
|
"""Delete file if it exists.
|
|
167
267
|
|
|
168
268
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
169
269
|
against base_path.
|
|
170
270
|
|
|
171
271
|
Args:
|
|
172
|
-
path: Path to delete (Path
|
|
272
|
+
path: Path to delete (Path or string)
|
|
173
273
|
"""
|
|
174
|
-
|
|
175
|
-
|
|
274
|
+
# Convert string to Path if needed
|
|
275
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
276
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
176
277
|
full_path.unlink(missing_ok=True)
|
|
278
|
+
|
|
279
|
+
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
|
|
280
|
+
"""Update frontmatter fields in a file while preserving all content.
|
|
281
|
+
|
|
282
|
+
Only modifies the frontmatter section, leaving all content untouched.
|
|
283
|
+
Creates frontmatter section if none exists.
|
|
284
|
+
Returns checksum of updated file.
|
|
285
|
+
|
|
286
|
+
Uses aiofiles for true async I/O (non-blocking).
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
path: Path to markdown file (Path or string)
|
|
290
|
+
updates: Dict of frontmatter fields to update
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Checksum of updated file
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
FileOperationError: If file operations fail
|
|
297
|
+
ParseError: If frontmatter parsing fails
|
|
298
|
+
"""
|
|
299
|
+
# Convert string to Path if needed
|
|
300
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
301
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Read current content using aiofiles
|
|
305
|
+
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
|
|
306
|
+
content = await f.read()
|
|
307
|
+
|
|
308
|
+
# Parse current frontmatter with proper error handling for malformed YAML
|
|
309
|
+
current_fm = {}
|
|
310
|
+
if file_utils.has_frontmatter(content):
|
|
311
|
+
try:
|
|
312
|
+
current_fm = file_utils.parse_frontmatter(content)
|
|
313
|
+
content = file_utils.remove_frontmatter(content)
|
|
314
|
+
except (ParseError, yaml.YAMLError) as e:
|
|
315
|
+
# Log warning and treat as plain markdown without frontmatter
|
|
316
|
+
logger.warning(
|
|
317
|
+
f"Failed to parse YAML frontmatter in {full_path}: {e}. "
|
|
318
|
+
"Treating file as plain markdown without frontmatter."
|
|
319
|
+
)
|
|
320
|
+
# Keep full content, treat as having no frontmatter
|
|
321
|
+
current_fm = {}
|
|
322
|
+
|
|
323
|
+
# Update frontmatter
|
|
324
|
+
new_fm = {**current_fm, **updates}
|
|
325
|
+
|
|
326
|
+
# Write new file with updated frontmatter
|
|
327
|
+
yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
|
|
328
|
+
final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
|
|
329
|
+
|
|
330
|
+
logger.debug(
|
|
331
|
+
"Updating frontmatter", path=str(full_path), update_keys=list(updates.keys())
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
await file_utils.write_file_atomic(full_path, final_content)
|
|
335
|
+
return await file_utils.compute_checksum(final_content)
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
# Only log real errors (not YAML parsing, which is handled above)
|
|
339
|
+
if not isinstance(e, (ParseError, yaml.YAMLError)):
|
|
340
|
+
logger.error(
|
|
341
|
+
"Failed to update frontmatter",
|
|
342
|
+
path=str(full_path),
|
|
343
|
+
error=str(e),
|
|
344
|
+
)
|
|
345
|
+
raise FileOperationError(f"Failed to update frontmatter: {e}")
|
|
346
|
+
|
|
347
|
+
async def compute_checksum(self, path: FilePath) -> str:
|
|
348
|
+
"""Compute checksum for a file using true async I/O.
|
|
349
|
+
|
|
350
|
+
Uses aiofiles for non-blocking I/O with 64KB chunked reading.
|
|
351
|
+
Semaphore limits concurrent file operations to prevent OOM.
|
|
352
|
+
Memory usage is constant regardless of file size.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
path: Path to the file (Path or string)
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
SHA256 checksum hex string
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
FileError: If checksum computation fails
|
|
362
|
+
"""
|
|
363
|
+
# Convert string to Path if needed
|
|
364
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
365
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
366
|
+
|
|
367
|
+
# Semaphore controls concurrency - max N files processed at once
|
|
368
|
+
async with self._file_semaphore:
|
|
369
|
+
try:
|
|
370
|
+
hasher = hashlib.sha256()
|
|
371
|
+
chunk_size = 65536 # 64KB chunks
|
|
372
|
+
|
|
373
|
+
# async I/O with aiofiles
|
|
374
|
+
async with aiofiles.open(full_path, mode="rb") as f:
|
|
375
|
+
while chunk := await f.read(chunk_size):
|
|
376
|
+
hasher.update(chunk)
|
|
377
|
+
|
|
378
|
+
return hasher.hexdigest()
|
|
379
|
+
|
|
380
|
+
except Exception as e: # pragma: no cover
|
|
381
|
+
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
|
|
382
|
+
raise FileError(f"Failed to compute checksum for {path}: {e}")
|
|
383
|
+
|
|
384
|
+
def file_stats(self, path: FilePath) -> stat_result:
|
|
385
|
+
"""Return file stats for a given path.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
path: Path to the file (Path or string)
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
File statistics
|
|
392
|
+
"""
|
|
393
|
+
# Convert string to Path if needed
|
|
394
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
395
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
396
|
+
# get file timestamps
|
|
397
|
+
return full_path.stat()
|
|
398
|
+
|
|
399
|
+
def content_type(self, path: FilePath) -> str:
|
|
400
|
+
"""Return content_type for a given path.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
path: Path to the file (Path or string)
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
MIME type of the file
|
|
407
|
+
"""
|
|
408
|
+
# Convert string to Path if needed
|
|
409
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
410
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
411
|
+
# get file timestamps
|
|
412
|
+
mime_type, _ = mimetypes.guess_type(full_path.name)
|
|
413
|
+
|
|
414
|
+
# .canvas files are json
|
|
415
|
+
if full_path.suffix == ".canvas":
|
|
416
|
+
mime_type = "application/json"
|
|
417
|
+
|
|
418
|
+
content_type = mime_type or "text/plain"
|
|
419
|
+
return content_type
|
|
420
|
+
|
|
421
|
+
def is_markdown(self, path: FilePath) -> bool:
|
|
422
|
+
"""Check if a file is a markdown file.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
path: Path to the file (Path or string)
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
True if the file is a markdown file, False otherwise
|
|
429
|
+
"""
|
|
430
|
+
return self.content_type(path) == "text/markdown"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Shared initialization service for Basic Memory.
|
|
2
|
+
|
|
3
|
+
This module provides shared initialization functions used by both CLI and API
|
|
4
|
+
to ensure consistent application startup across all entry points.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from basic_memory import db
|
|
13
|
+
from basic_memory.config import BasicMemoryConfig
|
|
14
|
+
from basic_memory.models import Project
|
|
15
|
+
from basic_memory.repository import (
|
|
16
|
+
ProjectRepository,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def initialize_database(app_config: BasicMemoryConfig) -> None:
|
|
21
|
+
"""Initialize database with migrations handled automatically by get_or_create_db.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
app_config: The Basic Memory project configuration
|
|
25
|
+
|
|
26
|
+
Note:
|
|
27
|
+
Database migrations are now handled automatically when the database
|
|
28
|
+
connection is first established via get_or_create_db().
|
|
29
|
+
"""
|
|
30
|
+
# Trigger database initialization and migrations by getting the database connection
|
|
31
|
+
try:
|
|
32
|
+
await db.get_or_create_db(app_config.database_path)
|
|
33
|
+
logger.info("Database initialization completed")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Error initializing database: {e}")
|
|
36
|
+
# Allow application to continue - it might still work
|
|
37
|
+
# depending on what the error was, and will fail with a
|
|
38
|
+
# more specific error if the database is actually unusable
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
|
42
|
+
"""Ensure all projects in config.json exist in the projects table and vice versa.
|
|
43
|
+
|
|
44
|
+
This uses the ProjectService's synchronize_projects method to ensure bidirectional
|
|
45
|
+
synchronization between the configuration file and the database.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
app_config: The Basic Memory application configuration
|
|
49
|
+
"""
|
|
50
|
+
logger.info("Reconciling projects from config with database...")
|
|
51
|
+
|
|
52
|
+
# Get database session - migrations handled centrally
|
|
53
|
+
_, session_maker = await db.get_or_create_db(
|
|
54
|
+
db_path=app_config.database_path,
|
|
55
|
+
db_type=db.DatabaseType.FILESYSTEM,
|
|
56
|
+
ensure_migrations=False,
|
|
57
|
+
)
|
|
58
|
+
project_repository = ProjectRepository(session_maker)
|
|
59
|
+
|
|
60
|
+
# Import ProjectService here to avoid circular imports
|
|
61
|
+
from basic_memory.services.project_service import ProjectService
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Create project service and synchronize projects
|
|
65
|
+
project_service = ProjectService(repository=project_repository)
|
|
66
|
+
await project_service.synchronize_projects()
|
|
67
|
+
logger.info("Projects successfully reconciled between config and database")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
# Log the error but continue with initialization
|
|
70
|
+
logger.error(f"Error during project synchronization: {e}")
|
|
71
|
+
logger.info("Continuing with initialization despite synchronization error")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def initialize_file_sync(
|
|
75
|
+
app_config: BasicMemoryConfig,
|
|
76
|
+
):
|
|
77
|
+
"""Initialize file synchronization services. This function starts the watch service and does not return
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
app_config: The Basic Memory project configuration
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The watch service task that's monitoring file changes
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# delay import
|
|
87
|
+
from basic_memory.sync import WatchService
|
|
88
|
+
|
|
89
|
+
# Load app configuration - migrations handled centrally
|
|
90
|
+
_, session_maker = await db.get_or_create_db(
|
|
91
|
+
db_path=app_config.database_path,
|
|
92
|
+
db_type=db.DatabaseType.FILESYSTEM,
|
|
93
|
+
ensure_migrations=False,
|
|
94
|
+
)
|
|
95
|
+
project_repository = ProjectRepository(session_maker)
|
|
96
|
+
|
|
97
|
+
# Initialize watch service
|
|
98
|
+
watch_service = WatchService(
|
|
99
|
+
app_config=app_config,
|
|
100
|
+
project_repository=project_repository,
|
|
101
|
+
quiet=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Get active projects
|
|
105
|
+
active_projects = await project_repository.get_active_projects()
|
|
106
|
+
|
|
107
|
+
# Start sync for all projects as background tasks (non-blocking)
|
|
108
|
+
async def sync_project_background(project: Project):
|
|
109
|
+
"""Sync a single project in the background."""
|
|
110
|
+
# avoid circular imports
|
|
111
|
+
from basic_memory.sync.sync_service import get_sync_service
|
|
112
|
+
|
|
113
|
+
logger.info(f"Starting background sync for project: {project.name}")
|
|
114
|
+
try:
|
|
115
|
+
# Create sync service
|
|
116
|
+
sync_service = await get_sync_service(project)
|
|
117
|
+
|
|
118
|
+
sync_dir = Path(project.path)
|
|
119
|
+
await sync_service.sync(sync_dir, project_name=project.name)
|
|
120
|
+
logger.info(f"Background sync completed successfully for project: {project.name}")
|
|
121
|
+
except Exception as e: # pragma: no cover
|
|
122
|
+
logger.error(f"Error in background sync for project {project.name}: {e}")
|
|
123
|
+
|
|
124
|
+
# Create background tasks for all project syncs (non-blocking)
|
|
125
|
+
sync_tasks = [
|
|
126
|
+
asyncio.create_task(sync_project_background(project)) for project in active_projects
|
|
127
|
+
]
|
|
128
|
+
logger.info(f"Created {len(sync_tasks)} background sync tasks")
|
|
129
|
+
|
|
130
|
+
# Don't await the tasks - let them run in background while we continue
|
|
131
|
+
|
|
132
|
+
# Then start the watch service in the background
|
|
133
|
+
logger.info("Starting watch service for all projects")
|
|
134
|
+
# run the watch service
|
|
135
|
+
try:
|
|
136
|
+
await watch_service.run()
|
|
137
|
+
logger.info("Watch service started")
|
|
138
|
+
except Exception as e: # pragma: no cover
|
|
139
|
+
logger.error(f"Error starting watch service: {e}")
|
|
140
|
+
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def initialize_app(
|
|
145
|
+
app_config: BasicMemoryConfig,
|
|
146
|
+
):
|
|
147
|
+
"""Initialize the Basic Memory application.
|
|
148
|
+
|
|
149
|
+
This function handles all initialization steps:
|
|
150
|
+
- Running database migrations
|
|
151
|
+
- Reconciling projects from config.json with projects table
|
|
152
|
+
- Setting up file synchronization
|
|
153
|
+
- Starting background migration for legacy project data
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
app_config: The Basic Memory project configuration
|
|
157
|
+
"""
|
|
158
|
+
logger.info("Initializing app...")
|
|
159
|
+
# Initialize database first
|
|
160
|
+
await initialize_database(app_config)
|
|
161
|
+
|
|
162
|
+
# Reconcile projects from config.json with projects table
|
|
163
|
+
await reconcile_projects_with_config(app_config)
|
|
164
|
+
|
|
165
|
+
logger.info("App initialization completed (migration running in background if needed)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
|
169
|
+
"""Ensure initialization runs in a synchronous context.
|
|
170
|
+
|
|
171
|
+
This is a wrapper for the async initialize_app function that can be
|
|
172
|
+
called from synchronous code like CLI entry points.
|
|
173
|
+
|
|
174
|
+
No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
app_config: The Basic Memory project configuration
|
|
178
|
+
"""
|
|
179
|
+
# Skip initialization in cloud mode - cloud manages its own projects
|
|
180
|
+
if app_config.cloud_mode_enabled:
|
|
181
|
+
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
result = asyncio.run(initialize_app(app_config))
|
|
186
|
+
logger.info(f"Initialization completed successfully: result={result}")
|
|
187
|
+
except Exception as e: # pragma: no cover
|
|
188
|
+
logger.exception(f"Error during initialization: {e}")
|
|
189
|
+
# Continue execution even if initialization fails
|
|
190
|
+
# The command might still work, or will fail with a
|
|
191
|
+
# more specific error message
|