basic-memory 0.14.2__py3-none-any.whl → 0.14.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +3 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/api/routers/resource_router.py +3 -3
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +46 -6
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +61 -34
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/file_utils.py +65 -0
- basic_memory/importers/chatgpt_importer.py +20 -10
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +42 -26
- basic_memory/markdown/utils.py +1 -1
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/build_context.py +12 -2
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/project_management.py +22 -7
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +17 -2
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/knowledge.py +13 -2
- basic_memory/models/project.py +3 -3
- basic_memory/repository/entity_repository.py +2 -2
- basic_memory/repository/project_repository.py +19 -1
- basic_memory/repository/search_repository.py +7 -3
- basic_memory/schemas/base.py +40 -10
- basic_memory/schemas/importer.py +1 -0
- basic_memory/schemas/memory.py +23 -11
- basic_memory/services/context_service.py +12 -2
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +56 -10
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +93 -36
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +53 -4
- basic_memory/sync/watch_service.py +31 -8
- basic_memory/utils.py +234 -71
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/mcp/async_client.py
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
from httpx import ASGITransport, AsyncClient
|
|
2
|
+
from loguru import logger
|
|
2
3
|
|
|
3
4
|
from basic_memory.api.app import app as fastapi_app
|
|
5
|
+
from basic_memory.config import ConfigManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_client() -> AsyncClient:
|
|
9
|
+
"""Create an HTTP client based on configuration.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
AsyncClient configured for either local ASGI or remote HTTP transport
|
|
13
|
+
"""
|
|
14
|
+
config_manager = ConfigManager()
|
|
15
|
+
config = config_manager.load_config()
|
|
16
|
+
|
|
17
|
+
if config.api_url:
|
|
18
|
+
# Use HTTP transport for remote API
|
|
19
|
+
logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
|
|
20
|
+
return AsyncClient(base_url=config.api_url)
|
|
21
|
+
else:
|
|
22
|
+
# Use ASGI transport for local API
|
|
23
|
+
logger.debug("Creating ASGI client for local Basic Memory API")
|
|
24
|
+
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
|
|
4
25
|
|
|
5
|
-
BASE_URL = "http://test"
|
|
6
26
|
|
|
7
27
|
# Create shared async client
|
|
8
|
-
client =
|
|
28
|
+
client = create_client()
|
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from typing import Optional
|
|
9
9
|
from loguru import logger
|
|
10
10
|
|
|
11
|
-
from basic_memory.config import ProjectConfig, get_project_config,
|
|
11
|
+
from basic_memory.config import ProjectConfig, get_project_config, ConfigManager
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@dataclass
|
|
@@ -23,7 +23,7 @@ class ProjectSession:
|
|
|
23
23
|
current_project: Optional[str] = None
|
|
24
24
|
default_project: Optional[str] = None
|
|
25
25
|
|
|
26
|
-
def initialize(self, default_project: str) ->
|
|
26
|
+
def initialize(self, default_project: str) -> "ProjectSession":
|
|
27
27
|
"""Set the default project from config on startup.
|
|
28
28
|
|
|
29
29
|
Args:
|
|
@@ -32,6 +32,7 @@ class ProjectSession:
|
|
|
32
32
|
self.default_project = default_project
|
|
33
33
|
self.current_project = default_project
|
|
34
34
|
logger.info(f"Initialized project session with default project: {default_project}")
|
|
35
|
+
return self
|
|
35
36
|
|
|
36
37
|
def get_current_project(self) -> str:
|
|
37
38
|
"""Get the currently active project name.
|
|
@@ -72,7 +73,7 @@ class ProjectSession:
|
|
|
72
73
|
via CLI or API to ensure MCP session stays in sync.
|
|
73
74
|
"""
|
|
74
75
|
# Reload config to get latest default project
|
|
75
|
-
current_config =
|
|
76
|
+
current_config = ConfigManager().config
|
|
76
77
|
new_default = current_config.default_project
|
|
77
78
|
|
|
78
79
|
# Reinitialize with new default
|
|
@@ -102,7 +103,8 @@ def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
|
|
|
102
103
|
return project
|
|
103
104
|
|
|
104
105
|
current_project = session.get_current_project()
|
|
105
|
-
|
|
106
|
+
active_project = get_project_config(current_project)
|
|
107
|
+
return active_project
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
def add_project_metadata(result: str, project_name: str) -> str:
|
|
@@ -10,12 +10,10 @@ from basic_memory.mcp.prompts import continue_conversation
|
|
|
10
10
|
from basic_memory.mcp.prompts import recent_activity
|
|
11
11
|
from basic_memory.mcp.prompts import search
|
|
12
12
|
from basic_memory.mcp.prompts import ai_assistant_guide
|
|
13
|
-
from basic_memory.mcp.prompts import sync_status
|
|
14
13
|
|
|
15
14
|
__all__ = [
|
|
16
15
|
"ai_assistant_guide",
|
|
17
16
|
"continue_conversation",
|
|
18
17
|
"recent_activity",
|
|
19
18
|
"search",
|
|
20
|
-
"sync_status",
|
|
21
19
|
]
|
basic_memory/mcp/server.py
CHANGED
|
@@ -7,25 +7,10 @@ from contextlib import asynccontextmanager
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from typing import AsyncIterator, Optional, Any
|
|
9
9
|
|
|
10
|
-
from dotenv import load_dotenv
|
|
11
10
|
from fastmcp import FastMCP
|
|
12
|
-
from fastmcp.utilities.logging import configure_logging as mcp_configure_logging
|
|
13
|
-
from mcp.server.auth.settings import AuthSettings
|
|
14
11
|
|
|
15
|
-
from basic_memory.config import
|
|
12
|
+
from basic_memory.config import ConfigManager
|
|
16
13
|
from basic_memory.services.initialization import initialize_app
|
|
17
|
-
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
|
18
|
-
from basic_memory.mcp.project_session import session
|
|
19
|
-
from basic_memory.mcp.external_auth_provider import (
|
|
20
|
-
create_github_provider,
|
|
21
|
-
create_google_provider,
|
|
22
|
-
)
|
|
23
|
-
from basic_memory.mcp.supabase_auth_provider import SupabaseOAuthProvider
|
|
24
|
-
|
|
25
|
-
# mcp console logging
|
|
26
|
-
mcp_configure_logging(level="ERROR")
|
|
27
|
-
|
|
28
|
-
load_dotenv()
|
|
29
14
|
|
|
30
15
|
|
|
31
16
|
@dataclass
|
|
@@ -36,7 +21,11 @@ class AppContext:
|
|
|
36
21
|
|
|
37
22
|
@asynccontextmanager
|
|
38
23
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
|
|
39
|
-
"""
|
|
24
|
+
""" """
|
|
25
|
+
# defer import so tests can monkeypatch
|
|
26
|
+
from basic_memory.mcp.project_session import session
|
|
27
|
+
|
|
28
|
+
app_config = ConfigManager().config
|
|
40
29
|
# Initialize on startup (now returns migration_manager)
|
|
41
30
|
migration_manager = await initialize_app(app_config)
|
|
42
31
|
|
|
@@ -50,60 +39,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
|
|
|
50
39
|
pass
|
|
51
40
|
|
|
52
41
|
|
|
53
|
-
#
|
|
54
|
-
def create_auth_config() -> tuple[AuthSettings | None, Any | None]:
|
|
55
|
-
"""Create OAuth configuration if enabled."""
|
|
56
|
-
# Check if OAuth is enabled via environment variable
|
|
57
|
-
import os
|
|
58
|
-
|
|
59
|
-
if os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true":
|
|
60
|
-
from pydantic import AnyHttpUrl
|
|
61
|
-
|
|
62
|
-
# Configure OAuth settings
|
|
63
|
-
issuer_url = os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000")
|
|
64
|
-
required_scopes = os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES", "read,write")
|
|
65
|
-
docs_url = os.getenv("FASTMCP_AUTH_DOCS_URL") or "http://localhost:8000/docs/oauth"
|
|
66
|
-
|
|
67
|
-
auth_settings = AuthSettings(
|
|
68
|
-
issuer_url=AnyHttpUrl(issuer_url),
|
|
69
|
-
service_documentation_url=AnyHttpUrl(docs_url),
|
|
70
|
-
required_scopes=required_scopes.split(",") if required_scopes else ["read", "write"],
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# Create OAuth provider based on type
|
|
74
|
-
provider_type = os.getenv("FASTMCP_AUTH_PROVIDER", "basic").lower()
|
|
75
|
-
|
|
76
|
-
if provider_type == "github":
|
|
77
|
-
auth_provider = create_github_provider()
|
|
78
|
-
elif provider_type == "google":
|
|
79
|
-
auth_provider = create_google_provider()
|
|
80
|
-
elif provider_type == "supabase":
|
|
81
|
-
supabase_url = os.getenv("SUPABASE_URL")
|
|
82
|
-
supabase_anon_key = os.getenv("SUPABASE_ANON_KEY")
|
|
83
|
-
supabase_service_key = os.getenv("SUPABASE_SERVICE_KEY")
|
|
84
|
-
|
|
85
|
-
if not supabase_url or not supabase_anon_key:
|
|
86
|
-
raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for Supabase auth")
|
|
87
|
-
|
|
88
|
-
auth_provider = SupabaseOAuthProvider(
|
|
89
|
-
supabase_url=supabase_url,
|
|
90
|
-
supabase_anon_key=supabase_anon_key,
|
|
91
|
-
supabase_service_key=supabase_service_key,
|
|
92
|
-
issuer_url=issuer_url,
|
|
93
|
-
)
|
|
94
|
-
else: # default to "basic"
|
|
95
|
-
auth_provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
|
96
|
-
|
|
97
|
-
return auth_settings, auth_provider
|
|
98
|
-
|
|
99
|
-
return None, None
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Create auth configuration
|
|
103
|
-
auth_settings, auth_provider = create_auth_config()
|
|
104
|
-
|
|
105
|
-
# Create the shared server instance
|
|
42
|
+
# Create the shared server instance with custom Stytch auth
|
|
106
43
|
mcp = FastMCP(
|
|
107
44
|
name="Basic Memory",
|
|
108
|
-
|
|
45
|
+
lifespan=app_lifespan,
|
|
109
46
|
)
|
|
@@ -15,6 +15,7 @@ from basic_memory.schemas.memory import (
|
|
|
15
15
|
memory_url_path,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
type StringOrInt = str | int
|
|
18
19
|
|
|
19
20
|
@mcp.tool(
|
|
20
21
|
description="""Build context from a memory:// URI to continue conversations naturally.
|
|
@@ -35,7 +36,7 @@ from basic_memory.schemas.memory import (
|
|
|
35
36
|
)
|
|
36
37
|
async def build_context(
|
|
37
38
|
url: MemoryUrl,
|
|
38
|
-
depth: Optional[
|
|
39
|
+
depth: Optional[StringOrInt] = 1,
|
|
39
40
|
timeframe: Optional[TimeFrame] = "7d",
|
|
40
41
|
page: int = 1,
|
|
41
42
|
page_size: int = 10,
|
|
@@ -80,6 +81,15 @@ async def build_context(
|
|
|
80
81
|
build_context("memory://specs/search", project="work-project")
|
|
81
82
|
"""
|
|
82
83
|
logger.info(f"Building context from {url}")
|
|
84
|
+
|
|
85
|
+
# Convert string depth to integer if needed
|
|
86
|
+
if isinstance(depth, str):
|
|
87
|
+
try:
|
|
88
|
+
depth = int(depth)
|
|
89
|
+
except ValueError:
|
|
90
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
91
|
+
raise ToolError(f"Invalid depth parameter: '{depth}' is not a valid integer")
|
|
92
|
+
|
|
83
93
|
# URL is already validated and normalized by MemoryUrl type annotation
|
|
84
94
|
|
|
85
95
|
# Get the active project first to check project-specific sync status
|
|
@@ -101,7 +111,7 @@ async def build_context(
|
|
|
101
111
|
metadata=MemoryMetadata(
|
|
102
112
|
depth=depth or 1,
|
|
103
113
|
timeframe=timeframe,
|
|
104
|
-
generated_at=datetime.now(),
|
|
114
|
+
generated_at=datetime.now().astimezone(),
|
|
105
115
|
primary_count=0,
|
|
106
116
|
related_count=0,
|
|
107
117
|
uri=migration_status, # Include status in metadata
|
|
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.utils import call_post, call_get
|
|
|
11
11
|
from basic_memory.mcp.project_session import get_active_project
|
|
12
12
|
from basic_memory.schemas import EntityResponse
|
|
13
13
|
from basic_memory.schemas.project_info import ProjectList
|
|
14
|
+
from basic_memory.utils import validate_project_path
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
async def _detect_cross_project_move_attempt(
|
|
@@ -47,18 +48,7 @@ async def _detect_cross_project_move_attempt(
|
|
|
47
48
|
identifier, destination_path, current_project, matching_project
|
|
48
49
|
)
|
|
49
50
|
|
|
50
|
-
#
|
|
51
|
-
# (e.g., contains common project-like patterns)
|
|
52
|
-
if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]):
|
|
53
|
-
# This might be a cross-project attempt, but we can't be sure
|
|
54
|
-
# Return a general guidance message
|
|
55
|
-
available_projects = [
|
|
56
|
-
p.name for p in project_list.projects if p.name != current_project
|
|
57
|
-
]
|
|
58
|
-
if available_projects:
|
|
59
|
-
return _format_potential_cross_project_guidance(
|
|
60
|
-
identifier, destination_path, current_project, available_projects
|
|
61
|
-
)
|
|
51
|
+
# No other cross-project patterns detected
|
|
62
52
|
|
|
63
53
|
except Exception as e:
|
|
64
54
|
# If we can't detect, don't interfere with normal error handling
|
|
@@ -404,6 +394,28 @@ async def move_note(
|
|
|
404
394
|
active_project = get_active_project(project)
|
|
405
395
|
project_url = active_project.project_url
|
|
406
396
|
|
|
397
|
+
# Validate destination path to prevent path traversal attacks
|
|
398
|
+
project_path = active_project.home
|
|
399
|
+
if not validate_project_path(destination_path, project_path):
|
|
400
|
+
logger.warning(
|
|
401
|
+
"Attempted path traversal attack blocked",
|
|
402
|
+
destination_path=destination_path,
|
|
403
|
+
project=active_project.name,
|
|
404
|
+
)
|
|
405
|
+
return f"""# Move Failed - Security Validation Error
|
|
406
|
+
|
|
407
|
+
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
|
408
|
+
|
|
409
|
+
## Valid path examples:
|
|
410
|
+
- `notes/my-file.md`
|
|
411
|
+
- `projects/2025/meeting-notes.md`
|
|
412
|
+
- `archive/old-notes.md`
|
|
413
|
+
|
|
414
|
+
## Try again with a safe path:
|
|
415
|
+
```
|
|
416
|
+
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
|
417
|
+
```"""
|
|
418
|
+
|
|
407
419
|
# Check for potential cross-project move attempts
|
|
408
420
|
cross_project_error = await _detect_cross_project_move_attempt(
|
|
409
421
|
identifier, destination_path, active_project.name
|
|
@@ -221,8 +221,10 @@ async def set_default_project(project_name: str, ctx: Context | None = None) ->
|
|
|
221
221
|
if ctx: # pragma: no cover
|
|
222
222
|
await ctx.info(f"Setting default project to: {project_name}")
|
|
223
223
|
|
|
224
|
-
# Call API to set default project
|
|
225
|
-
|
|
224
|
+
# Call API to set default project using URL encoding for special characters
|
|
225
|
+
from urllib.parse import quote
|
|
226
|
+
encoded_name = quote(project_name, safe='')
|
|
227
|
+
response = await call_put(client, f"/projects/{encoded_name}/default")
|
|
226
228
|
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
227
229
|
|
|
228
230
|
result = f"✓ {status_response.message}\n\n"
|
|
@@ -323,16 +325,29 @@ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
|
|
|
323
325
|
response = await call_get(client, "/projects/projects")
|
|
324
326
|
project_list = ProjectList.model_validate(response.json())
|
|
325
327
|
|
|
326
|
-
#
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
|
|
329
|
+
project_permalink = generate_permalink(project_name)
|
|
330
|
+
target_project = None
|
|
331
|
+
for p in project_list.projects:
|
|
332
|
+
# Match by permalink (handles case-insensitive input)
|
|
333
|
+
if p.permalink == project_permalink:
|
|
334
|
+
target_project = p
|
|
335
|
+
break
|
|
336
|
+
# Also match by name comparison (case-insensitive)
|
|
337
|
+
if p.name.lower() == project_name.lower():
|
|
338
|
+
target_project = p
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
if not target_project:
|
|
329
342
|
available_projects = [p.name for p in project_list.projects]
|
|
330
343
|
raise ValueError(
|
|
331
344
|
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
332
345
|
)
|
|
333
346
|
|
|
334
|
-
# Call API to delete project
|
|
335
|
-
|
|
347
|
+
# Call API to delete project using URL encoding for special characters
|
|
348
|
+
from urllib.parse import quote
|
|
349
|
+
encoded_name = quote(target_project.name, safe='')
|
|
350
|
+
response = await call_delete(client, f"/projects/{encoded_name}")
|
|
336
351
|
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
337
352
|
|
|
338
353
|
result = f"✓ {status_response.message}\n\n"
|
|
@@ -17,6 +17,7 @@ from basic_memory.mcp.async_client import client
|
|
|
17
17
|
from basic_memory.mcp.tools.utils import call_get
|
|
18
18
|
from basic_memory.mcp.project_session import get_active_project
|
|
19
19
|
from basic_memory.schemas.memory import memory_url_path
|
|
20
|
+
from basic_memory.utils import validate_project_path
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def calculate_target_params(content_length):
|
|
@@ -188,6 +189,21 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
|
|
|
188
189
|
project_url = active_project.project_url
|
|
189
190
|
|
|
190
191
|
url = memory_url_path(path)
|
|
192
|
+
|
|
193
|
+
# Validate path to prevent path traversal attacks
|
|
194
|
+
project_path = active_project.home
|
|
195
|
+
if not validate_project_path(url, project_path):
|
|
196
|
+
logger.warning(
|
|
197
|
+
"Attempted path traversal attack blocked",
|
|
198
|
+
path=path,
|
|
199
|
+
url=url,
|
|
200
|
+
project=active_project.name,
|
|
201
|
+
)
|
|
202
|
+
return {
|
|
203
|
+
"type": "error",
|
|
204
|
+
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
192
208
|
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
193
209
|
content_length = int(response.headers.get("content-length", 0))
|
|
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.search import search_notes
|
|
|
11
11
|
from basic_memory.mcp.tools.utils import call_get
|
|
12
12
|
from basic_memory.mcp.project_session import get_active_project
|
|
13
13
|
from basic_memory.schemas.memory import memory_url_path
|
|
14
|
+
from basic_memory.utils import validate_project_path
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@mcp.tool(
|
|
@@ -55,6 +56,20 @@ async def read_note(
|
|
|
55
56
|
# Get the active project first to check project-specific sync status
|
|
56
57
|
active_project = get_active_project(project)
|
|
57
58
|
|
|
59
|
+
# Validate identifier to prevent path traversal attacks
|
|
60
|
+
# We need to check both the raw identifier and the processed path
|
|
61
|
+
processed_path = memory_url_path(identifier)
|
|
62
|
+
project_path = active_project.home
|
|
63
|
+
|
|
64
|
+
if not validate_project_path(identifier, project_path) or not validate_project_path(processed_path, project_path):
|
|
65
|
+
logger.warning(
|
|
66
|
+
"Attempted path traversal attack blocked",
|
|
67
|
+
identifier=identifier,
|
|
68
|
+
processed_path=processed_path,
|
|
69
|
+
project=active_project.name,
|
|
70
|
+
)
|
|
71
|
+
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
|
72
|
+
|
|
58
73
|
# Check migration status and wait briefly if needed
|
|
59
74
|
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
60
75
|
|
|
@@ -124,7 +139,7 @@ def format_not_found_message(identifier: str) -> str:
|
|
|
124
139
|
return dedent(f"""
|
|
125
140
|
# Note Not Found: "{identifier}"
|
|
126
141
|
|
|
127
|
-
I
|
|
142
|
+
I couldn't find any notes matching "{identifier}". Here are some suggestions:
|
|
128
143
|
|
|
129
144
|
## Check Identifier Type
|
|
130
145
|
- If you provided a title, try using the exact permalink instead
|
|
@@ -170,7 +185,7 @@ def format_related_results(identifier: str, results) -> str:
|
|
|
170
185
|
message = dedent(f"""
|
|
171
186
|
# Note Not Found: "{identifier}"
|
|
172
187
|
|
|
173
|
-
I
|
|
188
|
+
I couldn't find an exact match for "{identifier}", but I found some related notes:
|
|
174
189
|
|
|
175
190
|
""")
|
|
176
191
|
|
|
@@ -4,8 +4,10 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
|
+
from basic_memory.config import ConfigManager
|
|
7
8
|
from basic_memory.mcp.server import mcp
|
|
8
9
|
from basic_memory.mcp.project_session import get_active_project
|
|
10
|
+
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _get_all_projects_status() -> list[str]:
|
|
@@ -13,8 +15,7 @@ def _get_all_projects_status() -> list[str]:
|
|
|
13
15
|
status_lines = []
|
|
14
16
|
|
|
15
17
|
try:
|
|
16
|
-
|
|
17
|
-
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
18
|
+
app_config = ConfigManager().config
|
|
18
19
|
|
|
19
20
|
if app_config.projects:
|
|
20
21
|
status_lines.extend(["", "---", "", "**All Projects Status:**"])
|
|
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools.utils import call_put
|
|
|
10
10
|
from basic_memory.mcp.project_session import get_active_project
|
|
11
11
|
from basic_memory.schemas import EntityResponse
|
|
12
12
|
from basic_memory.schemas.base import Entity
|
|
13
|
-
from basic_memory.utils import parse_tags
|
|
13
|
+
from basic_memory.utils import parse_tags, validate_project_path
|
|
14
14
|
|
|
15
15
|
# Define TagType as a Union that can accept either a string or a list of strings or None
|
|
16
16
|
TagType = Union[List[str], str, None]
|
|
@@ -75,6 +75,14 @@ async def write_note(
|
|
|
75
75
|
# Get the active project first to check project-specific sync status
|
|
76
76
|
active_project = get_active_project(project)
|
|
77
77
|
|
|
78
|
+
# Validate folder path to prevent path traversal attacks
|
|
79
|
+
project_path = active_project.home
|
|
80
|
+
if folder and not validate_project_path(folder, project_path):
|
|
81
|
+
logger.warning(
|
|
82
|
+
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
|
|
83
|
+
)
|
|
84
|
+
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
|
85
|
+
|
|
78
86
|
# Check migration status and wait briefly if needed
|
|
79
87
|
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
80
88
|
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Knowledge graph models."""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
+
from basic_memory.utils import ensure_timezone_aware
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
from sqlalchemy import (
|
|
@@ -73,8 +74,8 @@ class Entity(Base):
|
|
|
73
74
|
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
74
75
|
|
|
75
76
|
# Metadata and tracking
|
|
76
|
-
created_at: Mapped[datetime] = mapped_column(DateTime)
|
|
77
|
-
updated_at: Mapped[datetime] = mapped_column(DateTime)
|
|
77
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
|
|
78
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
|
|
78
79
|
|
|
79
80
|
# Relationships
|
|
80
81
|
project = relationship("Project", back_populates="entities")
|
|
@@ -103,6 +104,16 @@ class Entity(Base):
|
|
|
103
104
|
def is_markdown(self):
|
|
104
105
|
"""Check if the entity is a markdown file."""
|
|
105
106
|
return self.content_type == "text/markdown"
|
|
107
|
+
|
|
108
|
+
def __getattribute__(self, name):
|
|
109
|
+
"""Override attribute access to ensure datetime fields are timezone-aware."""
|
|
110
|
+
value = super().__getattribute__(name)
|
|
111
|
+
|
|
112
|
+
# Ensure datetime fields are timezone-aware
|
|
113
|
+
if name in ('created_at', 'updated_at') and isinstance(value, datetime):
|
|
114
|
+
return ensure_timezone_aware(value)
|
|
115
|
+
|
|
116
|
+
return value
|
|
106
117
|
|
|
107
118
|
def __repr__(self) -> str:
|
|
108
119
|
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
basic_memory/models/project.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Project model for Basic Memory."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, UTC
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import (
|
|
@@ -52,9 +52,9 @@ class Project(Base):
|
|
|
52
52
|
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
|
53
53
|
|
|
54
54
|
# Timestamps
|
|
55
|
-
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.
|
|
55
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
56
56
|
updated_at: Mapped[datetime] = mapped_column(
|
|
57
|
-
DateTime, default=datetime.
|
|
57
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
# Define relationships to entities, observations, and relations
|
|
@@ -57,7 +57,7 @@ class EntityRepository(Repository[Entity]):
|
|
|
57
57
|
"""
|
|
58
58
|
query = (
|
|
59
59
|
self.select()
|
|
60
|
-
.where(Entity.file_path ==
|
|
60
|
+
.where(Entity.file_path == Path(file_path).as_posix())
|
|
61
61
|
.options(*self.get_load_options())
|
|
62
62
|
)
|
|
63
63
|
return await self.find_one(query)
|
|
@@ -68,7 +68,7 @@ class EntityRepository(Repository[Entity]):
|
|
|
68
68
|
Args:
|
|
69
69
|
file_path: Path to the entity file (will be converted to string internally)
|
|
70
70
|
"""
|
|
71
|
-
return await self.delete_by_fields(file_path=
|
|
71
|
+
return await self.delete_by_fields(file_path=Path(file_path).as_posix())
|
|
72
72
|
|
|
73
73
|
def get_load_options(self) -> List[LoaderOption]:
|
|
74
74
|
"""Get SQLAlchemy loader options for eager loading relationships."""
|
|
@@ -46,7 +46,7 @@ class ProjectRepository(Repository[Project]):
|
|
|
46
46
|
Args:
|
|
47
47
|
path: Path to the project directory (will be converted to string internally)
|
|
48
48
|
"""
|
|
49
|
-
query = self.select().where(Project.path ==
|
|
49
|
+
query = self.select().where(Project.path == Path(path).as_posix())
|
|
50
50
|
return await self.find_one(query)
|
|
51
51
|
|
|
52
52
|
async def get_default_project(self) -> Optional[Project]:
|
|
@@ -83,3 +83,21 @@ class ProjectRepository(Repository[Project]):
|
|
|
83
83
|
await session.flush()
|
|
84
84
|
return target_project
|
|
85
85
|
return None # pragma: no cover
|
|
86
|
+
|
|
87
|
+
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
|
|
88
|
+
"""Update project path.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project_id: ID of the project to update
|
|
92
|
+
new_path: New filesystem path for the project
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The updated project if found, None otherwise
|
|
96
|
+
"""
|
|
97
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
98
|
+
project = await self.select_by_id(session, project_id)
|
|
99
|
+
if project:
|
|
100
|
+
project.path = new_path
|
|
101
|
+
await session.flush()
|
|
102
|
+
return project
|
|
103
|
+
return None
|
|
@@ -6,6 +6,7 @@ import time
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from typing import Any, Dict, List, Optional
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from loguru import logger
|
|
11
12
|
from sqlalchemy import Executable, Result, text
|
|
@@ -59,8 +60,11 @@ class SearchIndexRow:
|
|
|
59
60
|
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
|
60
61
|
return ""
|
|
61
62
|
|
|
63
|
+
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
|
64
|
+
normalized_path = Path(self.file_path).as_posix()
|
|
65
|
+
|
|
62
66
|
# Split the path by slashes
|
|
63
|
-
parts =
|
|
67
|
+
parts = normalized_path.split("/")
|
|
64
68
|
|
|
65
69
|
# If there's only one part (e.g., "README.md"), it's at the root
|
|
66
70
|
if len(parts) <= 1:
|
|
@@ -523,8 +527,8 @@ class SearchRepository:
|
|
|
523
527
|
async with db.scoped_session(self.session_maker) as session:
|
|
524
528
|
# Delete existing record if any
|
|
525
529
|
await session.execute(
|
|
526
|
-
text("DELETE FROM search_index WHERE permalink = :permalink"),
|
|
527
|
-
{"permalink": search_index_row.permalink},
|
|
530
|
+
text("DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"),
|
|
531
|
+
{"permalink": search_index_row.permalink, "project_id": self.project_id},
|
|
528
532
|
)
|
|
529
533
|
|
|
530
534
|
# Prepare data for insert with project_id
|