basic-memory 0.15.0__py3-none-any.whl → 0.15.1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (47) 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/command_utils.py +11 -28
  6. basic_memory/cli/commands/mcp.py +72 -67
  7. basic_memory/cli/commands/project.py +54 -49
  8. basic_memory/cli/commands/status.py +6 -15
  9. basic_memory/config.py +55 -9
  10. basic_memory/deps.py +7 -5
  11. basic_memory/ignore_utils.py +7 -7
  12. basic_memory/mcp/async_client.py +102 -4
  13. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  14. basic_memory/mcp/prompts/search.py +12 -11
  15. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  16. basic_memory/mcp/resources/project_info.py +9 -7
  17. basic_memory/mcp/tools/build_context.py +40 -39
  18. basic_memory/mcp/tools/canvas.py +21 -20
  19. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  20. basic_memory/mcp/tools/delete_note.py +22 -21
  21. basic_memory/mcp/tools/edit_note.py +105 -104
  22. basic_memory/mcp/tools/list_directory.py +98 -95
  23. basic_memory/mcp/tools/move_note.py +127 -125
  24. basic_memory/mcp/tools/project_management.py +101 -98
  25. basic_memory/mcp/tools/read_content.py +64 -63
  26. basic_memory/mcp/tools/read_note.py +88 -88
  27. basic_memory/mcp/tools/recent_activity.py +139 -135
  28. basic_memory/mcp/tools/search.py +27 -26
  29. basic_memory/mcp/tools/sync_status.py +133 -128
  30. basic_memory/mcp/tools/utils.py +0 -15
  31. basic_memory/mcp/tools/view_note.py +14 -28
  32. basic_memory/mcp/tools/write_note.py +97 -87
  33. basic_memory/repository/entity_repository.py +60 -0
  34. basic_memory/repository/repository.py +16 -3
  35. basic_memory/repository/search_repository.py +42 -0
  36. basic_memory/schemas/project_info.py +1 -1
  37. basic_memory/services/directory_service.py +124 -3
  38. basic_memory/services/entity_service.py +31 -9
  39. basic_memory/services/project_service.py +97 -10
  40. basic_memory/services/search_service.py +16 -8
  41. basic_memory/sync/sync_service.py +28 -13
  42. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
  43. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
  44. basic_memory/mcp/tools/headers.py +0 -44
  45. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  46. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  47. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.15.0"
4
+ __version__ = "0.15.1"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -10,7 +10,7 @@ from basic_memory.schemas.directory import DirectoryNode
10
10
  router = APIRouter(prefix="/directory", tags=["directory"])
11
11
 
12
12
 
