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.

Files changed (69) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +3 -1
  3. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
  4. basic_memory/api/app.py +4 -1
  5. basic_memory/api/routers/management_router.py +3 -1
  6. basic_memory/api/routers/project_router.py +21 -13
  7. basic_memory/api/routers/resource_router.py +3 -3
  8. basic_memory/cli/app.py +3 -3
  9. basic_memory/cli/commands/__init__.py +1 -2
  10. basic_memory/cli/commands/db.py +5 -5
  11. basic_memory/cli/commands/import_chatgpt.py +3 -2
  12. basic_memory/cli/commands/import_claude_conversations.py +3 -1
  13. basic_memory/cli/commands/import_claude_projects.py +3 -1
  14. basic_memory/cli/commands/import_memory_json.py +5 -2
  15. basic_memory/cli/commands/mcp.py +3 -15
  16. basic_memory/cli/commands/project.py +46 -6
  17. basic_memory/cli/commands/status.py +4 -1
  18. basic_memory/cli/commands/sync.py +10 -2
  19. basic_memory/cli/main.py +0 -1
  20. basic_memory/config.py +61 -34
  21. basic_memory/db.py +2 -6
  22. basic_memory/deps.py +3 -2
  23. basic_memory/file_utils.py +65 -0
  24. basic_memory/importers/chatgpt_importer.py +20 -10
  25. basic_memory/importers/memory_json_importer.py +22 -7
  26. basic_memory/importers/utils.py +2 -2
  27. basic_memory/markdown/entity_parser.py +2 -2
  28. basic_memory/markdown/markdown_processor.py +2 -2
  29. basic_memory/markdown/plugins.py +42 -26
  30. basic_memory/markdown/utils.py +1 -1
  31. basic_memory/mcp/async_client.py +22 -2
  32. basic_memory/mcp/project_session.py +6 -4
  33. basic_memory/mcp/prompts/__init__.py +0 -2
  34. basic_memory/mcp/server.py +8 -71
  35. basic_memory/mcp/tools/build_context.py +12 -2
  36. basic_memory/mcp/tools/move_note.py +24 -12
  37. basic_memory/mcp/tools/project_management.py +22 -7
  38. basic_memory/mcp/tools/read_content.py +16 -0
  39. basic_memory/mcp/tools/read_note.py +17 -2
  40. basic_memory/mcp/tools/sync_status.py +3 -2
  41. basic_memory/mcp/tools/write_note.py +9 -1
  42. basic_memory/models/knowledge.py +13 -2
  43. basic_memory/models/project.py +3 -3
  44. basic_memory/repository/entity_repository.py +2 -2
  45. basic_memory/repository/project_repository.py +19 -1
  46. basic_memory/repository/search_repository.py +7 -3
  47. basic_memory/schemas/base.py +40 -10
  48. basic_memory/schemas/importer.py +1 -0
  49. basic_memory/schemas/memory.py +23 -11
  50. basic_memory/services/context_service.py +12 -2
  51. basic_memory/services/directory_service.py +7 -0
  52. basic_memory/services/entity_service.py +56 -10
  53. basic_memory/services/initialization.py +0 -75
  54. basic_memory/services/project_service.py +93 -36
  55. basic_memory/sync/background_sync.py +4 -3
  56. basic_memory/sync/sync_service.py +53 -4
  57. basic_memory/sync/watch_service.py +31 -8
  58. basic_memory/utils.py +234 -71
  59. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
  60. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
  61. basic_memory/cli/commands/auth.py +0 -136
  62. basic_memory/mcp/auth_provider.py +0 -270
  63. basic_memory/mcp/external_auth_provider.py +0 -321
  64. basic_memory/mcp/prompts/sync_status.py +0 -112
  65. basic_memory/mcp/supabase_auth_provider.py +0 -463
  66. basic_memory/services/migration_service.py +0 -168
  67. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
  68. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
  69. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
@@ -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 = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
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, config_manager
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) -> None:
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 = config_manager.load_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
- return get_project_config(current_project)
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
  ]
@@ -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 app_config
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
- """Manage application lifecycle with type-safe context"""
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
- # OAuth configuration function
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
- auth=auth_provider,
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[int] = 1,
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
- # Check if the destination path looks like it might be trying to reference another project
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
- response = await call_put(client, f"/projects/{project_name}/default")
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
- # Check if project exists
327
- project_exists = any(p.name == project_name for p in project_list.projects)
328
- if not project_exists:
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
- response = await call_delete(client, f"/projects/{project_name}")
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 searched for "{identifier}" using multiple methods (direct lookup, title search, and text search) but couldn't find any matching notes. Here are some suggestions:
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 searched for "{identifier}" using direct lookup and title search but couldn't find an exact match. However, I found some related notes through text search:
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
- from basic_memory.config import app_config
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
 
@@ -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}'"
@@ -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.utcnow)
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.utcnow, onupdate=datetime.utcnow
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 == str(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=str(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 == str(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 = self.file_path.split("/")
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