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

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

Potentially problematic release.


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

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,50 @@
1
1
  """Database management commands."""
2
2
 
3
3
  import asyncio
4
+ from pathlib import Path
4
5
 
5
6
  import typer
6
7
  from loguru import logger
8
+ from rich.console import Console
9
+ from sqlalchemy.exc import OperationalError
7
10
 
8
11
  from basic_memory import db
9
12
  from basic_memory.cli.app import app
10
- from basic_memory.config import ConfigManager, BasicMemoryConfig, save_basic_memory_config
13
+ from basic_memory.config import ConfigManager
14
+ from basic_memory.repository import ProjectRepository
15
+ from basic_memory.services.initialization import reconcile_projects_with_config
16
+ from basic_memory.sync.sync_service import get_sync_service
17
+
18
+ console = Console()
19
+
20
+
21
+ async def _reindex_projects(app_config):
22
+ """Reindex all projects in a single async context.
23
+
24
+ This ensures all database operations use the same event loop,
25
+ and proper cleanup happens when the function completes.
26
+ """
27
+ try:
28
+ await reconcile_projects_with_config(app_config)
29
+
30
+ # Get database session (migrations already run if needed)
31
+ _, session_maker = await db.get_or_create_db(
32
+ db_path=app_config.database_path,
33
+ db_type=db.DatabaseType.FILESYSTEM,
34
+ )
35
+ project_repository = ProjectRepository(session_maker)
36
+ projects = await project_repository.get_active_projects()
37
+
38
+ for project in projects:
39
+ console.print(f" Indexing [cyan]{project.name}[/cyan]...")
40
+ logger.info(f"Starting sync for project: {project.name}")
41
+ sync_service = await get_sync_service(project)
42
+ sync_dir = Path(project.path)
43
+ await sync_service.sync(sync_dir, project_name=project.name)
44
+ logger.info(f"Sync completed for project: {project.name}")
45
+ finally:
46
+ # Clean up database connections before event loop closes
47
+ await db.shutdown_db()
11
48
 
12
49
 
13
50
  @app.command()
@@ -15,30 +52,52 @@ def reset(
15
52
  reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"),
16
53
  ): # pragma: no cover
17
54
  """Reset database (drop all tables and recreate)."""
18
- if typer.confirm("This will delete all data in your db. Are you sure?"):
55
+ console.print(
56
+ "[yellow]Note:[/yellow] This only deletes the index database. "
57
+ "Your markdown note files will not be affected.\n"
58
+ "Use [green]bm reset --reindex[/green] to automatically rebuild the index afterward."
59
+ )
60
+ if typer.confirm("Reset the database index?"):
19
61
  logger.info("Resetting database...")
20
62
  config_manager = ConfigManager()
21
63
  app_config = config_manager.config
22
64
  # Get database path
23
65
  db_path = app_config.app_database_path
24
66
 
25
- # Delete the database file if it exists
26
- if db_path.exists():
27
- db_path.unlink()
28
- logger.info(f"Database file deleted: {db_path}")
67
+ # Delete the database file and WAL files if they exist
68
+ for suffix in ["", "-shm", "-wal"]:
69
+ path = db_path.parent / f"{db_path.name}{suffix}"
70
+ if path.exists():
71
+ try:
72
+ path.unlink()
73
+ logger.info(f"Deleted: {path}")
74
+ except OSError as e:
75
+ console.print(
76
+ f"[red]Error:[/red] Cannot delete {path.name}: {e}\n"
77
+ "The database may be in use by another process (e.g., MCP server).\n"
78
+ "Please close Claude Desktop or any other Basic Memory clients and try again."
79
+ )
80
+ raise typer.Exit(1)
29
81
 
30
- # Reset project configuration
31
- config = BasicMemoryConfig()
32
- save_basic_memory_config(config_manager.config_file, config)
33
- logger.info("Project configuration reset to default")
34
-
35
- # Create a new empty database
36
- asyncio.run(db.run_migrations(app_config))
37
- logger.info("Database reset complete")
82
+ # Create a new empty database (preserves project configuration)
83
+ try:
84
+ asyncio.run(db.run_migrations(app_config))
85
+ except OperationalError as e:
86
+ if "disk I/O error" in str(e) or "database is locked" in str(e):
87
+ console.print(
88
+ "[red]Error:[/red] Cannot access database. "
89
+ "It may be in use by another process (e.g., MCP server).\n"
90
+ "Please close Claude Desktop or any other Basic Memory clients and try again."
91
+ )
92
+ raise typer.Exit(1)
93
+ raise
94
+ console.print("[green]Database reset complete[/green]")
38
95
 
