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.
- 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/command_utils.py +11 -28
- basic_memory/cli/commands/mcp.py +72 -67
- basic_memory/cli/commands/project.py +54 -49
- 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/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.1.dist-info}/METADATA +51 -4
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
- basic_memory/mcp/tools/headers.py +0 -44
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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("/
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
"""
|
|
34
|
+
"""Get project information via API endpoint."""
|
|
44
35
|
|
|
45
36
|
try:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
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
|
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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:
|
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
|
-
|
|
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
|
-
|
|
152
|
-
project_obj = await project_repository.get_by_permalink(
|
|
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
|
|