basic-memory 0.15.0__py3-none-any.whl → 0.15.2__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 (53) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/routers/directory_router.py +23 -2
  3. basic_memory/api/routers/project_router.py +1 -0
  4. basic_memory/cli/auth.py +2 -2
  5. basic_memory/cli/commands/cloud/__init__.py +2 -1
  6. basic_memory/cli/commands/cloud/bisync_commands.py +4 -57
  7. basic_memory/cli/commands/cloud/cloud_utils.py +100 -0
  8. basic_memory/cli/commands/cloud/upload.py +128 -0
  9. basic_memory/cli/commands/cloud/upload_command.py +93 -0
  10. basic_memory/cli/commands/command_utils.py +11 -28
  11. basic_memory/cli/commands/mcp.py +72 -67
  12. basic_memory/cli/commands/project.py +140 -120
  13. basic_memory/cli/commands/status.py +6 -15
  14. basic_memory/config.py +55 -9
  15. basic_memory/deps.py +7 -5
  16. basic_memory/ignore_utils.py +7 -7
  17. basic_memory/mcp/async_client.py +102 -4
  18. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  19. basic_memory/mcp/prompts/search.py +12 -11
  20. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  21. basic_memory/mcp/resources/project_info.py +9 -7
  22. basic_memory/mcp/tools/build_context.py +40 -39
  23. basic_memory/mcp/tools/canvas.py +21 -20
  24. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  25. basic_memory/mcp/tools/delete_note.py +22 -21
  26. basic_memory/mcp/tools/edit_note.py +105 -104
  27. basic_memory/mcp/tools/list_directory.py +98 -95
  28. basic_memory/mcp/tools/move_note.py +127 -125
  29. basic_memory/mcp/tools/project_management.py +101 -98
  30. basic_memory/mcp/tools/read_content.py +64 -63
  31. basic_memory/mcp/tools/read_note.py +88 -88
  32. basic_memory/mcp/tools/recent_activity.py +139 -135
  33. basic_memory/mcp/tools/search.py +27 -26
  34. basic_memory/mcp/tools/sync_status.py +133 -128
  35. basic_memory/mcp/tools/utils.py +0 -15
  36. basic_memory/mcp/tools/view_note.py +14 -28
  37. basic_memory/mcp/tools/write_note.py +97 -87
  38. basic_memory/repository/entity_repository.py +60 -0
  39. basic_memory/repository/repository.py +16 -3
  40. basic_memory/repository/search_repository.py +42 -0
  41. basic_memory/schemas/cloud.py +7 -3
  42. basic_memory/schemas/project_info.py +1 -1
  43. basic_memory/services/directory_service.py +124 -3
  44. basic_memory/services/entity_service.py +31 -9
  45. basic_memory/services/project_service.py +97 -10
  46. basic_memory/services/search_service.py +16 -8
  47. basic_memory/sync/sync_service.py +28 -13
  48. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/METADATA +51 -4
  49. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/RECORD +52 -50
  50. basic_memory/mcp/tools/headers.py +0 -44
  51. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/WHEEL +0 -0
  52. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/entry_points.txt +0 -0
  53. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/licenses/LICENSE +0 -0
@@ -20,70 +20,75 @@ from loguru import logger
20
20
  import threading
21
21
  from basic_memory.services.initialization import initialize_file_sync
22
22
 