39
96
  if reindex:
40
- # Run database sync directly
41
- from basic_memory.cli.commands.command_utils import run_sync
42
-
43
- logger.info("Rebuilding search index from filesystem...")
44
- asyncio.run(run_sync(project=None))
97
+ projects = list(app_config.projects)
98
+ if not projects:
99
+ console.print("[yellow]No projects configured. Skipping reindex.[/yellow]")
100
+ else:
101
+ console.print(f"Rebuilding search index for {len(projects)} project(s)...")
102
+ asyncio.run(_reindex_projects(app_config))
103
+ console.print("[green]Reindex complete[/green]")
@@ -0,0 +1,198 @@
1
+ """Format command for basic-memory CLI."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from loguru import logger
9
+ from rich.console import Console
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ from basic_memory.cli.app import app
13
+ from basic_memory.config import ConfigManager, get_project_config
14
+ from basic_memory.file_utils import format_file
15
+
16
+ console = Console()
17
+
18
+
19
+ def is_markdown_extension(path: Path) -> bool:
20
+ """Check if file has a markdown extension."""
21
+ return path.suffix.lower() in (".md", ".markdown")
22
+
23
+
24
+ async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]:
25
+ """Format a single file.
26
+
27
+ Returns:
28
+ Tuple of (path, success, error_message)
29
+ """
30
+ try:
31
+ result = await format_file(
32
+ file_path, app_config, is_markdown=is_markdown_extension(file_path)
33
+ )
34
+ if result is not None:
35
+ return (file_path, True, None)
36
+ else:
37
+ return (file_path, False, "No formatter configured or formatting skipped")
38
+ except Exception as e:
39
+ return (file_path, False, str(e))
40
+
41
+
42
+ async def format_files(
43
+ paths: list[Path], app_config, show_progress: bool = True
44
+ ) -> tuple[int, int, list[tuple[Path, str]]]:
45
+ """Format multiple files.
46
+
47
+ Returns:
48
+ Tuple of (formatted_count, skipped_count, errors)
49
+ """
50
+ formatted = 0
51
+ skipped = 0
52
+ errors: list[tuple[Path, str]] = []
53
+
54
+ if show_progress:
55
+ with Progress(
56
+ SpinnerColumn(),
57
+ TextColumn("[progress.description]{task.description}"),
58
+ console=console,
59
+ ) as progress:
60
+ task = progress.add_task("Formatting files...", total=len(paths))
61
+
62
+ for file_path in paths:
63
+ path, success, error = await format_single_file(file_path, app_config)
64
+ if success:
65
+ formatted += 1
66
+ elif error and "No formatter configured" not in error:
67
+ errors.append((path, error))
68
+ else:
69
+ skipped += 1
70
+ progress.update(task, advance=1)
71
+ else:
72
+ for file_path in paths:
73
+ path, success, error = await format_single_file(file_path, app_config)
74
+ if success:
75
+ formatted += 1
76
+ elif error and "No formatter configured" not in error:
77
+ errors.append((path, error))
78
+ else:
79
+ skipped += 1
80
+
81
+ return formatted, skipped, errors
82
+
83
+
84
+ async def run_format(
85
+ path: Optional[Path] = None,
86
+ project: Optional[str] = None,
87
+ ) -> None:
88
+ """Run the format command."""
89
+ app_config = ConfigManager().config
90
+
91
+ # Check if formatting is enabled
92
+ if (
93
+ not app_config.format_on_save
94
+ and not app_config.formatter_command
95
+ and not app_config.formatters
96
+ ):
97
+ console.print(
98
+ "[yellow]No formatters configured. Set format_on_save=true and "
99
+ "formatter_command or formatters in your config.[/yellow]"
100
+ )
101
+ console.print(
102
+ "\nExample config (~/.basic-memory/config.json):\n"
103
+ ' "format_on_save": true,\n'
104
+ ' "formatter_command": "prettier --write {file}"\n'
105
+ )
106
+ raise typer.Exit(1)
107
+
108
+ # Temporarily enable format_on_save for this command
109
+ # (so format_file actually runs the formatter)
110
+ original_format_on_save = app_config.format_on_save
111
+ app_config.format_on_save = True
112
+
113
+ try:
114
+ # Determine which files to format
115
+ if path:
116
+ # Format specific file or directory
117
+ if path.is_file():
118
+ files = [path]
119
+ elif path.is_dir():
120
+ # Find all markdown and json files
121
+ files = (
122
+ list(path.rglob("*.md"))
123
+ + list(path.rglob("*.json"))
124
+ + list(path.rglob("*.canvas"))
125
+ )
126
+ else:
127
+ console.print(f"[red]Path not found: {path}[/red]")
128
+ raise typer.Exit(1)
129
+ else:
130
+ # Format all files in project
131
+ project_config = get_project_config(project)
132
+ project_path = Path(project_config.home)
133
+
134
+ if not project_path.exists():
135
+ console.print(f"[red]Project path not found: {project_path}[/red]")
136
+ raise typer.Exit(1)
137
+
138
+ # Find all markdown and json files
139
+ files = (
140
+ list(project_path.rglob("*.md"))
141
+ + list(project_path.rglob("*.json"))
142
+ + list(project_path.rglob("*.canvas"))
143
+ )
144
+
145
+ if not files:
146
+ console.print("[yellow]No files found to format.[/yellow]")
147
+ return
148
+
149
+ console.print(f"Found {len(files)} file(s) to format...")
150
+
151
+ formatted, skipped, errors = await format_files(files, app_config)
152
+
153
+ # Print summary
154
+ console.print()
155
+ if formatted > 0:
156
+ console.print(f"[green]Formatted: {formatted} file(s)[/green]")
157
+ if skipped > 0:
158
+ console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]")
159
+ if errors:
160
+ console.print(f"[red]Errors: {len(errors)} file(s)[/red]")
161
+ for path, error in errors:
162
+ console.print(f" [red]{path}[/red]: {error}")
163
+
164
+ finally:
165
+ # Restore original setting
166
+ app_config.format_on_save = original_format_on_save
167
+
168
+
169
+ @app.command()
170
+ def format(
171
+ path: Annotated[
172
+ Optional[Path],
173
+ typer.Argument(help="File or directory to format. Defaults to current project."),
174
+ ] = None,
175
+ project: Annotated[
176
+ Optional[str],
177
+ typer.Option("--project", "-p", help="Project name to format."),
178
+ ] = None,
179
+ ) -> None:
180
+ """Format files using configured formatters.
181
+
182
+ Uses the formatter_command or formatters settings from your config.
183
+ By default, formats all .md, .json, and .canvas files in the current project.
184
+
185
+ Examples:
186
+ basic-memory format # Format all files in current project
187
+ basic-memory format --project research # Format files in specific project
188
+ basic-memory format notes/meeting.md # Format a specific file
189
+ basic-memory format notes/ # Format all files in directory
190
+ """
191
+ try:
192
+ asyncio.run(run_format(path, project))
193
+ except Exception as e:
194
+ if not isinstance(e, typer.Exit):
195
+ logger.error(f"Error formatting files: {e}")
196
+ console.print(f"[red]Error formatting files: {e}[/red]")
197
+ raise typer.Exit(code=1)
198
+ raise
@@ -3,13 +3,14 @@
3
3
  import asyncio
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Annotated
6
+ from typing import Annotated, Tuple
7
7
 
8
8
  import typer
9
9
  from basic_memory.cli.app import import_app
10
- from basic_memory.config import get_project_config
10
+ from basic_memory.config import ConfigManager, get_project_config
11
11
  from basic_memory.importers import ChatGPTImporter
12
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
13
+ from basic_memory.services.file_service import FileService
13
14
  from loguru import logger
14
15
  from rich.console import Console
15
16
  from rich.panel import Panel
@@ -17,11 +18,14 @@ from rich.panel import Panel
17
18
  console = Console()
18
19
 
19
20
 
20
- async def get_markdown_processor() -> MarkdownProcessor:
21
- """Get MarkdownProcessor instance."""
21
+ async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
22
+ """Get MarkdownProcessor and FileService instances for importers."""
22
23
  config = get_project_config()
24
+ app_config = ConfigManager().config
23
25
  entity_parser = EntityParser(config.home)
24
- return MarkdownProcessor(entity_parser)
26
+ markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
27
+ file_service = FileService(config.home, markdown_processor, app_config=app_config)
28
+ return markdown_processor, file_service
25
29
 
26
30
 
27
31
  @import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
@@ -48,15 +52,15 @@ def import_chatgpt(
48
52
  typer.echo(f"Error: File not found: {conversations_json}", err=True)
49
53
  raise typer.Exit(1)
50
54
 
51
- # Get markdown processor
52
- markdown_processor = asyncio.run(get_markdown_processor())
55
+ # Get importer dependencies
56
+ markdown_processor, file_service = asyncio.run(get_importer_dependencies())
53
57
  config = get_project_config()
54
58
  # Process the file
55
59
  base_path = config.home / folder
56
60
  console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
57
61
 
58
62
  # Create importer and run import
59
- importer = ChatGPTImporter(config.home, markdown_processor)
63
+ importer = ChatGPTImporter(config.home, markdown_processor, file_service)
60
64
  with conversations_json.open("r", encoding="utf-8") as file:
61
65
  json_data = json.load(file)
62
66
  result = asyncio.run(importer.import_data(json_data, folder))
@@ -3,13 +3,14 @@
3
3
  import asyncio
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Annotated
6
+ from typing import Annotated, Tuple
7
7
 
8
8
  import typer
9
9
  from basic_memory.cli.app import claude_app
10
- from basic_memory.config import get_project_config
10
+ from basic_memory.config import ConfigManager, get_project_config
11
11
  from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
12
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
13
+ from basic_memory.services.file_service import FileService
13
14
  from loguru import logger
14
15
  from rich.console import Console
15
16
  from rich.panel import Panel
@@ -17,11 +18,14 @@ from rich.panel import Panel
17
18
  console = Console()
18
19
 
19
20
 
20
- async def get_markdown_processor() -> MarkdownProcessor:
21
- """Get MarkdownProcessor instance."""
21
+ async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
22
+ """Get MarkdownProcessor and FileService instances for importers."""
22
23
  config = get_project_config()
24
+ app_config = ConfigManager().config
23
25
  entity_parser = EntityParser(config.home)
24
- return MarkdownProcessor(entity_parser)
26
+ markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
27
+ file_service = FileService(config.home, markdown_processor, app_config=app_config)
28
+ return markdown_processor, file_service
25
29
 
26
30
 
27
31
  @claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
@@ -49,11 +53,11 @@ def import_claude(
49
53
  typer.echo(f"Error: File not found: {conversations_json}", err=True)
50
54
  raise typer.Exit(1)
51
55
 
52
- # Get markdown processor
53
- markdown_processor = asyncio.run(get_markdown_processor())
56
+ # Get importer dependencies
57
+ markdown_processor, file_service = asyncio.run(get_importer_dependencies())
54
58
 
55
59
  # Create the importer
56
- importer = ClaudeConversationsImporter(config.home, markdown_processor)
60
+ importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
57
61
 
58
62
  # Process the file
59
63
  base_path = config.home / folder
@@ -3,13 +3,14 @@
3
3
  import asyncio
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Annotated
6
+ from typing import Annotated, Tuple
7
7
 
8
8
  import typer
9
9
  from basic_memory.cli.app import claude_app
10
- from basic_memory.config import get_project_config
10
+ from basic_memory.config import ConfigManager, get_project_config
11
11
  from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
12
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
13
+ from basic_memory.services.file_service import FileService
13
14
  from loguru import logger
14
15
  from rich.console import Console
15
16
  from rich.panel import Panel
@@ -17,11 +18,14 @@ from rich.panel import Panel
17
18
  console = Console()
18
19
 
19
20
 
20
- async def get_markdown_processor() -> MarkdownProcessor:
21
- """Get MarkdownProcessor instance."""
21
+ async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
22
+ """Get MarkdownProcessor and FileService instances for importers."""
22
23
  config = get_project_config()
24
+ app_config = ConfigManager().config
23
25
  entity_parser = EntityParser(config.home)
24
- return MarkdownProcessor(entity_parser)
26
+ markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
27
+ file_service = FileService(config.home, markdown_processor, app_config=app_config)
28
+ return markdown_processor, file_service
25
29
 
26
30
 
27
31
  @claude_app.command(name="projects", help="Import projects from Claude.ai.")
@@ -48,11 +52,11 @@ def import_projects(
48
52
  typer.echo(f"Error: File not found: {projects_json}", err=True)
49
53
  raise typer.Exit(1)
50
54
 
51
- # Get markdown processor
52
- markdown_processor = asyncio.run(get_markdown_processor())
55
+ # Get importer dependencies
56
+ markdown_processor, file_service = asyncio.run(get_importer_dependencies())
53
57
 
54
58
  # Create the importer
55
- importer = ClaudeProjectsImporter(config.home, markdown_processor)
59
+ importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
56
60
 
57
61
  # Process the file
58
62
  base_path = config.home / base_folder if base_folder else config.home
@@ -3,13 +3,14 @@
3
3
  import asyncio
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Annotated
6
+ from typing import Annotated, Tuple
7
7
 
8
8
  import typer
9
9
  from basic_memory.cli.app import import_app
10
- from basic_memory.config import get_project_config
10
+ from basic_memory.config import ConfigManager, get_project_config
11
11
  from basic_memory.importers.memory_json_importer import MemoryJsonImporter
12
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
13
+ from basic_memory.services.file_service import FileService
13
14
  from loguru import logger
14
15
  from rich.console import Console
15
16
  from rich.panel import Panel
@@ -17,11 +18,14 @@ from rich.panel import Panel
17
18
  console = Console()
18
19
 
19
20
 
20
- async def get_markdown_processor() -> MarkdownProcessor:
21
- """Get MarkdownProcessor instance."""
21
+ async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
22
+ """Get MarkdownProcessor and FileService instances for importers."""
22
23
  config = get_project_config()
24
+ app_config = ConfigManager().config
23
25
  entity_parser = EntityParser(config.home)
24
- return MarkdownProcessor(entity_parser)
26
+ markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
27
+ file_service = FileService(config.home, markdown_processor, app_config=app_config)
28
+ return markdown_processor, file_service
25
29
 
26
30
 
27
31
  @import_app.command()
@@ -47,11 +51,11 @@ def memory_json(
47
51
 
48
52
  config = get_project_config()
49
53
  try:
50
- # Get markdown processor
51
- markdown_processor = asyncio.run(get_markdown_processor())
54
+ # Get importer dependencies
55
+ markdown_processor, file_service = asyncio.run(get_importer_dependencies())
52
56
 
53
57
  # Create the importer
54
- importer = MemoryJsonImporter(config.home, markdown_processor)
58
+ importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
55
59
 
56
60
  # Process the file
57
61
  base_path = config.home if not destination_folder else config.home / destination_folder
@@ -1,14 +1,13 @@
1
1
  """MCP server command with streamable HTTP transport."""
2
2
 
3
- import asyncio
4
3
  import os
5
4
  import typer
6
5
  from typing import Optional
7
6
 
8
7
  from basic_memory.cli.app import app
9
- from basic_memory.config import ConfigManager
8
+ from basic_memory.config import ConfigManager, init_mcp_logging
10
9
 
11
- # Import mcp instance
10
+ # Import mcp instance (has lifespan that handles initialization and file sync)
12
11
  from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
13
12
 
14
13
  # Import mcp tools to register them
@@ -17,8 +16,6 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
17
16
  # Import prompts to register them
18
17
  import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
19
18
  from loguru import logger
20
- import threading
21
- from basic_memory.services.initialization import initialize_file_sync
22
19
 
23
20
  config = ConfigManager().config
24
21
 
@@ -43,7 +40,11 @@ if not config.cloud_mode_enabled:
43
40
  - stdio: Standard I/O (good for local usage)
44
41
  - streamable-http: Recommended for web deployments (default)
45
42
  - sse: Server-Sent Events (for compatibility with existing clients)
43
+
44
+ Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
46
45
  """
