basic-memory 0.13.0b4__py3-none-any.whl → 0.13.0b5__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 (39) hide show
  1. basic_memory/__init__.py +1 -7
  2. basic_memory/api/routers/knowledge_router.py +13 -0
  3. basic_memory/api/routers/memory_router.py +3 -4
  4. basic_memory/api/routers/project_router.py +6 -5
  5. basic_memory/api/routers/prompt_router.py +2 -2
  6. basic_memory/cli/commands/project.py +2 -2
  7. basic_memory/cli/commands/status.py +1 -1
  8. basic_memory/cli/commands/sync.py +1 -1
  9. basic_memory/mcp/prompts/__init__.py +2 -0
  10. basic_memory/mcp/prompts/sync_status.py +116 -0
  11. basic_memory/mcp/server.py +6 -6
  12. basic_memory/mcp/tools/__init__.py +4 -0
  13. basic_memory/mcp/tools/build_context.py +32 -7
  14. basic_memory/mcp/tools/canvas.py +2 -1
  15. basic_memory/mcp/tools/delete_note.py +159 -4
  16. basic_memory/mcp/tools/edit_note.py +17 -11
  17. basic_memory/mcp/tools/move_note.py +252 -40
  18. basic_memory/mcp/tools/project_management.py +35 -3
  19. basic_memory/mcp/tools/read_note.py +9 -2
  20. basic_memory/mcp/tools/search.py +180 -8
  21. basic_memory/mcp/tools/sync_status.py +254 -0
  22. basic_memory/mcp/tools/utils.py +47 -0
  23. basic_memory/mcp/tools/view_note.py +66 -0
  24. basic_memory/mcp/tools/write_note.py +13 -2
  25. basic_memory/repository/search_repository.py +99 -26
  26. basic_memory/schemas/base.py +33 -5
  27. basic_memory/schemas/memory.py +58 -1
  28. basic_memory/services/entity_service.py +4 -4
  29. basic_memory/services/initialization.py +32 -5
  30. basic_memory/services/link_resolver.py +20 -5
  31. basic_memory/services/migration_service.py +168 -0
  32. basic_memory/services/project_service.py +97 -47
  33. basic_memory/services/sync_status_service.py +181 -0
  34. basic_memory/sync/sync_service.py +55 -2
  35. {basic_memory-0.13.0b4.dist-info → basic_memory-0.13.0b5.dist-info}/METADATA +2 -2
  36. {basic_memory-0.13.0b4.dist-info → basic_memory-0.13.0b5.dist-info}/RECORD +39 -34
  37. {basic_memory-0.13.0b4.dist-info → basic_memory-0.13.0b5.dist-info}/WHEEL +0 -0
  38. {basic_memory-0.13.0b4.dist-info → basic_memory-0.13.0b5.dist-info}/entry_points.txt +0 -0
  39. {basic_memory-0.13.0b4.dist-info → basic_memory-0.13.0b5.dist-info}/licenses/LICENSE +0 -0
@@ -9,8 +9,44 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
9
9
  from basic_memory.schemas.search import SearchItemType
10
10
 
11
11
 
12
+ def validate_memory_url_path(path: str) -> bool:
13
+ """Validate that a memory URL path is well-formed.
14
+
15
+ Args:
16
+ path: The path part of a memory URL (without memory:// prefix)
17
+
18
+ Returns:
19
+ True if the path is valid, False otherwise
20
+
21
+ Examples:
22
+ >>> validate_memory_url_path("specs/search")
23
+ True
24
+ >>> validate_memory_url_path("memory//test") # Double slash
25
+ False
26
+ >>> validate_memory_url_path("invalid://test") # Contains protocol
27
+ False
28
+ """
29
+ if not path or not path.strip():
30
+ return False
31
+
32
+ # Check for invalid protocol schemes within the path first (more specific)
33
+ if "://" in path:
34
+ return False
35
+
36
+ # Check for double slashes (except at the beginning for absolute paths)
37
+ if "//" in path:
38
+ return False
39
+
40
+ # Check for invalid characters (excluding * which is used for pattern matching)
41
+ invalid_chars = {"<", ">", '"', "|", "?"}
42
+ if any(char in path for char in invalid_chars):
43
+ return False
44
+
45
+ return True
46
+
47
+
12
48
  def normalize_memory_url(url: str | None) -> str:
13
- """Normalize a MemoryUrl string.
49
+ """Normalize a MemoryUrl string with validation.
14
50
 
15
51
  Args:
16
52
  url: A path like "specs/search" or "memory://specs/search"
@@ -18,22 +54,43 @@ def normalize_memory_url(url: str | None) -> str:
18
54
  Returns:
19
55
  Normalized URL starting with memory://
20
56
 
