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.
- basic_memory/__init__.py +1 -1
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/project_router.py +1 -0
- basic_memory/cli/auth.py +2 -2
- basic_memory/cli/commands/cloud/__init__.py +2 -1
- basic_memory/cli/commands/cloud/bisync_commands.py +4 -57
- basic_memory/cli/commands/cloud/cloud_utils.py +100 -0
- basic_memory/cli/commands/cloud/upload.py +128 -0
- basic_memory/cli/commands/cloud/upload_command.py +93 -0
- basic_memory/cli/commands/command_utils.py +11 -28
- basic_memory/cli/commands/mcp.py +72 -67
- basic_memory/cli/commands/project.py +140 -120
- basic_memory/cli/commands/status.py +6 -15
- basic_memory/config.py +55 -9
- basic_memory/deps.py +7 -5
- basic_memory/ignore_utils.py +7 -7
- basic_memory/mcp/async_client.py +102 -4
- basic_memory/mcp/prompts/continue_conversation.py +16 -15
- basic_memory/mcp/prompts/search.py +12 -11
- basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
- basic_memory/mcp/resources/project_info.py +9 -7
- basic_memory/mcp/tools/build_context.py +40 -39
- basic_memory/mcp/tools/canvas.py +21 -20
- basic_memory/mcp/tools/chatgpt_tools.py +11 -2
- basic_memory/mcp/tools/delete_note.py +22 -21
- basic_memory/mcp/tools/edit_note.py +105 -104
- basic_memory/mcp/tools/list_directory.py +98 -95
- basic_memory/mcp/tools/move_note.py +127 -125
- basic_memory/mcp/tools/project_management.py +101 -98
- basic_memory/mcp/tools/read_content.py +64 -63
- basic_memory/mcp/tools/read_note.py +88 -88
- basic_memory/mcp/tools/recent_activity.py +139 -135
- basic_memory/mcp/tools/search.py +27 -26
- basic_memory/mcp/tools/sync_status.py +133 -128
- basic_memory/mcp/tools/utils.py +0 -15
- basic_memory/mcp/tools/view_note.py +14 -28
- basic_memory/mcp/tools/write_note.py +97 -87
- basic_memory/repository/entity_repository.py +60 -0
- basic_memory/repository/repository.py +16 -3
- basic_memory/repository/search_repository.py +42 -0
- basic_memory/schemas/cloud.py +7 -3
- basic_memory/schemas/project_info.py +1 -1
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +31 -9
- basic_memory/services/project_service.py +97 -10
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +28 -13
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/METADATA +51 -4
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/RECORD +52 -50
- basic_memory/mcp/tools/headers.py +0 -44
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/licenses/LICENSE +0 -0
basic_memory/cli/commands/mcp.py
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
logger.info("
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 =
|
|
163
|
-
|
|
159
|
+
response = await call_put(client, f"/projects/{project_permalink}/default")
|
|
160
|
+
return ProjectStatusResponse.model_validate(response.json())
|
|
164
161
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
@project_app.command("sync-config")
|
|
171
|
+
def synchronize_projects() -> None:
|
|
172
|
+
"""Synchronize project config between configuration file and database.
|
|
178
173
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
raise typer.Exit(1)
|
|
174
|
+
Note: This command is only available in local mode.
|
|
175
|
+
"""
|
|
176
|
+
config = ConfigManager().config
|
|
183
177
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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 =
|
|
200
|
-
|
|
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
|
-
|
|
222
|
+
try:
|
|
223
|
+
result = asyncio.run(_move_project())
|
|
224
|
+
console.print(f"[green]{result.message}[/green]")
|
|
205
225
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
107
|
-
|
|
106
|
+
# Project path constraints
|
|
107
|
+
project_root: Optional[str] = Field(
|
|
108
108
|
default=None,
|
|
109
|
-
description="
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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:
|