46
+ # Initialize logging for MCP (file only, stdout breaks protocol)
47
+ init_mcp_logging()
47
48
 
48
49
  # Validate and set project constraint if specified
49
50
  if project:
@@ -57,27 +58,8 @@ if not config.cloud_mode_enabled:
57
58
  os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
58
59
  logger.info(f"MCP server constrained to project: {project_name}")
59
60
 
60
- app_config = ConfigManager().config
61
-
62
- def run_file_sync():
63
- """Run file sync in a separate thread with its own event loop."""
64
- loop = asyncio.new_event_loop()
65
- asyncio.set_event_loop(loop)
66
- try:
67
- loop.run_until_complete(initialize_file_sync(app_config))
68
- except Exception as e:
69
- logger.error(f"File sync error: {e}", err=True)
70
- finally:
71
- loop.close()
72
-
73
- logger.info(f"Sync changes enabled: {app_config.sync_changes}")
74
- if app_config.sync_changes:
75
- # Start the sync thread
76
- sync_thread = threading.Thread(target=run_file_sync, daemon=True)
77
- sync_thread.start()
78
- logger.info("Started file sync in background")
79
-
80
- # Now run the MCP server (blocks)
61
+ # Run the MCP server (blocks)
62
+ # Lifespan handles: initialization, migrations, file sync, cleanup
81
63
  logger.info(f"Starting MCP server with {transport.upper()} transport")