23
-
24
- @app.command()
25
- def mcp(
26
- transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
27
- host: str = typer.Option(
28
- "0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
29
- ),
30
- port: int = typer.Option(8000, help="Port for HTTP transports"),
31
- path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
32
- project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
33
- ): # pragma: no cover
34
- """Run the MCP server with configurable transport options.
35
-
36
- This command starts an MCP server using one of three transport options:
37
-
38
- - stdio: Standard I/O (good for local usage)
39
- - streamable-http: Recommended for web deployments (default)
40
- - sse: Server-Sent Events (for compatibility with existing clients)
41
- """
42
-
43
- # Validate and set project constraint if specified
44
- if project:
45
- config_manager = ConfigManager()
46
- project_name, _ = config_manager.get_project(project)
47
- if not project_name:
48
- typer.echo(f"No project found named: {project}", err=True)
49
- raise typer.Exit(1)
50
-
51
- # Set env var with validated project name
52
- os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
53
- logger.info(f"MCP server constrained to project: {project_name}")
54
-
55
- app_config = ConfigManager().config
56
-
57
- def run_file_sync():
58
- """Run file sync in a separate thread with its own event loop."""
59
- loop = asyncio.new_event_loop()
60
- asyncio.set_event_loop(loop)
61
- try:
62
- loop.run_until_complete(initialize_file_sync(app_config))
63
- except Exception as e:
64
- logger.error(f"File sync error: {e}", err=True)
65
- finally:
66
- loop.close()
67
-
68
- logger.info(f"Sync changes enabled: {app_config.sync_changes}")
69
- if app_config.sync_changes:
70
- # Start the sync thread
71
- sync_thread = threading.Thread(target=run_file_sync, daemon=True)
72
- sync_thread.start()
73
- logger.info("Started file sync in background")
74
-
75
- # Now run the MCP server (blocks)
76
- logger.info(f"Starting MCP server with {transport.upper()} transport")
77
-
78
- if transport == "stdio":
79
- mcp_server.run(
80
- transport=transport,
81
- )
82
- elif transport == "streamable-http" or transport == "sse":
83
- mcp_server.run(
84
- transport=transport,
85
- host=host,
86
- port=port,
87
- path=path,
88
- log_level="INFO",
89
- )
23
+ config = ConfigManager().config
24
+
25
+ if not config.cloud_mode_enabled:
26
+
27
+ @app.command()
28
+ def mcp(
29
+ transport: str = typer.Option(
30
+ "stdio", help="Transport type: stdio, streamable-http, or sse"
31
+ ),
32
+ host: str = typer.Option(
33
+ "0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
34
+ ),
35
+ port: int = typer.Option(8000, help="Port for HTTP transports"),
36
+ path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
37
+ project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
38
+ ): # pragma: no cover
39
+ """Run the MCP server with configurable transport options.
40
+
41
+ This command starts an MCP server using one of three transport options:
42
+
43
+ - stdio: Standard I/O (good for local usage)
44
+ - streamable-http: Recommended for web deployments (default)
45
+ - sse: Server-Sent Events (for compatibility with existing clients)
46
+ """
47
+
48
+ # Validate and set project constraint if specified
49
+ if project:
50
+ config_manager = ConfigManager()
51
+ project_name, _ = config_manager.get_project(project)
52
+ if not project_name:
53
+ typer.echo(f"No project found named: {project}", err=True)
54
+ raise typer.Exit(1)
55
+
56
+ # Set env var with validated project name
57
+ os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
58
+ logger.info(f"MCP server constrained to project: {project_name}")
59
+
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)
81
+ logger.info(f"Starting MCP server with {transport.upper()} transport")
82
+
83
+ if transport == "stdio":
84
+ mcp_server.run(
85
+ transport=transport,
86
+ )
87
+ elif transport == "streamable-http" or transport == "sse":
88
+ mcp_server.run(
89
+ transport=transport,
90
+ host=host,
91
+ port=port,
92
+ path=path,
93
+ log_level="INFO",
94
+ )
@@ -9,14 +9,13 @@ from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
11
  from basic_memory.cli.app import app
12
- from basic_memory.cli.commands.cloud import get_authenticated_headers
13
12
  from basic_memory.cli.commands.command_utils import get_project_info
14
13
  from basic_memory.config import ConfigManager
15
14
  import json
16
15
  from datetime import datetime
17
16
 
18
17
  from rich.panel import Panel
19
- from basic_memory.mcp.async_client import client
18
+ from basic_memory.mcp.async_client import get_client
20
19
  from basic_memory.mcp.tools.utils import call_get
21
20
  from basic_memory.schemas.project_info import ProjectList
22
21
  from basic_memory.mcp.tools.utils import call_post
@@ -32,8 +31,6 @@ console = Console()
32
31
  project_app = typer.Typer(help="Manage multiple Basic Memory projects")
33
32
  app.add_typer(project_app, name="project")
34
33
 
35
- config = ConfigManager().config
36
-
37
34
 
38
35
  def format_path(path: str) -> str:
39
36
  """Format a path for display, using ~ for home directory."""
@@ -46,14 +43,14 @@ def format_path(path: str) -> str:
46
43
  @project_app.command("list")
47
44
  def list_projects() -> None:
48
45
  """List all Basic Memory projects."""
49
- # Use API to list projects
50
- try:
51
- auth_headers = {}
52
- if config.cloud_mode_enabled:
53
- auth_headers = asyncio.run(get_authenticated_headers())
54
46
 