13
- @router.get("/tree", response_model=DirectoryNode)
13
+ @router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
14
14
  async def get_directory_tree(
15
15
  directory_service: DirectoryServiceDep,
16
16
  project_id: ProjectIdDep,
@@ -31,7 +31,28 @@ async def get_directory_tree(
31
31
  return tree
32
32
 
33
33
 
34
- @router.get("/list", response_model=List[DirectoryNode])
34
+ @router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
35
+ async def get_directory_structure(
36
+ directory_service: DirectoryServiceDep,
37
+ project_id: ProjectIdDep,
38
+ ):
39
+ """Get folder structure for navigation (no files).
40
+
41
+ Optimized endpoint for folder tree navigation. Returns only directory nodes
42
+ without file metadata. For full tree with files, use /directory/tree.
43
+
44
+ Args:
45
+ directory_service: Service for directory operations
46
+ project_id: ID of the current project
47
+
48
+ Returns:
49
+ DirectoryNode tree containing only folders (type="directory")
50
+ """
51
+ structure = await directory_service.get_directory_structure()
52
+ return structure
53
+
54
+
55
+ @router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
35
56
  async def list_directory(
36
57
  directory_service: DirectoryServiceDep,
37
58
  project_id: ProjectIdDep,
@@ -194,6 +194,7 @@ async def add_project(
194
194
  Response confirming the project was added
195
195
  """
196
196
  try: # pragma: no cover
197
+ # The service layer now handles cloud mode validation and path sanitization
197
198
  await project_service.add_project(
198
199
  project_data.name, project_data.path, set_default=project_data.set_default
199
200
  )
basic_memory/cli/auth.py CHANGED
@@ -244,7 +244,7 @@ class CLIAuth:
244
244
 
245
245
  async def login(self) -> bool:
246
246
  """Perform OAuth Device Authorization login flow."""
247
- console.print("[blue]Initiating WorkOS authentication...[/blue]")
247
+ console.print("[blue]Initiating authentication...[/blue]")
248
248
 
249
249
  # Step 1: Request device authorization
250
250
  device_response = await self.request_device_authorization()
@@ -265,7 +265,7 @@ class CLIAuth:
265
265
  # Step 4: Save tokens
266
266
  self.save_tokens(tokens)
267
267
 
268
- console.print("\n[green]✅ Successfully authenticated with WorkOS![/green]")
268
+ console.print("\n[green]✅ Successfully authenticated with Basic Memory Cloud![/green]")
269
269
  return True
270
270
 
271
271
  def logout(self) -> None:
@@ -7,8 +7,7 @@ import typer
7
7
 
8
8
  from rich.console import Console
9
9
 
10
- from basic_memory.cli.commands.cloud import get_authenticated_headers
11
- from basic_memory.mcp.async_client import client
10
+ from basic_memory.mcp.async_client import get_client
12
11
 
13
12
  from basic_memory.mcp.tools.utils import call_post, call_get
14
13
  from basic_memory.mcp.project_context import get_active_project
@@ -21,40 +20,24 @@ async def run_sync(project: Optional[str] = None):
21
20
  """Run sync operation via API endpoint."""
22
21
 
23
22
  try:
24
- from basic_memory.config import ConfigManager
25
-
26
- config = ConfigManager().config
27
- auth_headers = {}
28
- if config.cloud_mode_enabled:
29
- auth_headers = await get_authenticated_headers()
30
-
31
- project_item = await get_active_project(client, project, None, headers=auth_headers)
32
- response = await call_post(
33
- client, f"{project_item.project_url}/project/sync", headers=auth_headers
34
- )
35
- data = response.json()
36
- console.print(f"[green]✓ {data['message']}[/green]")
23
+ async with get_client() as client:
24
+ project_item = await get_active_project(client, project, None)
25
+ response = await call_post(client, f"{project_item.project_url}/project/sync")
26
+ data = response.json()
27
+ console.print(f"[green]✓ {data['message']}[/green]")
37
28
  except (ToolError, ValueError) as e:
38
29
  console.print(f"[red]✗ Sync failed: {e}[/red]")
39
30
  raise typer.Exit(1)
40
31
 
41
32
 
42
33
  async def get_project_info(project: str):
43
- """Run sync operation via API endpoint."""
34
+ """Get project information via API endpoint."""
44
35
 
45
36
  try:
46
- from basic_memory.config import ConfigManager
47
-
48
- config = ConfigManager().config
49
- auth_headers = {}
50
- if config.cloud_mode_enabled:
51
- auth_headers = await get_authenticated_headers()
52
-
53
- project_item = await get_active_project(client, project, None, headers=auth_headers)
54
- response = await call_get(
55
- client, f"{project_item.project_url}/project/info", headers=auth_headers
56
- )
57
- return ProjectInfoResponse.model_validate(response.json())
37
+ async with get_client() as client:
38
+ project_item = await get_active_project(client, project, None)
39
+ response = await call_get(client, f"{project_item.project_url}/project/info")
40
+ return ProjectInfoResponse.model_validate(response.json())
58
41
  except (ToolError, ValueError) as e:
59
42
  console.print(f"[red]✗ Sync failed: {e}[/red]")
60
43
  raise typer.Exit(1)
@@ -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
@@ -46,14 +45,14 @@ def format_path(path: str) -> str:
46
45
  @project_app.command("list")
47
46
  def list_projects() -> None:
48
47
  """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
48
 
55
- response = asyncio.run(call_get(client, "/projects/projects", headers=auth_headers))
56
- result = ProjectList.model_validate(response.json())
49
+ async def _list_projects():
50
+ async with get_client() as client:
51
+ response = await call_get(client, "/projects/projects")
52
+ return ProjectList.model_validate(response.json())
53
+
54
+ try:
55
+ result = asyncio.run(_list_projects())
57
56
 
58
57
  table = Table(title="Basic Memory Projects")
59
58
  table.add_column("Name", style="cyan")
@@ -79,16 +78,14 @@ if config.cloud_mode_enabled:
79
78
  ) -> None:
80
79
  """Add a new project to Basic Memory Cloud"""
81
80
 
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())
81
+ async def _add_project():
82
+ async with get_client() as client:
83
+ data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
84
+ response = await call_post(client, "/projects/projects", json=data)
85
+ return ProjectStatusResponse.model_validate(response.json())
91
86
 
87
+ try:
88
+ result = asyncio.run(_add_project())
92
89
  console.print(f"[green]{result.message}[/green]")
93
90
  except Exception as e:
94
91
  console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -109,12 +106,14 @@ else:
109
106
  # Resolve to absolute path
110
107
  resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
111
108
 
112
- try:
113
- data = {"name": name, "path": resolved_path, "set_default": set_default}
114
-
115
- response = asyncio.run(call_post(client, "/projects/projects", json=data))
116
- result = ProjectStatusResponse.model_validate(response.json())
109
+ async def _add_project():
110
+ async with get_client() as client:
111
+ data = {"name": name, "path": resolved_path, "set_default": set_default}
112
+ response = await call_post(client, "/projects/projects", json=data)
113
+ return ProjectStatusResponse.model_validate(response.json())
117
114
 
115
+ try:
116
+ result = asyncio.run(_add_project())
118
117
  console.print(f"[green]{result.message}[/green]")
119
118
  except Exception as e:
120
119
  console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -130,17 +129,15 @@ def remove_project(
130
129
  name: str = typer.Argument(..., help="Name of the project to remove"),
131
130
  ) -> None:
132
131
  """Remove a project."""
133
- try:
134
- auth_headers = {}
135
- if config.cloud_mode_enabled:
136
- auth_headers = asyncio.run(get_authenticated_headers())
137
132
 
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())
133
+ async def _remove_project():
134
+ async with get_client() as client:
135
+ project_permalink = generate_permalink(name)
136
+ response = await call_delete(client, f"/projects/{project_permalink}")
137
+ return ProjectStatusResponse.model_validate(response.json())
143
138
 
139
+ try:
140
+ result = asyncio.run(_remove_project())
144
141
  console.print(f"[green]{result.message}[/green]")
145
142
  except Exception as e:
146
143
  console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -157,11 +154,15 @@ if not config.cloud_mode_enabled:
157
154
  name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
158
155
  ) -> None:
159
156
  """Set the default project when 'config.default_project_mode' is set."""
160
- try:
161
- project_permalink = generate_permalink(name)
162
- response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
163
- result = ProjectStatusResponse.model_validate(response.json())
164
157
 
158
+ async def _set_default():
159
+ async with get_client() as client:
160
+ project_permalink = generate_permalink(name)
161
+ response = await call_put(client, f"/projects/{project_permalink}/default")
162
+ return ProjectStatusResponse.model_validate(response.json())
163
+
164
+ try:
165
+ result = asyncio.run(_set_default())
165
166
  console.print(f"[green]{result.message}[/green]")
166
167
  except Exception as e:
167
168
  console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -170,12 +171,14 @@ if not config.cloud_mode_enabled:
170
171
  @project_app.command("sync-config")
171
172
  def synchronize_projects() -> None:
172
173
  """Synchronize project config between configuration file and database."""
173
- # Call the API to synchronize projects
174
174
 
175
- try:
176
- response = asyncio.run(call_post(client, "/projects/config/sync"))
177
- result = ProjectStatusResponse.model_validate(response.json())
175
+ async def _sync_config():
176
+ async with get_client() as client:
177
+ response = await call_post(client, "/projects/config/sync")
178
+ return ProjectStatusResponse.model_validate(response.json())
178
179
 
180
+ try:
181
+ result = asyncio.run(_sync_config())
179
182
  console.print(f"[green]{result.message}[/green]")
180
183
  except Exception as e: # pragma: no cover
181
184
  console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -190,17 +193,19 @@ if not config.cloud_mode_enabled:
190
193
  # Resolve to absolute path
191
194
  resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
192
195
 
193
- try:
194
- data = {"path": resolved_path}
195
-
196
- project_permalink = generate_permalink(name)
196
+ async def _move_project():
197
+ async with get_client() as client:
198
+ data = {"path": resolved_path}
199
+ project_permalink = generate_permalink(name)
197
200
 
198
- # 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())
201
+ # TODO fix route to use ProjectPathDep
202
+ response = await call_patch(
203
+ client, f"/{name}/project/{project_permalink}", json=data
204
+ )
205
+ return ProjectStatusResponse.model_validate(response.json())
203
206
 
207
+ try:
208
+ result = asyncio.run(_move_project())
204
209
  console.print(f"[green]{result.message}[/green]")
205
210
 
206
211
  # Show important file movement reminder
@@ -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:
basic_memory/deps.py CHANGED
@@ -33,6 +33,7 @@ from basic_memory.services.file_service import FileService
33
33
  from basic_memory.services.link_resolver import LinkResolver
34
34
  from basic_memory.services.search_service import SearchService
35
35
  from basic_memory.sync import SyncService
36
+ from basic_memory.utils import generate_permalink
36
37
 
37
38
 
38
39
  def get_app_config() -> BasicMemoryConfig: # pragma: no cover
@@ -61,8 +62,9 @@ async def get_project_config(
61
62
  Raises:
62
63
  HTTPException: If project is not found
63
64
  """
64
-
65
- project_obj = await project_repository.get_by_permalink(str(project))
65
+ # Convert project name to permalink for lookup
66
+ project_permalink = generate_permalink(str(project))
67
+ project_obj = await project_repository.get_by_permalink(project_permalink)
66
68
  if project_obj:
67
69
  return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
68
70
 
@@ -147,9 +149,9 @@ async def get_project_id(
147
149
  Raises:
148
150
  HTTPException: If project is not found
149
151
  """
150
-
151
- # Try by permalink first (most common case with URL paths)
152
- project_obj = await project_repository.get_by_permalink(str(project))
152
+ # Convert project name to permalink for lookup
153
+ project_permalink = generate_permalink(str(project))
154
+ project_obj = await project_repository.get_by_permalink(project_permalink)
153
155
  if project_obj:
154
156
  return project_obj.id
155
157