82
64
 
83
65
  if transport == "stdio":
@@ -16,14 +16,9 @@ from datetime import datetime
16
16
 
17
17
  from rich.panel import Panel
18
18
  from basic_memory.mcp.async_client import get_client
19
- from basic_memory.mcp.tools.utils import call_get
20
- from basic_memory.schemas.project_info import ProjectList
21
- from basic_memory.mcp.tools.utils import call_post
22
- from basic_memory.schemas.project_info import ProjectStatusResponse
23
- from basic_memory.mcp.tools.utils import call_delete
24
- from basic_memory.mcp.tools.utils import call_put
19
+ from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
20
+ from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
25
21
  from basic_memory.utils import generate_permalink, normalize_project_path
26
- from basic_memory.mcp.tools.utils import call_patch
27
22
 
28
23
  # Import rclone commands for project sync
29
24
  from basic_memory.cli.commands.cloud.rclone_commands import (
@@ -254,9 +249,17 @@ def remove_project(
254
249
 
255
250
  async def _remove_project():
256
251
  async with get_client() as client:
252
+ # Convert name to permalink for efficient resolution
257
253
  project_permalink = generate_permalink(name)
254
+
255
+ # Use v2 project resolver to find project ID by permalink
256
+ resolve_data = {"identifier": project_permalink}
257
+ response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
258
+ target_project = response.json()
259
+
260
+ # Use v2 API with project ID
258
261
  response = await call_delete(
259
- client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
262
+ client, f"/v2/projects/{target_project['external_id']}?delete_notes={delete_notes}"
260
263
  )
261
264
  return ProjectStatusResponse.model_validate(response.json())
262
265
 
@@ -329,8 +332,18 @@ def set_default_project(
329
332
 
330
333
  async def _set_default():
331
334
  async with get_client() as client:
335
+ # Convert name to permalink for efficient resolution
332
336
  project_permalink = generate_permalink(name)
333
- response = await call_put(client, f"/projects/{project_permalink}/default")
337
+
338
+ # Use v2 project resolver to find project ID by permalink
339
+ resolve_data = {"identifier": project_permalink}
340
+ response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
341
+ target_project = response.json()
342
+
343
+ # Use v2 API with project ID
344
+ response = await call_put(
345
+ client, f"/v2/projects/{target_project['external_id']}/default"
346
+ )
334
347
  return ProjectStatusResponse.model_validate(response.json())
335
348
 
336
349
  try:
@@ -1,6 +1,5 @@
1
1
  """Status command for basic-memory CLI."""
2
2
 
3
- import asyncio
4
3
  from typing import Set, Dict
5
4
  from typing import Annotated, Optional
6
5
 
@@ -165,8 +164,10 @@ def status(
165
164
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
166
165
  ):
167
166
  """Show sync status between files and database."""
167
+ from basic_memory.cli.commands.command_utils import run_with_cleanup
168
+
168
169
  try:
169
- asyncio.run(run_status(project, verbose)) # pragma: no cover
170
+ run_with_cleanup(run_status(project, verbose)) # pragma: no cover
170
171
  except Exception as e:
171
172
  logger.error(f"Error checking status: {e}")
172
173
  typer.echo(f"Error checking status: {e}", err=True)