55
- response = asyncio.run(call_get(client, "/projects/projects", headers=auth_headers))
56
- result = ProjectList.model_validate(response.json())
47
+ async def _list_projects():
48
+ async with get_client() as client:
49
+ response = await call_get(client, "/projects/projects")
50
+ return ProjectList.model_validate(response.json())
51
+
52
+ try:
53
+ result = asyncio.run(_list_projects())
57
54
 
58
55
  table = Table(title="Basic Memory Projects")
59
56
  table.add_column("Name", style="cyan")
@@ -70,59 +67,53 @@ def list_projects() -> None:
70
67
  raise typer.Exit(1)
71
68
 
72
69
 
73
- if config.cloud_mode_enabled:
74
-
75
- @project_app.command("add")
76
- def add_project_cloud(
77
- name: str = typer.Argument(..., help="Name of the project"),
78
- set_default: bool = typer.Option(False, "--default", help="Set as default project"),
79
- ) -> None:
80
- """Add a new project to Basic Memory Cloud"""
81
-
82
- try:
83
- auth_headers = asyncio.run(get_authenticated_headers())
84
-
85
- data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
86
-
87
- response = asyncio.run(
88
- call_post(client, "/projects/projects", json=data, headers=auth_headers)
89
- )
90
- result = ProjectStatusResponse.model_validate(response.json())
91
-
92
- console.print(f"[green]{result.message}[/green]")
93
- except Exception as e:
94
- console.print(f"[red]Error adding project: {str(e)}[/red]")
70
+ @project_app.command("add")
71
+ def add_project(
72
+ name: str = typer.Argument(..., help="Name of the project"),
73
+ path: str = typer.Argument(
74
+ None, help="Path to the project directory (required for local mode)"
75
+ ),
76
+ set_default: bool = typer.Option(False, "--default", help="Set as default project"),
77
+ ) -> None:
78
+ """Add a new project.
79
+
80
+ For cloud mode: only name is required
81
+ For local mode: both name and path are required
82
+ """
83
+ config = ConfigManager().config
84
+
85
+ if config.cloud_mode_enabled:
86
+ # Cloud mode: path not needed (auto-generated from name)
87
+ async def _add_project():
88
+ async with get_client() as client:
89
+ data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
90
+ response = await call_post(client, "/projects/projects", json=data)
91
+ return ProjectStatusResponse.model_validate(response.json())
92
+ else:
93
+ # Local mode: path is required
94
+ if path is None:
95
+ console.print("[red]Error: path argument is required in local mode[/red]")
95
96
  raise typer.Exit(1)
96
97
 
97
- # Display usage hint
98
- console.print("\nTo use this project:")
99
- console.print(f" basic-memory --project={name} <command>")
100
- else:
101
-
102
- @project_app.command("add")
103
- def add_project(
104
- name: str = typer.Argument(..., help="Name of the project"),
105
- path: str = typer.Argument(..., help="Path to the project directory"),
106
- set_default: bool = typer.Option(False, "--default", help="Set as default project"),
107
- ) -> None:
108
- """Add a new project."""
109
98
  # Resolve to absolute path
110
99
  resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
111
100
 
112
- try:
113
- data = {"name": name, "path": resolved_path, "set_default": set_default}
101
+ async def _add_project():
102
+ async with get_client() as client:
103
+ data = {"name": name, "path": resolved_path, "set_default": set_default}
104
+ response = await call_post(client, "/projects/projects", json=data)
105
+ return ProjectStatusResponse.model_validate(response.json())
114
106
 
115
- response = asyncio.run(call_post(client, "/projects/projects", json=data))
116
- result = ProjectStatusResponse.model_validate(response.json())
117
-
118
- console.print(f"[green]{result.message}[/green]")
119
- except Exception as e:
120
- console.print(f"[red]Error adding project: {str(e)}[/red]")
121
- raise typer.Exit(1)
107
+ try:
108
+ result = asyncio.run(_add_project())
109
+ console.print(f"[green]{result.message}[/green]")
110
+ except Exception as e:
111
+ console.print(f"[red]Error adding project: {str(e)}[/red]")
112
+ raise typer.Exit(1)
122
113
 
123
- # Display usage hint
124
- console.print("\nTo use this project:")
125
- console.print(f" basic-memory --project={name} <command>")
114
+ # Display usage hint
115
+ console.print("\nTo use this project:")
116
+ console.print(f" basic-memory --project={name} <command>")
126
117
 
