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
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import hashlib
|
|
5
5
|
import mimetypes
|
|
6
|
-
from
|
|
6
|
+
from datetime import datetime
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, Dict, Tuple, Union
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
|
|
9
9
|
|
|
10
10
|
import aiofiles
|
|
11
|
+
|
|
11
12
|
import yaml
|
|
12
13
|
|
|
13
14
|
from basic_memory import file_utils
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
17
|
+
from basic_memory.config import BasicMemoryConfig
|
|
18
|
+
from basic_memory.file_utils import FileError, FileMetadata, ParseError
|
|
15
19
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
16
20
|
from basic_memory.models import Entity as EntityModel
|
|
17
21
|
from basic_memory.schemas import Entity as EntitySchema
|
|
@@ -41,9 +45,11 @@ class FileService:
|
|
|
41
45
|
base_path: Path,
|
|
42
46
|
markdown_processor: MarkdownProcessor,
|
|
43
47
|
max_concurrent_files: int = 10,
|
|
48
|
+
app_config: Optional["BasicMemoryConfig"] = None,
|
|
44
49
|
):
|
|
45
50
|
self.base_path = base_path.resolve() # Get absolute path
|
|
46
51
|
self.markdown_processor = markdown_processor
|
|
52
|
+
self.app_config = app_config
|
|
47
53
|
# Semaphore to limit concurrent file operations
|
|
48
54
|
# Prevents OOM on large projects by processing files in batches
|
|
49
55
|
self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
|
|
@@ -148,12 +154,15 @@ class FileService:
|
|
|
148
154
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
149
155
|
against base_path.
|
|
150
156
|
|
|
157
|
+
If format_on_save is enabled in config, runs the configured formatter
|
|
158
|
+
after writing and returns the checksum of the formatted content.
|
|
159
|
+
|
|
151
160
|
Args:
|
|
152
161
|
path: Where to write (Path or string)
|
|
153
162
|
content: Content to write
|
|
154
163
|
|
|
155
164
|
Returns:
|
|
156
|
-
Checksum of written content
|
|
165
|
+
Checksum of written content (or formatted content if formatting enabled)
|
|
157
166
|
|
|
158
167
|
Raises:
|
|
159
168
|
FileOperationError: If write fails
|
|
@@ -176,8 +185,17 @@ class FileService:
|
|
|
176
185
|
|
|
177
186
|
await file_utils.write_file_atomic(full_path, content)
|
|
178
187
|
|
|
179
|
-
#
|
|
180
|
-
|
|
188
|
+
# Format file if configured
|
|
189
|
+
final_content = content
|
|
190
|
+
if self.app_config:
|
|
191
|
+
formatted_content = await file_utils.format_file(
|
|
192
|
+
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
|
193
|
+
)
|
|
194
|
+
if formatted_content is not None:
|
|
195
|
+
final_content = formatted_content # pragma: no cover
|
|
196
|
+
|
|
197
|
+
# Compute and return checksum of final content
|
|
198
|
+
checksum = await file_utils.compute_checksum(final_content)
|
|
181
199
|
logger.debug(f"File write completed path={full_path}, {checksum=}")
|
|
182
200
|
return checksum
|
|
183
201
|
|
|
@@ -216,6 +234,45 @@ class FileService:
|
|
|
216
234
|
)
|
|
217
235
|
return content
|
|
218
236
|
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
# Preserve FileNotFoundError so callers (e.g. sync) can treat it as deletion.
|
|
239
|
+
logger.warning("File not found", operation="read_file_content", path=str(full_path))
|
|
240
|
+
raise
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.exception("File read error", path=str(full_path), error=str(e))
|
|
243
|
+
raise FileOperationError(f"Failed to read file: {e}")
|
|
244
|
+
|
|
245
|
+
async def read_file_bytes(self, path: FilePath) -> bytes:
|
|
246
|
+
"""Read file content as bytes using true async I/O with aiofiles.
|
|
247
|
+
|
|
248
|
+
This method reads files in binary mode, suitable for non-text files
|
|
249
|
+
like images, PDFs, etc. For cloud compatibility with S3FileService.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
path: Path to read (Path or string)
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
File content as bytes
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
FileOperationError: If read fails
|
|
259
|
+
"""
|
|
260
|
+
# Convert string to Path if needed
|
|
261
|
+
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
262
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
logger.debug("Reading file bytes", operation="read_file_bytes", path=str(full_path))
|
|
266
|
+
async with aiofiles.open(full_path, mode="rb") as f:
|
|
267
|
+
content = await f.read()
|
|
268
|
+
|
|
269
|
+
logger.debug(
|
|
270
|
+
"File read completed",
|
|
271
|
+
path=str(full_path),
|
|
272
|
+
content_length=len(content),
|
|
273
|
+
)
|
|
274
|
+
return content
|
|
275
|
+
|
|
219
276
|
except Exception as e:
|
|
220
277
|
logger.exception("File read error", path=str(full_path), error=str(e))
|
|
221
278
|
raise FileOperationError(f"Failed to read file: {e}")
|
|
@@ -276,6 +333,43 @@ class FileService:
|
|
|
276
333
|
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
277
334
|
full_path.unlink(missing_ok=True)
|
|
278
335
|
|
|
336
|
+
async def move_file(self, source: FilePath, destination: FilePath) -> None:
|
|
337
|
+
"""Move/rename a file from source to destination.
|
|
338
|
+
|
|
339
|
+
This method abstracts the underlying storage (filesystem vs cloud).
|
|
340
|
+
Default implementation uses atomic filesystem rename, but cloud-backed
|
|
341
|
+
implementations (e.g., S3) can override to copy+delete.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
source: Source path (relative to base_path or absolute)
|
|
345
|
+
destination: Destination path (relative to base_path or absolute)
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
FileOperationError: If the move fails
|
|
349
|
+
"""
|
|
350
|
+
# Convert strings to Paths and resolve relative paths against base_path
|
|
351
|
+
src_obj = self.base_path / source if isinstance(source, str) else source
|
|
352
|
+
dst_obj = self.base_path / destination if isinstance(destination, str) else destination
|
|
353
|
+
src_full = src_obj if src_obj.is_absolute() else self.base_path / src_obj
|
|
354
|
+
dst_full = dst_obj if dst_obj.is_absolute() else self.base_path / dst_obj
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
# Ensure destination directory exists
|
|
358
|
+
await self.ensure_directory(dst_full.parent)
|
|
359
|
+
|
|
360
|
+
# Use semaphore for concurrency control and run blocking rename in executor
|
|
361
|
+
async with self._file_semaphore:
|
|
362
|
+
loop = asyncio.get_event_loop()
|
|
363
|
+
await loop.run_in_executor(None, lambda: src_full.rename(dst_full))
|
|
364
|
+
except Exception as e: # pragma: no cover
|
|
365
|
+
logger.exception(
|
|
366
|
+
"File move error",
|
|
367
|
+
source=str(src_full),
|
|
368
|
+
destination=str(dst_full),
|
|
369
|
+
error=str(e),
|
|
370
|
+
)
|
|
371
|
+
raise FileOperationError(f"Failed to move file {source} -> {destination}: {e}")
|
|
372
|
+
|
|
279
373
|
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
|
|
280
374
|
"""Update frontmatter fields in a file while preserving all content.
|
|
281
375
|
|
|
@@ -311,14 +405,14 @@ class FileService:
|
|
|
311
405
|
try:
|
|
312
406
|
current_fm = file_utils.parse_frontmatter(content)
|
|
313
407
|
content = file_utils.remove_frontmatter(content)
|
|
314
|
-
except (ParseError, yaml.YAMLError) as e:
|
|
408
|
+
except (ParseError, yaml.YAMLError) as e: # pragma: no cover
|
|
315
409
|
# Log warning and treat as plain markdown without frontmatter
|
|
316
|
-
logger.warning(
|
|
410
|
+
logger.warning( # pragma: no cover
|
|
317
411
|
f"Failed to parse YAML frontmatter in {full_path}: {e}. "
|
|
318
412
|
"Treating file as plain markdown without frontmatter."
|
|
319
413
|
)
|
|
320
414
|
# Keep full content, treat as having no frontmatter
|
|
321
|
-
current_fm = {}
|
|
415
|
+
current_fm = {} # pragma: no cover
|
|
322
416
|
|
|
323
417
|
# Update frontmatter
|
|
324
418
|
new_fm = {**current_fm, **updates}
|
|
@@ -332,9 +426,19 @@ class FileService:
|
|
|
332
426
|
)
|
|
333
427
|
|
|
334
428
|
await file_utils.write_file_atomic(full_path, final_content)
|
|
335
|
-
return await file_utils.compute_checksum(final_content)
|
|
336
429
|
|
|
337
|
-
|
|
430
|
+
# Format file if configured
|
|
431
|
+
content_for_checksum = final_content
|
|
432
|
+
if self.app_config:
|
|
433
|
+
formatted_content = await file_utils.format_file(
|
|
434
|
+
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
|
435
|
+
)
|
|
436
|
+
if formatted_content is not None:
|
|
437
|
+
content_for_checksum = formatted_content # pragma: no cover
|
|
438
|
+
|
|
439
|
+
return await file_utils.compute_checksum(content_for_checksum)
|
|
440
|
+
|
|
441
|
+
except Exception as e: # pragma: no cover
|
|
338
442
|
# Only log real errors (not YAML parsing, which is handled above)
|
|
339
443
|
if not isinstance(e, (ParseError, yaml.YAMLError)):
|
|
340
444
|
logger.error(
|
|
@@ -381,20 +485,31 @@ class FileService:
|
|
|
381
485
|
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
|
|
382
486
|
raise FileError(f"Failed to compute checksum for {path}: {e}")
|
|
383
487
|
|
|
384
|
-
def
|
|
385
|
-
"""Return file
|
|
488
|
+
async def get_file_metadata(self, path: FilePath) -> FileMetadata:
|
|
489
|
+
"""Return file metadata for a given path.
|
|
490
|
+
|
|
491
|
+
This method is async to support cloud implementations (S3FileService)
|
|
492
|
+
where file metadata requires async operations (head_object).
|
|
386
493
|
|
|
387
494
|
Args:
|
|
388
495
|
path: Path to the file (Path or string)
|
|
389
496
|
|
|
390
497
|
Returns:
|
|
391
|
-
|
|
498
|
+
FileMetadata with size, created_at, and modified_at
|
|
392
499
|
"""
|
|
393
500
|
# Convert string to Path if needed
|
|
394
501
|
path_obj = self.base_path / path if isinstance(path, str) else path
|
|
395
502
|
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
396
|
-
|
|
397
|
-
|
|
503
|
+
|
|
504
|
+
# Run blocking stat() in thread pool to maintain async compatibility
|
|
505
|
+
loop = asyncio.get_event_loop()
|
|
506
|
+
stat_result = await loop.run_in_executor(None, full_path.stat)
|
|
507
|
+
|
|
508
|
+
return FileMetadata(
|
|
509
|
+
size=stat_result.st_size,
|
|
510
|
+
created_at=datetime.fromtimestamp(stat_result.st_ctime).astimezone(),
|
|
511
|
+
modified_at=datetime.fromtimestamp(stat_result.st_mtime).astimezone(),
|
|
512
|
+
)
|
|
398
513
|
|
|
399
514
|
def content_type(self, path: FilePath) -> str:
|
|
400
515
|
"""Return content_type for a given path.
|
|
@@ -5,8 +5,11 @@ to ensure consistent application startup across all entry points.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
12
|
+
|
|
10
13
|
from loguru import logger
|
|
11
14
|
|
|
12
15
|
from basic_memory import db
|
|
@@ -27,15 +30,12 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None:
|
|
|
27
30
|
Database migrations are now handled automatically when the database
|
|
28
31
|
connection is first established via get_or_create_db().
|
|
29
32
|
"""
|
|
30
|
-
# Trigger database initialization and migrations by getting the database connection
|
|
31
33
|
try:
|
|
32
34
|
await db.get_or_create_db(app_config.database_path)
|
|
33
35
|
logger.info("Database initialization completed")
|
|
34
36
|
except Exception as e:
|
|
35
|
-
logger.error(f"Error
|
|
36
|
-
|
|
37
|
-
# depending on what the error was, and will fail with a
|
|
38
|
-
# more specific error if the database is actually unusable
|
|
37
|
+
logger.error(f"Error during database initialization: {e}")
|
|
38
|
+
raise
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
|
@@ -49,31 +49,29 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
|
|
49
49
|
"""
|
|
50
50
|
logger.info("Reconciling projects from config with database...")
|
|
51
51
|
|
|
52
|
-
# Get database session
|
|
52
|
+
# Get database session (engine already created by initialize_database)
|
|
53
53
|
_, session_maker = await db.get_or_create_db(
|
|
54
54
|
db_path=app_config.database_path,
|
|
55
55
|
db_type=db.DatabaseType.FILESYSTEM,
|
|
56
|
-
ensure_migrations=False,
|
|
57
56
|
)
|
|
58
57
|
project_repository = ProjectRepository(session_maker)
|
|
59
58
|
|
|
60
59
|
# Import ProjectService here to avoid circular imports
|
|
61
60
|
from basic_memory.services.project_service import ProjectService
|
|
62
61
|
|
|
62
|
+
# Create project service and synchronize projects
|
|
63
|
+
project_service = ProjectService(repository=project_repository)
|
|
63
64
|
try:
|
|
64
|
-
# Create project service and synchronize projects
|
|
65
|
-
project_service = ProjectService(repository=project_repository)
|
|
66
65
|
await project_service.synchronize_projects()
|
|
67
66
|
logger.info("Projects successfully reconciled between config and database")
|
|
68
67
|
except Exception as e:
|
|
69
|
-
# Log the error but continue with initialization
|
|
70
68
|
logger.error(f"Error during project synchronization: {e}")
|
|
71
69
|
logger.info("Continuing with initialization despite synchronization error")
|
|
72
70
|
|
|
73
71
|
|
|
74
72
|
async def initialize_file_sync(
|
|
75
73
|
app_config: BasicMemoryConfig,
|
|
76
|
-
):
|
|
74
|
+
) -> None:
|
|
77
75
|
"""Initialize file synchronization services. This function starts the watch service and does not return
|
|
78
76
|
|
|
79
77
|
Args:
|
|
@@ -82,15 +80,20 @@ async def initialize_file_sync(
|
|
|
82
80
|
Returns:
|
|
83
81
|
The watch service task that's monitoring file changes
|
|
84
82
|
"""
|
|
83
|
+
# Never start file watching during tests. Even "background" watchers add tasks/threads
|
|
84
|
+
# and can interact badly with strict asyncio teardown (especially on Windows/aiosqlite).
|
|
85
|
+
# Skip file sync in test environments to avoid interference with tests
|
|
86
|
+
if app_config.is_test_env:
|
|
87
|
+
logger.info("Test environment detected - skipping file sync initialization")
|
|
88
|
+
return None
|
|
85
89
|
|
|
86
90
|
# delay import
|
|
87
91
|
from basic_memory.sync import WatchService
|
|
88
92
|
|
|
89
|
-
#
|
|
93
|
+
# Get database session (migrations already run if needed)
|
|
90
94
|
_, session_maker = await db.get_or_create_db(
|
|
91
95
|
db_path=app_config.database_path,
|
|
92
96
|
db_type=db.DatabaseType.FILESYSTEM,
|
|
93
|
-
ensure_migrations=False,
|
|
94
97
|
)
|
|
95
98
|
project_repository = ProjectRepository(session_maker)
|
|
96
99
|
|
|
@@ -104,6 +107,12 @@ async def initialize_file_sync(
|
|
|
104
107
|
# Get active projects
|
|
105
108
|
active_projects = await project_repository.get_active_projects()
|
|
106
109
|
|
|
110
|
+
# Filter to constrained project if MCP server was started with --project
|
|
111
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
112
|
+
if constrained_project:
|
|
113
|
+
active_projects = [p for p in active_projects if p.name == constrained_project]
|
|
114
|
+
logger.info(f"Background sync constrained to project: {constrained_project}")
|
|
115
|
+
|
|
107
116
|
# Start sync for all projects as background tasks (non-blocking)
|
|
108
117
|
async def sync_project_background(project: Project):
|
|
109
118
|
"""Sync a single project in the background."""
|
|
@@ -131,12 +140,10 @@ async def initialize_file_sync(
|
|
|
131
140
|
|
|
132
141
|
# Then start the watch service in the background
|
|
133
142
|
logger.info("Starting watch service for all projects")
|
|
143
|
+
|
|
134
144
|
# run the watch service
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
logger.info("Watch service started")
|
|
138
|
-
except Exception as e: # pragma: no cover
|
|
139
|
-
logger.error(f"Error starting watch service: {e}")
|
|
145
|
+
await watch_service.run()
|
|
146
|
+
logger.info("Watch service started")
|
|
140
147
|
|
|
141
148
|
return None
|
|
142
149
|
|
|
@@ -155,6 +162,11 @@ async def initialize_app(
|
|
|
155
162
|
Args:
|
|
156
163
|
app_config: The Basic Memory project configuration
|
|
157
164
|
"""
|
|
165
|
+
# Skip initialization in cloud mode - cloud manages its own projects
|
|
166
|
+
if app_config.cloud_mode_enabled:
|
|
167
|
+
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
|
168
|
+
return
|
|
169
|
+
|
|
158
170
|
logger.info("Initializing app...")
|
|
159
171
|
# Initialize database first
|
|
160
172
|
await initialize_database(app_config)
|
|
@@ -181,11 +193,24 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
|
|
181
193
|
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
|
|
182
194
|
return
|
|
183
195
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
async def _init_and_cleanup():
|
|
197
|
+
"""Initialize app and clean up database connections.
|
|
198
|
+
|
|
199
|
+
Database connections created during initialization must be cleaned up
|
|
200
|
+
before the event loop closes, otherwise the process will hang indefinitely.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
await initialize_app(app_config)
|
|
204
|
+
finally:
|
|
205
|
+
# Always cleanup database connections to prevent process hang
|
|
206
|
+
await db.shutdown_db()
|
|
207
|
+
|
|
208
|
+
# On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
|
|
209
|
+
# The ProactorEventLoop can raise "IndexError: pop from an empty deque" during
|
|
210
|
+
# event loop cleanup when there are pending handles. SelectorEventLoop is more
|
|
211
|
+
# stable for our use case (no subprocess pipes or named pipes needed).
|
|
212
|
+
if sys.platform == "win32":
|
|
213
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
214
|
+
|
|
215
|
+
asyncio.run(_init_and_cleanup())
|
|
216
|
+
logger.info("Initialization completed successfully")
|
|
@@ -8,6 +8,7 @@ from datetime import datetime
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Dict, Optional, Sequence
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
from loguru import logger
|
|
12
13
|
from sqlalchemy import text
|
|
13
14
|
|
|
@@ -23,9 +24,6 @@ from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_co
|
|
|
23
24
|
from basic_memory.utils import generate_permalink
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
config = ConfigManager().config
|
|
27
|
-
|
|
28
|
-
|
|
29
27
|
class ProjectService:
|
|
30
28
|
"""Service for managing Basic Memory projects."""
|
|
31
29
|
|
|
@@ -46,7 +44,7 @@ class ProjectService:
|
|
|
46
44
|
return ConfigManager()
|
|
47
45
|
|
|
48
46
|
@property
|
|
49
|
-
def config(self) -> ProjectConfig:
|
|
47
|
+
def config(self) -> ProjectConfig: # pragma: no cover
|
|
50
48
|
"""Get the current project configuration.
|
|
51
49
|
|
|
52
50
|
Returns:
|
|
@@ -143,6 +141,7 @@ class ProjectService:
|
|
|
143
141
|
"""
|
|
144
142
|
# If project_root is set, constrain all projects to that directory
|
|
145
143
|
project_root = self.config_manager.config.project_root
|
|
144
|
+
sanitized_name = None
|
|
146
145
|
if project_root:
|
|
147
146
|
base_path = Path(project_root)
|
|
148
147
|
|
|
@@ -155,11 +154,11 @@ class ProjectService:
|
|
|
155
154
|
resolved_path = (base_path / sanitized_name).resolve().as_posix()
|
|
156
155
|
|
|
157
156
|
# Verify the resolved path is actually under project_root
|
|
158
|
-
if not resolved_path.startswith(base_path.resolve().as_posix()):
|
|
157
|
+
if not resolved_path.startswith(base_path.resolve().as_posix()): # pragma: no cover
|
|
159
158
|
raise ValueError(
|
|
160
159
|
f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
|
|
161
160
|
f"All projects must be created under this directory. Invalid path: {path}"
|
|
162
|
-
)
|
|
161
|
+
) # pragma: no cover
|
|
163
162
|
|
|
164
163
|
# Check for case-insensitive path collisions with existing projects
|
|
165
164
|
existing_projects = await self.list_projects()
|
|
@@ -168,11 +167,11 @@ class ProjectService:
|
|
|
168
167
|
existing.path.lower() == resolved_path.lower()
|
|
169
168
|
and existing.path != resolved_path
|
|
170
169
|
):
|
|
171
|
-
raise ValueError(
|
|
170
|
+
raise ValueError( # pragma: no cover
|
|
172
171
|
f"Path collision detected: '{resolved_path}' conflicts with existing project "
|
|
173
172
|
f"'{existing.name}' at '{existing.path}'. "
|
|
174
173
|
f"In cloud mode, paths are normalized to lowercase to prevent case-sensitivity issues."
|
|
175
|
-
)
|
|
174
|
+
) # pragma: no cover
|
|
176
175
|
else:
|
|
177
176
|
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
|
|
178
177
|
|
|
@@ -199,14 +198,15 @@ class ProjectService:
|
|
|
199
198
|
f"Projects cannot share directory trees."
|
|
200
199
|
)
|
|
201
200
|
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
if not self.config_manager.config.cloud_mode:
|
|
202
|
+
# First add to config file (this will validate the project doesn't exist)
|
|
203
|
+
self.config_manager.add_project(name, resolved_path)
|
|
204
204
|
|
|
205
205
|
# Then add to database
|
|
206
206
|
project_data = {
|
|
207
207
|
"name": name,
|
|
208
208
|
"path": resolved_path,
|
|
209
|
-
"permalink":
|
|
209
|
+
"permalink": sanitized_name,
|
|
210
210
|
"is_active": True,
|
|
211
211
|
# Don't set is_default=False to avoid UNIQUE constraint issues
|
|
212
212
|
# Let it default to NULL, only set to True when explicitly making default
|
|
@@ -237,20 +237,22 @@ class ProjectService:
|
|
|
237
237
|
# Get project from database first
|
|
238
238
|
project = await self.get_project(name)
|
|
239
239
|
if not project:
|
|
240
|
-
raise ValueError(f"Project '{name}' not found")
|
|
240
|
+
raise ValueError(f"Project '{name}' not found") # pragma: no cover
|
|
241
241
|
|
|
242
242
|
project_path = project.path
|
|
243
243
|
|
|
244
244
|
# Check if project is default (in cloud mode, check database; in local mode, check config)
|
|
245
245
|
if project.is_default or name == self.config_manager.config.default_project:
|
|
246
|
-
raise ValueError(f"Cannot remove the default project '{name}'")
|
|
246
|
+
raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
|
|
247
247
|
|
|
248
248
|
# Remove from config if it exists there (may not exist in cloud mode)
|
|
249
249
|
try:
|
|
250
250
|
self.config_manager.remove_project(name)
|
|
251
|
-
except ValueError:
|
|
251
|
+
except ValueError: # pragma: no cover
|
|
252
252
|
# Project not in config - that's OK in cloud mode, continue with database deletion
|
|
253
|
-
logger.debug(
|
|
253
|
+
logger.debug( # pragma: no cover
|
|
254
|
+
f"Project '{name}' not found in config, removing from database only"
|
|
255
|
+
)
|
|
254
256
|
|
|
255
257
|
# Remove from database
|
|
256
258
|
await self.repository.delete(project.id)
|
|
@@ -265,11 +267,13 @@ class ProjectService:
|
|
|
265
267
|
await asyncio.to_thread(shutil.rmtree, project_path)
|
|
266
268
|
logger.info(f"Deleted project directory: {project_path}")
|
|
267
269
|
else:
|
|
268
|
-
logger.warning(
|
|
270
|
+
logger.warning( # pragma: no cover
|
|
269
271
|
f"Project directory not found or not a directory: {project_path}"
|
|
270
|
-
)
|
|
271
|
-
except Exception as e:
|
|
272
|
-
logger.warning(
|
|
272
|
+
) # pragma: no cover
|
|
273
|
+
except Exception as e: # pragma: no cover
|
|
274
|
+
logger.warning( # pragma: no cover
|
|
275
|
+
f"Failed to delete project directory {project_path}: {e}"
|
|
276
|
+
)
|
|
273
277
|
|
|
274
278
|
async def set_default_project(self, name: str) -> None:
|
|
275
279
|
"""Set the default project in configuration and database.
|
|
@@ -283,15 +287,17 @@ class ProjectService:
|
|
|
283
287
|
if not self.repository: # pragma: no cover
|
|
284
288
|
raise ValueError("Repository is required for set_default_project")
|
|
285
289
|
|
|
286
|
-
#
|
|
287
|
-
self.config_manager.set_default_project(name)
|
|
288
|
-
|
|
289
|
-
# Then update database using the same lookup logic as get_project
|
|
290
|
+
# Look up project in database first to validate it exists
|
|
290
291
|
project = await self.get_project(name)
|
|
291
|
-
if project:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
if not project:
|
|
293
|
+
raise ValueError(f"Project '{name}' not found")
|
|
294
|
+
|
|
295
|
+
# Update database
|
|
296
|
+
await self.repository.set_as_default(project.id)
|
|
297
|
+
|
|
298
|
+
# Update config file only in local mode (cloud mode uses database only)
|
|
299
|
+
if not self.config_manager.config.cloud_mode:
|
|
300
|
+
self.config_manager.set_default_project(name)
|
|
295
301
|
|
|
296
302
|
logger.info(f"Project '{name}' set as default in configuration and database")
|
|
297
303
|
|
|
@@ -430,8 +436,8 @@ class ProjectService:
|
|
|
430
436
|
Raises:
|
|
431
437
|
ValueError: If the project doesn't exist or repository isn't initialized
|
|
432
438
|
"""
|
|
433
|
-
if not self.repository:
|
|
434
|
-
raise ValueError("Repository is required for move_project")
|
|
439
|
+
if not self.repository: # pragma: no cover
|
|
440
|
+
raise ValueError("Repository is required for move_project") # pragma: no cover
|
|
435
441
|
|
|
436
442
|
# Resolve to absolute path
|
|
437
443
|
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
|
@@ -766,25 +772,42 @@ class ProjectService:
|
|
|
766
772
|
)
|
|
767
773
|
|
|
768
774
|
# Query for monthly entity creation (project filtered)
|
|
775
|
+
# Use different date formatting for SQLite vs Postgres
|
|
776
|
+
from basic_memory.config import DatabaseBackend
|
|
777
|
+
|
|
778
|
+
is_postgres = self.config_manager.config.database_backend == DatabaseBackend.POSTGRES
|
|
779
|
+
date_format = (
|
|
780
|
+
"to_char(created_at, 'YYYY-MM')" if is_postgres else "strftime('%Y-%m', created_at)"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Postgres needs datetime objects, SQLite needs ISO strings
|
|
784
|
+
six_months_param = six_months_ago if is_postgres else six_months_ago.isoformat()
|
|
785
|
+
|
|
769
786
|
entity_growth_result = await self.repository.execute_query(
|
|
770
|
-
text("""
|
|
771
|
-
SELECT
|
|
772
|
-
|
|
787
|
+
text(f"""
|
|
788
|
+
SELECT
|
|
789
|
+
{date_format} AS month,
|
|
773
790
|
COUNT(*) AS count
|
|
774
791
|
FROM entity
|
|
775
792
|
WHERE created_at >= :six_months_ago AND project_id = :project_id
|
|
776
793
|
GROUP BY month
|
|
777
794
|
ORDER BY month
|
|
778
795
|
"""),
|
|
779
|
-
{"six_months_ago":
|
|
796
|
+
{"six_months_ago": six_months_param, "project_id": project_id},
|
|
780
797
|
)
|
|
781
798
|
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
|
|
782
799
|
|
|
783
800
|
# Query for monthly observation creation (project filtered)
|
|
801
|
+
date_format_entity = (
|
|
802
|
+
"to_char(entity.created_at, 'YYYY-MM')"
|
|
803
|
+
if is_postgres
|
|
804
|
+
else "strftime('%Y-%m', entity.created_at)"
|
|
805
|
+
)
|
|
806
|
+
|
|
784
807
|
observation_growth_result = await self.repository.execute_query(
|
|
785
|
-
text("""
|
|
786
|
-
SELECT
|
|
787
|
-
|
|
808
|
+
text(f"""
|
|
809
|
+
SELECT
|
|
810
|
+
{date_format_entity} AS month,
|
|
788
811
|
COUNT(*) AS count
|
|
789
812
|
FROM observation
|
|
790
813
|
INNER JOIN entity ON observation.entity_id = entity.id
|
|
@@ -792,15 +815,15 @@ class ProjectService:
|
|
|
792
815
|
GROUP BY month
|
|
793
816
|
ORDER BY month
|
|
794
817
|
"""),
|
|
795
|
-
{"six_months_ago":
|
|
818
|
+
{"six_months_ago": six_months_param, "project_id": project_id},
|
|
796
819
|
)
|
|
797
820
|
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
|
|
798
821
|
|
|
799
822
|
# Query for monthly relation creation (project filtered)
|
|
800
823
|
relation_growth_result = await self.repository.execute_query(
|
|
801
|
-
text("""
|
|
802
|
-
SELECT
|
|
803
|
-
|
|
824
|
+
text(f"""
|
|
825
|
+
SELECT
|
|
826
|
+
{date_format_entity} AS month,
|
|
804
827
|
COUNT(*) AS count
|
|
805
828
|
FROM relation
|
|
806
829
|
INNER JOIN entity ON relation.from_id = entity.id
|
|
@@ -808,7 +831,7 @@ class ProjectService:
|
|
|
808
831
|
GROUP BY month
|
|
809
832
|
ORDER BY month
|
|
810
833
|
"""),
|
|
811
|
-
{"six_months_ago":
|
|
834
|
+
{"six_months_ago": six_months_param, "project_id": project_id},
|
|
812
835
|
)
|
|
813
836
|
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
|
|
814
837
|
|
|
@@ -849,8 +872,10 @@ class ProjectService:
|
|
|
849
872
|
watch_status = None
|
|
850
873
|
watch_status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
|
|
851
874
|
if watch_status_path.exists():
|
|
852
|
-
try:
|
|
853
|
-
watch_status = json.loads(
|
|
875
|
+
try: # pragma: no cover
|
|
876
|
+
watch_status = json.loads( # pragma: no cover
|
|
877
|
+
watch_status_path.read_text(encoding="utf-8")
|
|
878
|
+
)
|
|
854
879
|
except Exception: # pragma: no cover
|
|
855
880
|
pass
|
|
856
881
|
|