57
+ Raises:
58
+ ValueError: If the URL path is malformed
59
+
21
60
  Examples:
22
61
  >>> normalize_memory_url("specs/search")
23
62
  'memory://specs/search'
24
63
  >>> normalize_memory_url("memory://specs/search")
25
64
  'memory://specs/search'
65
+ >>> normalize_memory_url("memory//test")
66
+ Traceback (most recent call last):
67
+ ...
68
+ ValueError: Invalid memory URL path: 'memory//test' contains double slashes
26
69
  """
27
70
  if not url:
28
71
  return ""
29
72
 
30
73
  clean_path = url.removeprefix("memory://")
74
+
75
+ # Validate the extracted path
76
+ if not validate_memory_url_path(clean_path):
77
+ # Provide specific error messages for common issues
78
+ if "://" in clean_path:
79
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
80
+ elif "//" in clean_path:
81
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
82
+ elif not clean_path.strip():
83
+ raise ValueError("Memory URL path cannot be empty or whitespace")
84
+ else:
85
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
86
+
31
87
  return f"memory://{clean_path}"
32
88
 
33
89
 
34
90
  MemoryUrl = Annotated[
35
91
  str,
36
92
  BeforeValidator(str.strip), # Clean whitespace
93
+ BeforeValidator(normalize_memory_url), # Validate and normalize the URL
37
94
  MinLen(1),
38
95
  MaxLen(2028),
39
96
  ]
@@ -413,8 +413,8 @@ class EntityService(BaseService[EntityModel]):
413
413
  """
414
414
  logger.debug(f"Editing entity: {identifier}, operation: {operation}")
415
415
 
416
- # Find the entity using the link resolver
417
- entity = await self.link_resolver.resolve_link(identifier)
416
+ # Find the entity using the link resolver with strict mode for destructive operations
417
+ entity = await self.link_resolver.resolve_link(identifier, strict=True)
418
418
  if not entity:
419
419
  raise EntityNotFoundError(f"Entity not found: {identifier}")
420
420
 
@@ -630,8 +630,8 @@ class EntityService(BaseService[EntityModel]):
630
630
  """
631
631
  logger.debug(f"Moving entity: {identifier} to {destination_path}")
632
632
 
633
- # 1. Resolve identifier to entity
634
- entity = await self.link_resolver.resolve_link(identifier)
633
+ # 1. Resolve identifier to entity with strict mode for destructive operations
634
+ entity = await self.link_resolver.resolve_link(identifier, strict=True)
635
635
  if not entity:
636
636
  raise EntityNotFoundError(f"Entity not found: {identifier}")
637
637
 
@@ -83,7 +83,9 @@ async def migrate_legacy_projects(app_config: BasicMemoryConfig):
83
83
  logger.error(f"Project {project_name} not found in database, skipping migration")
84
84
  continue
85
85
 
86
+ logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
86
87
  await migrate_legacy_project_data(project, legacy_dir)
88
+ logger.info(f"Completed migration for project: {project_name}")
87
89
  logger.info("Legacy projects successfully migrated")
88
90
 
89
91
 
@@ -104,7 +106,7 @@ async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> boo
104
106
  sync_dir = Path(project.path)
105
107
 
106
108
  logger.info(f"Sync starting project: {project.name}")
107
- await sync_service.sync(sync_dir)
109
+ await sync_service.sync(sync_dir, project_name=project.name)
108
110
  logger.info(f"Sync completed successfully for project: {project.name}")
109
111
 
110
112
  # After successful sync, remove the legacy directory
@@ -158,12 +160,32 @@ async def initialize_file_sync(
158
160
  sync_dir = Path(project.path)
159
161
 
160
162
  try:
161
- await sync_service.sync(sync_dir)
163
+ await sync_service.sync(sync_dir, project_name=project.name)
162
164
  logger.info(f"Sync completed successfully for project: {project.name}")
165
+
166
+ # Mark project as watching for changes after successful sync
167
+ from basic_memory.services.sync_status_service import sync_status_tracker
168
+
169
+ sync_status_tracker.start_project_watch(project.name)
170
+ logger.info(f"Project {project.name} is now watching for changes")
163
171
  except Exception as e: # pragma: no cover
164
172
  logger.error(f"Error syncing project {project.name}: {e}")
173
+ # Mark sync as failed for this project
174
+ from basic_memory.services.sync_status_service import sync_status_tracker
175
+
176
+ sync_status_tracker.fail_project_sync(project.name, str(e))
165
177
  # Continue with other projects even if one fails
166
178
 
179
+ # Mark migration complete if it was in progress
180
+ try:
181
+ from basic_memory.services.migration_service import migration_manager
182
+
183
+ if not migration_manager.is_ready: # pragma: no cover
184
+ migration_manager.mark_completed("Migration completed with file sync")
185
+ logger.info("Marked migration as completed after file sync")
186
+ except Exception as e: # pragma: no cover
187
+ logger.warning(f"Could not update migration status: {e}")
188
+
167
189
  # Then start the watch service in the background
168
190
  logger.info("Starting watch service for all projects")
169
191
  # run the watch service
@@ -185,7 +207,7 @@ async def initialize_app(
185
207
  - Running database migrations
186
208
  - Reconciling projects from config.json with projects table
187
209
  - Setting up file synchronization
188
- - Migrating legacy project data
210
+ - Starting background migration for legacy project data
189
211
 
190
212
  Args:
191
213
  app_config: The Basic Memory project configuration
@@ -197,8 +219,13 @@ async def initialize_app(
197
219
  # Reconcile projects from config.json with projects table
198
220
  await reconcile_projects_with_config(app_config)
199
221
 
200
- # migrate legacy project data
201
- await migrate_legacy_projects(app_config)
222
+ # Start background migration for legacy project data (non-blocking)
223
+ from basic_memory.services.migration_service import migration_manager
224
+
225
+ await migration_manager.start_background_migration(app_config)
226
+
227
+ logger.info("App initialization completed (migration running in background if needed)")
228
+ return migration_manager
202
229
 
203
230
 
204
231
  def ensure_initialization(app_config: BasicMemoryConfig) -> None:
@@ -26,8 +26,16 @@ class LinkResolver:
26
26
  self.entity_repository = entity_repository
27
27
  self.search_service = search_service
28
28
 
29
- async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
30
- """Resolve a markdown link to a permalink."""
29
+ async def resolve_link(
30
+ self, link_text: str, use_search: bool = True, strict: bool = False
31
+ ) -> Optional[Entity]:
32
+ """Resolve a markdown link to a permalink.
33
+
34
+ Args:
35
+ link_text: The link text to resolve
36
+ use_search: Whether to use search-based fuzzy matching as fallback
37
+ strict: If True, only exact matches are allowed (no fuzzy search fallback)
38
+ """
31
39
  logger.trace(f"Resolving link: {link_text}")
32
40
 
33
41
  # Clean link text and extract any alias
@@ -41,7 +49,8 @@ class LinkResolver:
41
49
 
42
50
  # 2. Try exact title match
43
51
  found = await self.entity_repository.get_by_title(clean_text)
44
- if found and len(found) == 1:
52
+ if found:
53
+ # Return first match if there are duplicates (consistent behavior)
45
54
  entity = found[0]
46
55
  logger.debug(f"Found title match: {entity.title}")
47
56
  return entity
@@ -60,9 +69,12 @@ class LinkResolver:
60
69
  logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
61
70
  return found_path_md
62
71
 
63
- # search if indicated
72
+ # In strict mode, don't try fuzzy search - return None if no exact match found
73
+ if strict:
74
+ return None
75
+
76
+ # 5. Fall back to search for fuzzy matching (only if not in strict mode)
64
77
  if use_search and "*" not in clean_text:
65
- # 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
66
78
  results = await self.search_service.search(
67
79
  query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
68
80
  )
@@ -101,5 +113,8 @@ class LinkResolver:
101
113
  text, alias = text.split("|", 1)
102
114
  text = text.strip()
103
115
  alias = alias.strip()
116
+ else:
117
+ # Strip whitespace from text even if no alias
118
+ text = text.strip()
104
119
 
105
120
  return text, alias
@@ -0,0 +1,168 @@
1
+ """Migration service for handling background migrations and status tracking."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from loguru import logger
10
+
11
+ from basic_memory.config import BasicMemoryConfig
12
+
13
+
14
+ class MigrationStatus(Enum):
15
+ """Status of migration operations."""
16
+
17
+ NOT_NEEDED = "not_needed"
18
+ PENDING = "pending"
19
+ IN_PROGRESS = "in_progress"
20
+ COMPLETED = "completed"
21
+ FAILED = "failed"
22
+
23
+
24
+ @dataclass
25
+ class MigrationState:
26
+ """Current state of migration operations."""
27
+
28
+ status: MigrationStatus
29
+ message: str
30
+ progress: Optional[str] = None
31
+ error: Optional[str] = None
32
+ projects_migrated: int = 0
33
+ projects_total: int = 0
34
+
35
+
36
+ class MigrationManager:
37
+ """Manages background migration operations and status tracking."""
38
+
39
+ def __init__(self):
40
+ self._state = MigrationState(
41
+ status=MigrationStatus.NOT_NEEDED, message="No migration required"
42
+ )
43
+ self._migration_task: Optional[asyncio.Task] = None
44
+
45
+ @property
46
+ def state(self) -> MigrationState:
47
+ """Get current migration state."""
48
+ return self._state
49
+
50
+ @property
51
+ def is_ready(self) -> bool:
52
+ """Check if the system is ready for normal operations."""
53
+ return self._state.status in (MigrationStatus.NOT_NEEDED, MigrationStatus.COMPLETED)
54
+
55
+ @property
56
+ def status_message(self) -> str:
57
+ """Get a user-friendly status message."""
58
+ if self._state.status == MigrationStatus.IN_PROGRESS:
59
+ progress = (
60
+ f" ({self._state.projects_migrated}/{self._state.projects_total})"
61
+ if self._state.projects_total > 0
62
+ else ""
63
+ )
64
+ return f"🔄 File sync in progress{progress}: {self._state.message}. Use sync_status() tool for details."
65
+ elif self._state.status == MigrationStatus.FAILED:
66
+ return f"❌ File sync failed: {self._state.error or 'Unknown error'}. Use sync_status() tool for details."
67
+ elif self._state.status == MigrationStatus.COMPLETED:
68
+ return "✅ File sync completed successfully"
69
+ else:
70
+ return "✅ System ready"
71
+
72
+ async def check_migration_needed(self, app_config: BasicMemoryConfig) -> bool:
73
+ """Check if migration is needed without performing it."""
74
+ from basic_memory import db
75
+ from basic_memory.repository import ProjectRepository
76
+
77
+ try:
78
+ # Get database session
79
+ _, session_maker = await db.get_or_create_db(
80
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
81
+ )
82
+ project_repository = ProjectRepository(session_maker)
83
+
84
+ # Check for legacy projects
85
+ legacy_projects = []
86
+ for project_name, project_path in app_config.projects.items():
87
+ legacy_dir = Path(project_path) / ".basic-memory"
88
+ if legacy_dir.exists():
89
+ project = await project_repository.get_by_name(project_name)
90
+ if project:
91
+ legacy_projects.append(project)
92
+
93
+ if legacy_projects:
94
+ self._state = MigrationState(
95
+ status=MigrationStatus.PENDING,
96
+ message="Legacy projects detected",
97
+ projects_total=len(legacy_projects),
98
+ )
99
+ return True
100
+ else:
101
+ self._state = MigrationState(
102
+ status=MigrationStatus.NOT_NEEDED, message="No migration required"
103
+ )
104
+ return False
105
+
106
+ except Exception as e:
107
+ logger.error(f"Error checking migration status: {e}")
108
+ self._state = MigrationState(
109
+ status=MigrationStatus.FAILED, message="Migration check failed", error=str(e)
110
+ )
111
+ return False
112
+
113
+ async def start_background_migration(self, app_config: BasicMemoryConfig) -> None:
114
+ """Start migration in background if needed."""
115
+ if not await self.check_migration_needed(app_config):
116
+ return
117
+
118
+ if self._migration_task and not self._migration_task.done():
119
+ logger.info("Migration already in progress")
120
+ return
121
+
122
+ logger.info("Starting background migration")
123
+ self._migration_task = asyncio.create_task(self._run_migration(app_config))
124
+
125
+ async def _run_migration(self, app_config: BasicMemoryConfig) -> None:
126
+ """Run the actual migration process."""
127
+ try:
128
+ self._state.status = MigrationStatus.IN_PROGRESS
129
+ self._state.message = "Migrating legacy projects"
130
+
131
+ # Import here to avoid circular imports
132
+ from basic_memory.services.initialization import migrate_legacy_projects
133
+
134
+ # Run the migration
135
+ await migrate_legacy_projects(app_config)
136
+
137
+ self._state = MigrationState(
138
+ status=MigrationStatus.COMPLETED, message="Migration completed successfully"
139
+ )
140
+ logger.info("Background migration completed successfully")
141
+
142
+ except Exception as e:
143
+ logger.error(f"Background migration failed: {e}")
144
+ self._state = MigrationState(
145
+ status=MigrationStatus.FAILED, message="Migration failed", error=str(e)
146
+ )
147
+
148
+ async def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
149
+ """Wait for migration to complete."""
150
+ if self.is_ready:
151
+ return True
152
+
153
+ if not self._migration_task:
154
+ return False
155
+
156
+ try:
157
+ await asyncio.wait_for(self._migration_task, timeout=timeout)
158
+ return self.is_ready
159
+ except asyncio.TimeoutError:
160
+ return False
161
+
162
+ def mark_completed(self, message: str = "Migration completed") -> None:
163
+ """Mark migration as completed externally."""
164
+ self._state = MigrationState(status=MigrationStatus.COMPLETED, message=message)
165
+
166
+
167
+ # Global migration manager instance
168
+ migration_manager = MigrationManager()