127
118
 
128
119
  @project_app.command("remove")
@@ -130,17 +121,15 @@ def remove_project(
130
121
  name: str = typer.Argument(..., help="Name of the project to remove"),
131
122
  ) -> None:
132
123
  """Remove a project."""
133
- try:
134
- auth_headers = {}
135
- if config.cloud_mode_enabled:
136
- auth_headers = asyncio.run(get_authenticated_headers())
137
124
 
138
- project_permalink = generate_permalink(name)
139
- response = asyncio.run(
140
- call_delete(client, f"/projects/{project_permalink}", headers=auth_headers)
141
- )
142
- result = ProjectStatusResponse.model_validate(response.json())
125
+ async def _remove_project():
126
+ async with get_client() as client:
127
+ project_permalink = generate_permalink(name)
128
+ response = await call_delete(client, f"/projects/{project_permalink}")
129
+ return ProjectStatusResponse.model_validate(response.json())
143
130
 
131
+ try:
132
+ result = asyncio.run(_remove_project())
144
133
  console.print(f"[green]{result.message}[/green]")
145
134
  except Exception as e:
146
135
  console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -150,76 +139,107 @@ def remove_project(
150
139
  console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
151
140
 
152
141
 
153
- if not config.cloud_mode_enabled:
142
+ @project_app.command("default")
143
+ def set_default_project(
144
+ name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
145
+ ) -> None:
146
+ """Set the default project when 'config.default_project_mode' is set.
154
147
 
155
- @project_app.command("default")
156
- def set_default_project(
157
- name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
158
- ) -> None:
159
- """Set the default project when 'config.default_project_mode' is set."""
160
- try:
148
+ Note: This command is only available in local mode.
149
+ """
150
+ config = ConfigManager().config
151
+
152
+ if config.cloud_mode_enabled:
153
+ console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
154
+ raise typer.Exit(1)
155
+
156
+ async def _set_default():
157
+ async with get_client() as client:
161
158
  project_permalink = generate_permalink(name)
162
- response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
163
- result = ProjectStatusResponse.model_validate(response.json())
159
+ response = await call_put(client, f"/projects/{project_permalink}/default")
160
+ return ProjectStatusResponse.model_validate(response.json())
164
161
 
165
- console.print(f"[green]{result.message}[/green]")
166
- except Exception as e:
167
- console.print(f"[red]Error setting default project: {str(e)}[/red]")
168
- raise typer.Exit(1)
162
+ try:
163
+ result = asyncio.run(_set_default())
164
+ console.print(f"[green]{result.message}[/green]")
165
+ except Exception as e:
166
+ console.print(f"[red]Error setting default project: {str(e)}[/red]")
167
+ raise typer.Exit(1)
169
168
 
170
- @project_app.command("sync-config")
171
- def synchronize_projects() -> None:
172
- """Synchronize project config between configuration file and database."""
173
- # Call the API to synchronize projects
174
169
 
175
- try:
176
- response = asyncio.run(call_post(client, "/projects/config/sync"))
177
- result = ProjectStatusResponse.model_validate(response.json())
170
+ @project_app.command("sync-config")
171
+ def synchronize_projects() -> None:
172
+ """Synchronize project config between configuration file and database.
178
173
 
179
- console.print(f"[green]{result.message}[/green]")
180
- except Exception as e: # pragma: no cover
181
- console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
182
- raise typer.Exit(1)
174
+ Note: This command is only available in local mode.
175
+ """
176
+ config = ConfigManager().config
183
177
 
184
- @project_app.command("move")
185
- def move_project(
186
- name: str = typer.Argument(..., help="Name of the project to move"),
187
- new_path: str = typer.Argument(..., help="New absolute path for the project"),
188
- ) -> None:
189
- """Move a project to a new location."""
190
- # Resolve to absolute path
191
- resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
178
+ if config.cloud_mode_enabled:
179
+ console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
180
+ raise typer.Exit(1)
192
181
 
193
- try:
194
- data = {"path": resolved_path}
182
+ async def _sync_config():
183
+ async with get_client() as client:
184
+ response = await call_post(client, "/projects/config/sync")
185
+ return ProjectStatusResponse.model_validate(response.json())
195
186
 
187
+ try:
188
+ result = asyncio.run(_sync_config())
189
+ console.print(f"[green]{result.message}[/green]")
190
+ except Exception as e: # pragma: no cover
191
+ console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
192
+ raise typer.Exit(1)
193
+
194
+
195
+ @project_app.command("move")
196
+ def move_project(
197
+ name: str = typer.Argument(..., help="Name of the project to move"),
198
+ new_path: str = typer.Argument(..., help="New absolute path for the project"),
199
+ ) -> None:
200
+ """Move a project to a new location.
201
+
202
+ Note: This command is only available in local mode.
203
+ """
204
+ config = ConfigManager().config
205
+
206
+ if config.cloud_mode_enabled:
207
+ console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
208
+ raise typer.Exit(1)
209
+
210
+ # Resolve to absolute path
211
+ resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
212
+
213
+ async def _move_project():
214
+ async with get_client() as client:
215
+ data = {"path": resolved_path}
196
216
  project_permalink = generate_permalink(name)
197
217
 
198
218
  # TODO fix route to use ProjectPathDep
199
- response = asyncio.run(
200
- call_patch(client, f"/{name}/project/{project_permalink}", json=data)
201
- )
202
- result = ProjectStatusResponse.model_validate(response.json())
219
+ response = await call_patch(client, f"/{name}/project/{project_permalink}", json=data)
220
+ return ProjectStatusResponse.model_validate(response.json())
203
221
 
204
- console.print(f"[green]{result.message}[/green]")
222
+ try:
223
+ result = asyncio.run(_move_project())
224
+ console.print(f"[green]{result.message}[/green]")
205
225
 
206
- # Show important file movement reminder
207
- console.print() # Empty line for spacing
208
- console.print(
209
- Panel(
210
- "[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
211
- "[yellow]You must manually move your project files from the old location to:[/yellow]\n"
212
- f"[cyan]{resolved_path}[/cyan]\n\n"
213
- "[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
214
- title="⚠️ Manual File Movement Required",
215
- border_style="yellow",
216
- expand=False,
217
- )
226
+ # Show important file movement reminder
227
+ console.print() # Empty line for spacing
228
+ console.print(
229
+ Panel(
230
+ "[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
231
+ "[yellow]You must manually move your project files from the old location to:[/yellow]\n"
232
+ f"[cyan]{resolved_path}[/cyan]\n\n"
233
+ "[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
234
+ title="⚠️ Manual File Movement Required",
235
+ border_style="yellow",
236
+ expand=False,
218
237
  )
238
+ )
219
239
 
220
- except Exception as e:
221
- console.print(f"[red]Error moving project: {str(e)}[/red]")
222
- raise typer.Exit(1)
240
+ except Exception as e:
241
+ console.print(f"[red]Error moving project: {str(e)}[/red]")
242
+ raise typer.Exit(1)
223
243
 
224
244
 
225
245
  @project_app.command("info")
@@ -12,8 +12,7 @@ from rich.panel import Panel
12
12
  from rich.tree import Tree
13
13
 
14
14
  from basic_memory.cli.app import app
15
- from basic_memory.cli.commands.cloud import get_authenticated_headers
16
- from basic_memory.mcp.async_client import client
15
+ from basic_memory.mcp.async_client import get_client
17
16
  from basic_memory.mcp.tools.utils import call_post
18
17
  from basic_memory.schemas import SyncReportResponse
19
18
  from basic_memory.mcp.project_context import get_active_project
@@ -130,20 +129,12 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
130
129
  """Check sync status of files vs database."""
131
130
 
132
131
  try:
133
- from basic_memory.config import ConfigManager
132
+ async with get_client() as client:
133
+ project_item = await get_active_project(client, project, None)
134
+ response = await call_post(client, f"{project_item.project_url}/project/status")
135
+ sync_report = SyncReportResponse.model_validate(response.json())
134
136
 
135
- config = ConfigManager().config
136
- auth_headers = {}
137
- if config.cloud_mode_enabled:
138
- auth_headers = await get_authenticated_headers()
139
-
140
- project_item = await get_active_project(client, project, None)
141
- response = await call_post(
142
- client, f"{project_item.project_url}/project/status", headers=auth_headers
143
- )
144
- sync_report = SyncReportResponse.model_validate(response.json())
145
-
146
- display_changes(project_item.name, "Status", sync_report, verbose)
137
+ display_changes(project_item.name, "Status", sync_report, verbose)
147
138
 
148
139
  except (ValueError, ToolError) as e:
149
140
  console.print(f"[red]✗ Error: {e}[/red]")
basic_memory/config.py CHANGED
@@ -103,10 +103,10 @@ class BasicMemoryConfig(BaseSettings):
103
103
  description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
104
104
  )
105
105
 
106
- # API connection configuration
107
- api_url: Optional[str] = Field(
106
+ # Project path constraints
107
+ project_root: Optional[str] = Field(
108
108
  default=None,
109
- description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
109
+ description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
110
110
  )
111
111
 
112
112
  # Cloud configuration
@@ -232,6 +232,10 @@ class BasicMemoryConfig(BaseSettings):
232
232
  return Path.home() / DATA_DIR_NAME
233
233
 
234
234
 
235
+ # Module-level cache for configuration
236
+ _CONFIG_CACHE: Optional[BasicMemoryConfig] = None
237
+
238
+
235
239
  class ConfigManager:
236
240
  """Manages Basic Memory configuration."""
237
241
 
@@ -241,7 +245,12 @@ class ConfigManager:
241
245
  if isinstance(home, str):
242
246
  home = Path(home)
243
247
 
244
- self.config_dir = home / DATA_DIR_NAME
248
+ # Allow override via environment variable
249
+ if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
250
+ self.config_dir = Path(config_dir)
251
+ else:
252
+ self.config_dir = home / DATA_DIR_NAME
253
+
245
254
  self.config_file = self.config_dir / CONFIG_FILE_NAME
246
255
 
247
256
  # Ensure config directory exists
@@ -253,12 +262,45 @@ class ConfigManager:
253
262
  return self.load_config()
254
263
 
255
264
  def load_config(self) -> BasicMemoryConfig:
256
- """Load configuration from file or create default."""
265
+ """Load configuration from file or create default.
266
+
267
+ Environment variables take precedence over file config values,
268
+ following Pydantic Settings best practices.
269
+
270
+ Uses module-level cache for performance across ConfigManager instances.
271
+ """
272
+ global _CONFIG_CACHE
273
+
274
+ # Return cached config if available
275
+ if _CONFIG_CACHE is not None:
276
+ return _CONFIG_CACHE
257
277
 
258
278
  if self.config_file.exists():
259
279
  try:
260
- data = json.loads(self.config_file.read_text(encoding="utf-8"))
261
- return BasicMemoryConfig(**data)
280
+ file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
281
+
282
+ # First, create config from environment variables (Pydantic will read them)
283
+ # Then overlay with file data for fields that aren't set via env vars
284
+ # This ensures env vars take precedence
285
+
286
+ # Get env-based config fields that are actually set
287
+ env_config = BasicMemoryConfig()
288
+ env_dict = env_config.model_dump()
289
+
290
+ # Merge: file data as base, but only use it for fields not set by env
291
+ # We detect env-set fields by comparing to default values
292
+ merged_data = file_data.copy()
293
+
294
+ # For fields that have env var overrides, use those instead of file values
295
+ # The env_prefix is "BASIC_MEMORY_" so we check those
296
+ for field_name in BasicMemoryConfig.model_fields.keys():
297
+ env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
298
+ if env_var_name in os.environ:
299
+ # Environment variable is set, use it
300
+ merged_data[field_name] = env_dict[field_name]
301
+
302
+ _CONFIG_CACHE = BasicMemoryConfig(**merged_data)
303
+ return _CONFIG_CACHE
262
304
  except Exception as e: # pragma: no cover
263
305
  logger.exception(f"Failed to load config: {e}")
264
306
  raise e
@@ -268,8 +310,11 @@ class ConfigManager:
268
310
  return config
269
311
 
270
312
  def save_config(self, config: BasicMemoryConfig) -> None:
271
- """Save configuration to file."""
313
+ """Save configuration to file and invalidate cache."""
314
+ global _CONFIG_CACHE
272
315
  save_basic_memory_config(self.config_file, config)
316
+ # Invalidate cache so next load_config() reads fresh data
317
+ _CONFIG_CACHE = None
273
318
 
274
319
  @property
275
320
  def projects(self) -> Dict[str, str]:
@@ -309,7 +354,8 @@ class ConfigManager:
309
354
  if project_name == config.default_project: # pragma: no cover
310
355
  raise ValueError(f"Cannot remove the default project '{name}'")
311
356
 
312
- del config.projects[name]
357
+ # Use the found project_name (which may differ from input name due to permalink matching)
358
+ del config.projects[project_name]
313
359
  self.save_config(config)
314
360
 
315
361
  def set_default_project(self, name: str) -> None: