basic-memory 0.13.8.dev1__py3-none-any.whl → 0.14.0b1__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 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.13.7"
4
+ __version__ = "0.14.0b1"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -52,7 +52,7 @@ async def to_graph_context(
52
52
  file_path=item.file_path,
53
53
  permalink=item.permalink, # pyright: ignore
54
54
  relation_type=item.relation_type, # pyright: ignore
55
- from_entity=from_entity.title, # pyright: ignore
55
+ from_entity=from_entity.title if from_entity else None,
56
56
  to_entity=to_entity.title if to_entity else None,
57
57
  created_at=item.created_at,
58
58
  )
@@ -78,7 +78,6 @@ def mcp(
78
78
  if transport == "stdio":
79
79
  mcp_server.run(
80
80
  transport=transport,
81
- log_level="INFO",
82
81
  )
83
82
  elif transport == "streamable-http" or transport == "sse":
84
83
  mcp_server.run(
basic_memory/config.py CHANGED
@@ -45,7 +45,9 @@ class BasicMemoryConfig(BaseSettings):
45
45
  env: Environment = Field(default="dev", description="Environment name")
46
46
 
47
47
  projects: Dict[str, str] = Field(
48
- default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
48
+ default_factory=lambda: {
49
+ "main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
50
+ },
49
51
  description="Mapping of project names to their filesystem paths",
50
52
  )
51
53
  default_project: str = Field(
@@ -92,7 +94,9 @@ class BasicMemoryConfig(BaseSettings):
92
94
  """Ensure configuration is valid after initialization."""
93
95
  # Ensure main project exists
94
96
  if "main" not in self.projects: # pragma: no cover
95
- self.projects["main"] = str(Path.home() / "basic-memory")
97
+ self.projects["main"] = str(
98
+ Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
99
+ )
96
100
 
97
101
  # Ensure default project is valid
98
102
  if self.default_project not in self.projects: # pragma: no cover
@@ -82,10 +82,15 @@ async def build_context(
82
82
  logger.info(f"Building context from {url}")
83
83
  # URL is already validated and normalized by MemoryUrl type annotation
84
84
 
85
+ # Get the active project first to check project-specific sync status
86
+ active_project = get_active_project(project)
87
+
85
88
  # Check migration status and wait briefly if needed
86
89
  from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
87
90
 
88
- migration_status = await wait_for_migration_or_return_status(timeout=5.0)
91
+ migration_status = await wait_for_migration_or_return_status(
92
+ timeout=5.0, project_name=active_project.name
93
+ )
89
94
  if migration_status: # pragma: no cover
90
95
  # Return a proper GraphContext with status message
91
96
  from basic_memory.schemas.memory import MemoryMetadata
@@ -102,8 +107,6 @@ async def build_context(
102
107
  uri=migration_status, # Include status in metadata
103
108
  ),
104
109
  )
105
-
106
- active_project = get_active_project(project)
107
110
  project_url = active_project.project_url
108
111
 
109
112
  response = await call_get(
@@ -7,9 +7,155 @@ from loguru import logger
7
7
 
8
8
  from basic_memory.mcp.async_client import client
9
9
  from basic_memory.mcp.server import mcp
10
- from basic_memory.mcp.tools.utils import call_post
10
+ 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
+ from basic_memory.schemas.project_info import ProjectList
14
+
15
+
16
+ async def _detect_cross_project_move_attempt(
17
+ identifier: str, destination_path: str, current_project: str
18
+ ) -> Optional[str]:
19
+ """Detect potential cross-project move attempts and return guidance.
20
+
21
+ Args:
22
+ identifier: The note identifier being moved
23
+ destination_path: The destination path
24
+ current_project: The current active project
25
+
26
+ Returns:
27
+ Error message with guidance if cross-project move is detected, None otherwise
28
+ """
29
+ try:
30
+ # Get list of all available projects to check against
31
+ response = await call_get(client, "/projects/projects")
32
+ project_list = ProjectList.model_validate(response.json())
33
+ project_names = [p.name.lower() for p in project_list.projects]
34
+
35
+ # Check if destination path contains any project names
36
+ dest_lower = destination_path.lower()
37
+ path_parts = dest_lower.split("/")
38
+
39
+ # Look for project names in the destination path
40
+ for part in path_parts:
41
+ if part in project_names and part != current_project.lower():
42
+ # Found a different project name in the path
43
+ matching_project = next(
44
+ p.name for p in project_list.projects if p.name.lower() == part
45
+ )
46
+ return _format_cross_project_error_response(
47
+ identifier, destination_path, current_project, matching_project
48
+ )
49
+
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
+ )
62
+
63
+ except Exception as e:
64
+ # If we can't detect, don't interfere with normal error handling
65
+ logger.debug(f"Could not check for cross-project move: {e}")
66
+ return None
67
+
68
+ return None
69
+
70
+
71
+ def _format_cross_project_error_response(
72
+ identifier: str, destination_path: str, current_project: str, target_project: str
73
+ ) -> str:
74
+ """Format error response for detected cross-project move attempts."""
75
+ return dedent(f"""
76
+ # Move Failed - Cross-Project Move Not Supported
77
+
78
+ Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
79
+
80
+ **Current project:** {current_project}
81
+ **Target project:** {target_project}
82
+
83
+ ## Cross-project moves are not supported directly
84
+
85
+ Notes can only be moved within the same project. To move content between projects, use this workflow:
86
+
87
+ ### Recommended approach:
88
+ ```
89
+ # 1. Read the note content from current project
90
+ read_note("{identifier}")
91
+
92
+ # 2. Switch to the target project
93
+ switch_project("{target_project}")
94
+
95
+ # 3. Create the note in the target project
96
+ write_note("Note Title", "content from step 1", "target-folder")
97
+
98
+ # 4. Switch back to original project (optional)
99
+ switch_project("{current_project}")
100
+
101
+ # 5. Delete the original note if desired
102
+ delete_note("{identifier}")
103
+ ```
104
+
105
+ ### Alternative: Stay in current project
106
+ If you want to move the note within the **{current_project}** project only:
107
+ ```
108
+ move_note("{identifier}", "new-folder/new-name.md")
109
+ ```
110
+
111
+ ## Available projects:
112
+ Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
113
+ """).strip()
114
+
115
+
116
+ def _format_potential_cross_project_guidance(
117
+ identifier: str, destination_path: str, current_project: str, available_projects: list[str]
118
+ ) -> str:
119
+ """Format guidance for potentially cross-project moves."""
120
+ other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
121
+ if len(available_projects) > 3:
122
+ other_projects += f" (and {len(available_projects) - 3} others)"
123
+
124
+ return dedent(f"""
125
+ # Move Failed - Check Project Context
126
+
127
+ Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
128
+
129
+ ## If you intended to move within the current project:
130
+ The destination path should be relative to the project root:
131
+ ```
132
+ move_note("{identifier}", "folder/filename.md")
133
+ ```
134
+
135
+ ## If you intended to move to a different project:
136
+ Cross-project moves require switching projects first. Available projects: {other_projects}
137
+
138
+ ### To move to another project:
139
+ ```
140
+ # 1. Read the content
141
+ read_note("{identifier}")
142
+
143
+ # 2. Switch to target project
144
+ switch_project("target-project-name")
145
+
146
+ # 3. Create note in target project
147
+ write_note("Title", "content", "folder")
148
+
149
+ # 4. Switch back and delete original if desired
150
+ switch_project("{current_project}")
151
+ delete_note("{identifier}")
152
+ ```
153
+
154
+ ### To see all projects:
155
+ ```
156
+ list_projects()
157
+ ```
158
+ """).strip()
13
159
 
14
160
 
15
161
  def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
@@ -258,6 +404,14 @@ async def move_note(
258
404
  active_project = get_active_project(project)
259
405
  project_url = active_project.project_url
260
406
 
407
+ # Check for potential cross-project move attempts
408
+ cross_project_error = await _detect_cross_project_move_attempt(
409
+ identifier, destination_path, active_project.name
410
+ )
411
+ if cross_project_error:
412
+ logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
413
+ return cross_project_error
414
+
261
415
  try:
262
416
  # Prepare move request
263
417
  move_data = {
@@ -52,14 +52,17 @@ async def read_note(
52
52
  read_note("Meeting Notes", project="work-project")
53
53
  """
54
54
 
55
+ # Get the active project first to check project-specific sync status
56
+ active_project = get_active_project(project)
57
+
55
58
  # Check migration status and wait briefly if needed
56
59
  from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
57
60
 
58
- migration_status = await wait_for_migration_or_return_status(timeout=5.0)
61
+ migration_status = await wait_for_migration_or_return_status(
62
+ timeout=5.0, project_name=active_project.name
63
+ )
59
64
  if migration_status: # pragma: no cover
60
65
  return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
61
-
62
- active_project = get_active_project(project)
63
66
  project_url = active_project.project_url
64
67
 
65
68
  # Get the file via REST API - first try direct permalink lookup
@@ -525,11 +525,16 @@ def check_migration_status() -> Optional[str]:
525
525
  return None
526
526
 
527
527
 
528
- async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
528
+ async def wait_for_migration_or_return_status(
529
+ timeout: float = 5.0, project_name: Optional[str] = None
530
+ ) -> Optional[str]:
529
531
  """Wait briefly for sync/migration to complete, or return status message.
530
532
 
531
533
  Args:
532
534
  timeout: Maximum time to wait for sync completion
535
+ project_name: Optional project name to check specific project status.
536
+ If provided, only checks that project's readiness.
537
+ If None, uses global status check (legacy behavior).
533
538
 
534
539
  Returns:
535
540
  Status message if sync is still in progress, None if ready
@@ -538,18 +543,36 @@ async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[
538
543
  from basic_memory.services.sync_status_service import sync_status_tracker
539
544
  import asyncio
540
545
 
541
- if sync_status_tracker.is_ready:
546
+ # Check if we should use project-specific or global status
547
+ def is_ready() -> bool:
548
+ if project_name:
549
+ return sync_status_tracker.is_project_ready(project_name)
550
+ return sync_status_tracker.is_ready
551
+
552
+ if is_ready():
542
553
  return None
543
554
 
544
555
  # Wait briefly for sync to complete
545
556
  start_time = asyncio.get_event_loop().time()
546
557
  while (asyncio.get_event_loop().time() - start_time) < timeout:
547
- if sync_status_tracker.is_ready:
558
+ if is_ready():
548
559
  return None
549
560
  await asyncio.sleep(0.1) # Check every 100ms
550
561
 
551
562
  # Still not ready after timeout
552
- return sync_status_tracker.get_summary()
563
+ if project_name:
564
+ # For project-specific checks, get project status details
565
+ project_status = sync_status_tracker.get_project_status(project_name)
566
+ if project_status and project_status.status.value == "failed":
567
+ error_msg = project_status.error or "Unknown sync error"
568
+ return f"❌ Sync failed for project '{project_name}': {error_msg}"
569
+ elif project_status:
570
+ return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
571
+ else:
572
+ return f"⚠️ Project '{project_name}' status unknown"
573
+ else:
574
+ # Fall back to global summary for legacy calls
575
+ return sync_status_tracker.get_summary()
553
576
  except Exception: # pragma: no cover
554
577
  # If there's any error, assume ready
555
578
  return None
@@ -72,10 +72,15 @@ async def write_note(
72
72
  """
73
73
  logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
74
74
 
75
+ # Get the active project first to check project-specific sync status
76
+ active_project = get_active_project(project)
77
+
75
78
  # Check migration status and wait briefly if needed
76
79
  from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
77
80
 
78
- migration_status = await wait_for_migration_or_return_status(timeout=5.0)
81
+ migration_status = await wait_for_migration_or_return_status(
82
+ timeout=5.0, project_name=active_project.name
83
+ )
79
84
  if migration_status: # pragma: no cover
80
85
  return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
81
86
 
@@ -91,7 +96,6 @@ async def write_note(
91
96
  content=content,
92
97
  entity_metadata=metadata,
93
98
  )
94
- active_project = get_active_project(project)
95
99
  project_url = active_project.project_url
96
100
 
97
101
  # Create or update via knowledge API
@@ -142,7 +142,7 @@ class EntityRepository(Repository[Entity]):
142
142
  await session.flush()
143
143
  # Return with relationships loaded
144
144
  query = (
145
- select(Entity)
145
+ self.select()
146
146
  .where(Entity.file_path == entity.file_path)
147
147
  .options(*self.get_load_options())
148
148
  )
@@ -162,7 +162,7 @@ class EntityRepository(Repository[Entity]):
162
162
 
163
163
  # Return with relationships loaded
164
164
  query = (
165
- select(Entity)
165
+ self.select()
166
166
  .where(Entity.file_path == entity.file_path)
167
167
  .options(*self.get_load_options())
168
168
  )
@@ -203,7 +203,7 @@ class EntityRepository(Repository[Entity]):
203
203
  await session.flush()
204
204
  # Return the updated entity with relationships loaded
205
205
  query = (
206
- select(Entity)
206
+ self.select()
207
207
  .where(Entity.file_path == entity.file_path)
208
208
  .options(*self.get_load_options())
209
209
  )
@@ -243,7 +243,7 @@ class EntityRepository(Repository[Entity]):
243
243
 
244
244
  # Return the inserted entity with relationships loaded
245
245
  query = (
246
- select(Entity)
246
+ self.select()
247
247
  .where(Entity.file_path == entity.file_path)
248
248
  .options(*self.get_load_options())
249
249
  )
@@ -1,6 +1,7 @@
1
1
  """Repository for search operations."""
2
2
 
3
3
  import json
4
+ import re
4
5
  import time
5
6
  from dataclasses import dataclass
6
7
  from datetime import datetime
@@ -120,23 +121,141 @@ class SearchRepository:
120
121
  logger.error(f"Error initializing search index: {e}")
121
122
  raise e
122
123
 
123
- def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
124
- """Prepare a search term for FTS5 query.
124
+ def _prepare_boolean_query(self, query: str) -> str:
125
+ """Prepare a Boolean query by quoting individual terms while preserving operators.
125
126
 
126
127
  Args:
127
- term: The search term to prepare
128
+ query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
129
+
130
+ Returns:
131
+ A properly formatted Boolean query with quoted terms that need quoting
132
+ """
133
+ # Define Boolean operators and their boundaries
134
+ boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
135
+
136
+ # Split the query by Boolean operators, keeping the operators
137
+ parts = re.split(boolean_pattern, query)
138
+
139
+ processed_parts = []
140
+ for part in parts:
141
+ part = part.strip()
142
+ if not part:
143
+ continue
144
+
145
+ # If it's a Boolean operator, keep it as is
146
+ if part in ["AND", "OR", "NOT"]:
147
+ processed_parts.append(part)
148
+ else:
149
+ # Handle parentheses specially - they should be preserved for grouping
150
+ if "(" in part or ")" in part:
151
+ # Parse parenthetical expressions carefully
152
+ processed_part = self._prepare_parenthetical_term(part)
153
+ processed_parts.append(processed_part)
154
+ else:
155
+ # This is a search term - for Boolean queries, don't add prefix wildcards
156
+ prepared_term = self._prepare_single_term(part, is_prefix=False)
157
+ processed_parts.append(prepared_term)
158
+
159
+ return " ".join(processed_parts)
160
+
161
+ def _prepare_parenthetical_term(self, term: str) -> str:
162
+ """Prepare a term that contains parentheses, preserving the parentheses for grouping.
163
+
164
+ Args:
165
+ term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
166
+
167
+ Returns:
168
+ A properly formatted term with parentheses preserved
169
+ """
170
+ # Handle terms that start/end with parentheses but may contain quotable content
171
+ result = ""
172
+ i = 0
173
+ while i < len(term):
174
+ if term[i] in "()":
175
+ # Preserve parentheses as-is
176
+ result += term[i]
177
+ i += 1
178
+ else:
179
+ # Find the next parenthesis or end of string
180
+ start = i
181
+ while i < len(term) and term[i] not in "()":
182
+ i += 1
183
+
184
+ # Extract the content between parentheses
185
+ content = term[start:i].strip()
186
+ if content:
187
+ # Only quote if it actually needs quoting (has hyphens, special chars, etc)
188
+ # but don't quote if it's just simple words
189
+ if self._needs_quoting(content):
190
+ escaped_content = content.replace('"', '""')
191
+ result += f'"{escaped_content}"'
192
+ else:
193
+ result += content
194
+
195
+ return result
196
+
197
+ def _needs_quoting(self, term: str) -> bool:
198
+ """Check if a term needs to be quoted for FTS5 safety.
199
+
200
+ Args:
201
+ term: The term to check
202
+
203
+ Returns:
204
+ True if the term should be quoted
205
+ """
206
+ if not term or not term.strip():
207
+ return False
208
+
209
+ # Characters that indicate we should quote (excluding parentheses which are valid syntax)
210
+ needs_quoting_chars = [
211
+ " ",
212
+ ".",
213
+ ":",
214
+ ";",
215
+ ",",
216
+ "<",
217
+ ">",
218
+ "?",
219
+ "/",
220
+ "-",
221
+ "'",
222
+ '"',
223
+ "[",
224
+ "]",
225
+ "{",
226
+ "}",
227
+ "+",
228
+ "!",
229
+ "@",
230
+ "#",
231
+ "$",
232
+ "%",
233
+ "^",
234
+ "&",
235
+ "=",
236
+ "|",
237
+ "\\",
238
+ "~",
239
+ "`",
240
+ ]
241
+
242
+ return any(c in term for c in needs_quoting_chars)
243
+
244
+ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
245
+ """Prepare a single search term (no Boolean operators).
246
+
247
+ Args:
248
+ term: A single search term
128
249
  is_prefix: Whether to add prefix search capability (* suffix)
129
250
 
130
- For FTS5:
131
- - Boolean operators (AND, OR, NOT) are preserved for complex queries
132
- - Terms with FTS5 special characters are quoted to prevent syntax errors
133
- - Simple terms get prefix wildcards for better matching
251
+ Returns:
252
+ A properly formatted single term
134
253
  """
135
- # Check for explicit boolean operators - if present, return the term as is
136
- boolean_operators = [" AND ", " OR ", " NOT "]
137
- if any(op in f" {term} " for op in boolean_operators):
254
+ if not term or not term.strip():
138
255
  return term
139
256
 
257
+ term = term.strip()
258
+
140
259
  # Check if term is already a proper wildcard pattern (alphanumeric + *)
141
260
  # e.g., "hello*", "test*world" - these should be left alone
142
261
  if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
@@ -218,6 +337,26 @@ class SearchRepository:
218
337
 
219
338
  return term
220
339
 
340
+ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
341
+ """Prepare a search term for FTS5 query.
342
+
343
+ Args:
344
+ term: The search term to prepare
345
+ is_prefix: Whether to add prefix search capability (* suffix)
346
+
347
+ For FTS5:
348
+ - Boolean operators (AND, OR, NOT) are preserved for complex queries
349
+ - Terms with FTS5 special characters are quoted to prevent syntax errors
350
+ - Simple terms get prefix wildcards for better matching
351
+ """
352
+ # Check for explicit boolean operators - if present, process as Boolean query
353
+ boolean_operators = [" AND ", " OR ", " NOT "]
354
+ if any(op in f" {term} " for op in boolean_operators):
355
+ return self._prepare_boolean_query(term)
356
+
357
+ # For non-Boolean queries, use the single term preparation logic
358
+ return self._prepare_single_term(term, is_prefix)
359
+
221
360
  async def search(
222
361
  self,
223
362
  search_text: Optional[str] = None,
@@ -242,19 +381,10 @@ class SearchRepository:
242
381
  # For wildcard searches, don't add any text conditions - return all results
243
382
  pass
244
383
  else:
245
- # Check for explicit boolean operators - only detect them in proper boolean contexts
246
- has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
247
-
248
- if has_boolean:
249
- # If boolean operators are present, use the raw query
250
- # No need to prepare it, FTS5 will understand the operators
251
- params["text"] = search_text
252
- conditions.append("(title MATCH :text OR content_stems MATCH :text)")
253
- else:
254
- # Standard search with term preparation
255
- processed_text = self._prepare_search_term(search_text.strip())
256
- params["text"] = processed_text
257
- conditions.append("(title MATCH :text OR content_stems MATCH :text)")
384
+ # Use _prepare_search_term to handle both Boolean and non-Boolean queries
385
+ processed_text = self._prepare_search_term(search_text.strip())
386
+ params["text"] = processed_text
387
+ conditions.append("(title MATCH :text OR content_stems MATCH :text)")
258
388
 
259
389
  # Handle title match search
260
390
  if title:
@@ -134,7 +134,7 @@ class RelationSummary(BaseModel):
134
134
  file_path: str
135
135
  permalink: str
136
136
  relation_type: str
137
- from_entity: str
137
+ from_entity: Optional[str] = None
138
138
  to_entity: Optional[str] = None
139
139
  created_at: datetime
140
140
 
@@ -682,8 +682,8 @@ class EntityService(BaseService[EntityModel]):
682
682
  # 6. Prepare database updates
683
683
  updates = {"file_path": destination_path}
684
684
 
685
- # 7. Update permalink if configured
686
- if app_config.update_permalinks_on_move:
685
+ # 7. Update permalink if configured or if entity has null permalink
686
+ if app_config.update_permalinks_on_move or old_permalink is None:
687
687
  # Generate new permalink from destination path
688
688
  new_permalink = await self.resolve_permalink(destination_path)
689
689
 
@@ -693,7 +693,12 @@ class EntityService(BaseService[EntityModel]):
693
693
  )
694
694
 
695
695
  updates["permalink"] = new_permalink
696
- logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
696
+ if old_permalink is None:
697
+ logger.info(
698
+ f"Generated permalink for entity with null permalink: {new_permalink}"
699
+ )
700
+ else:
701
+ logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
697
702
 
698
703
  # 8. Recalculate checksum
699
704
  new_checksum = await self.file_service.compute_checksum(destination_path)
@@ -131,6 +131,23 @@ class SyncStatusTracker:
131
131
  """Check if system is ready (no sync in progress)."""
132
132
  return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
133
133
 
134
+ def is_project_ready(self, project_name: str) -> bool:
135
+ """Check if a specific project is ready for operations.
136
+
137
+ Args:
138
+ project_name: Name of the project to check
139
+
140
+ Returns:
141
+ True if the project is ready (completed, watching, or not tracked),
142
+ False if the project is syncing, scanning, or failed
143
+ """
144
+ project_status = self._project_statuses.get(project_name)
145
+ if not project_status:
146
+ # Project not tracked = ready (likely hasn't been synced yet)
147
+ return True
148
+
149
+ return project_status.status in (SyncStatus.COMPLETED, SyncStatus.WATCHING, SyncStatus.IDLE)
150
+
134
151
  def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
135
152
  """Get status for a specific project."""
136
153
  return self._project_statuses.get(project_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-memory
3
- Version: 0.13.8.dev1
3
+ Version: 0.14.0b1
4
4
  Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
5
5
  Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
6
6
  Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
@@ -1,5 +1,5 @@
1
- basic_memory/__init__.py,sha256=lyC6nHbFkXl-Q5KVthLKDdH6q30O0-hAs7LPLP9pVrI,256
2
- basic_memory/config.py,sha256=OP8ygXZuiAEpKp2EvudMFWnJDiEf3ZWAUZqvqzkEo6g,11700
1
+ basic_memory/__init__.py,sha256=DPlOFFx4PSC_e78q8BXXJTJMjPtdv3jAI7UszpA20Dk,258
2
+ basic_memory/config.py,sha256=YX6pP8aOMlIx9NoCeKLS0b5cgOnegbWhX2ijJzimLQg,11828
3
3
  basic_memory/db.py,sha256=bFuJHj_PGEhaj5ZgRItIUSW0ujAFCGgYKO7nZsjbYD0,7582
4
4
  basic_memory/deps.py,sha256=zXOhqXCoSVIa1iIcO8U6uUiofJn5eT4ycwJkH9I2kX4,12102
5
5
  basic_memory/file_utils.py,sha256=eaxTKLLEbTIy_Mb_Iv_Dmt4IXAJSrZGVi-Knrpyci3E,6700
@@ -27,7 +27,7 @@ basic_memory/api/routers/project_router.py,sha256=cXGx6VZMg67jdzMi1Xf8SodtueEI04
27
27
  basic_memory/api/routers/prompt_router.py,sha256=4wxq6-NREgVJM8N9C0YsN1AAUDD8nkTCOzWyzSqTSFw,9948
28
28
  basic_memory/api/routers/resource_router.py,sha256=WEJEqEaY_yTKj5-U-rW4kXQKUcJflykgwI6_g_R41ck,8058
29
29
  basic_memory/api/routers/search_router.py,sha256=GD62jlCQTiL_VNsdibi-b1f6H40KCWo9SX2Cl7YH4QU,1226
30
- basic_memory/api/routers/utils.py,sha256=vW-bYUmATQPe-pYbQfNjuho-BzsHhy9bmWv-LZX3HTc,5153
30
+ basic_memory/api/routers/utils.py,sha256=nmD1faJOHcnWQjbCanojUwA9xhinf764U8SUqjNXpXw,5159
31
31
  basic_memory/cli/__init__.py,sha256=arcKLAWRDhPD7x5t80MlviZeYzwHZ0GZigyy3NKVoGk,33
32
32
  basic_memory/cli/app.py,sha256=BCIaiJBPV0ipk8KwPRLNiG2ACKBQH9wo1ewKZm7CV7o,2269
33
33
  basic_memory/cli/main.py,sha256=_0eW-TWYxj8WyCHSS9kFcrrntpeIsqJrA2P0n6LfFvY,475
@@ -38,7 +38,7 @@ basic_memory/cli/commands/import_chatgpt.py,sha256=VU_Kfr4B0lVh_Z2_bmsTO3e3U40wr
38
38
  basic_memory/cli/commands/import_claude_conversations.py,sha256=sXnP0hjfwUapwHQDzxE2HEkCDe8FTaE4cogKILsD1EA,2866
39
39
  basic_memory/cli/commands/import_claude_projects.py,sha256=mWYIeA-mu_Pq23R7OEtY2XHXG5CAh1dMGIBhckB4zRk,2811
40
40
  basic_memory/cli/commands/import_memory_json.py,sha256=Vz5rt7KCel5B3Dtv57WPEUJTHCMwFUqQlOCm2djwUi8,2867
41
- basic_memory/cli/commands/mcp.py,sha256=W7TnSjjEFYDkrImJHkRI_OFa3muBZU0zW_c9LSVbVdk,3153
41
+ basic_memory/cli/commands/mcp.py,sha256=NCqyY5dFHDtYnMDrWQiquoGbALUh-AqN4iRjJlFngxY,3123
42
42
  basic_memory/cli/commands/project.py,sha256=9OJWoV9kSkLePETUPxNs0v5YceXLgPWO2fU45ZinP9g,12252
43
43
  basic_memory/cli/commands/status.py,sha256=708EK8-iPjyc1iE5MPECzAyZraGYoGpvYjLwTm-BlQs,5719
44
44
  basic_memory/cli/commands/sync.py,sha256=gOU_onrMj9_IRiIe8FWU_FLEvfjcOt-qhrvvFJuU-ws,8010
@@ -73,40 +73,40 @@ basic_memory/mcp/prompts/utils.py,sha256=VacrbqwYtySpIlYIrKHo5s6jtoTMscYJqrFRH3z
73
73
  basic_memory/mcp/resources/ai_assistant_guide.md,sha256=qnYWDkYlb-JmKuOoZ5llmRas_t4dWDXB_i8LE277Lgs,14777
74
74
  basic_memory/mcp/resources/project_info.py,sha256=LcUkTx4iXBfU6Lp4TVch78OqLopbOy4ljyKnfr4VXso,1906
75
75
  basic_memory/mcp/tools/__init__.py,sha256=lCCOC0jElvL2v53WI_dxRs4qABq4Eo-YGm6j2XeZ6AQ,1591
76
- basic_memory/mcp/tools/build_context.py,sha256=RbevfGVblSF901kAD2zc1CQ5z3tzfLC9XV_jcq35d_Y,4490
76
+ basic_memory/mcp/tools/build_context.py,sha256=ckKAt3uPXz5hzT_e68PuZuK8_tquo2OOai4uM_yxl44,4611
77
77
  basic_memory/mcp/tools/canvas.py,sha256=22F9G9gfPb-l8i1B5ra4Ja_h9zYY83rPY9mDA5C5gkY,3738
78
78
  basic_memory/mcp/tools/delete_note.py,sha256=tSyRc_VgBmLyVeenClwX1Sk--LKcGahAMzTX2mK2XIs,7346
79
79
  basic_memory/mcp/tools/edit_note.py,sha256=q4x-f7-j_l-wzm17-AVFT1_WGCo0Cq4lI3seYSe21aY,13570
80
80
  basic_memory/mcp/tools/list_directory.py,sha256=-FxDsCru5YD02M4qkQDAurEJWyRaC7YI4YR6zg0atR8,5236
81
- basic_memory/mcp/tools/move_note.py,sha256=esnbddG2OcmIgRNuQwx5OhlwZ1CWcOheg3hUobsEcq0,11320
81
+ basic_memory/mcp/tools/move_note.py,sha256=jAsCFXrcWXPoBWlWcW8y3Tli5MkKwCQK-n6IwUZoOK8,17357
82
82
  basic_memory/mcp/tools/project_management.py,sha256=sZQbak0jIQ6k03Syz6X6Zsy-C9z8KdPnEQcpJLCxPwM,12779
83
83
  basic_memory/mcp/tools/read_content.py,sha256=4FTw13B8UjVVhR78NJB9HKeJb_nA6-BGT1WdGtekN5Q,8596
84
- basic_memory/mcp/tools/read_note.py,sha256=GdsJLkcDrCBnmNeM9BZRx9Xs2LUqH5ty_E471T9Kf1Y,7493
84
+ basic_memory/mcp/tools/read_note.py,sha256=V08NdBqWY8Y0Q4zuwK--zN3VK7fmuCH1mOYZKwL1IT4,7614
85
85
  basic_memory/mcp/tools/recent_activity.py,sha256=XVjNJAJnmxvzx9_Ls1A-QOd2yTR7pJlSTTuRxSivmN4,4833
86
86
  basic_memory/mcp/tools/search.py,sha256=22sLHed6z53mH9NQqBv37Xi4d6AtOTyrUvKs2Mycijk,11296
87
87
  basic_memory/mcp/tools/sync_status.py,sha256=mt0DdcaAlyiKW4NK4gy6psajSqcez0bOm_4MzG1NOdg,10486
88
- basic_memory/mcp/tools/utils.py,sha256=wsfrgiBScacMilODu85AXbUUKA5fJi4_6phDIC9dQRs,19702
88
+ basic_memory/mcp/tools/utils.py,sha256=qVAEkR4naCLrqIo_7xXFubqGGxypouz-DB4_svTvARY,20892
89
89
  basic_memory/mcp/tools/view_note.py,sha256=ddNXxyETsdA5SYflIaQVj_Cbd7I7CLVs3atRRDMbGmg,2499
90
- basic_memory/mcp/tools/write_note.py,sha256=dPC2bqxnuWD1NgDn8hXx4mMtQ8T91WoS2kNmHtKsm70,6056
90
+ basic_memory/mcp/tools/write_note.py,sha256=GFmX_VLJvcqK29-ADTCDnPgBaweAq_9IBGCs99mwFTw,6178
91
91
  basic_memory/models/__init__.py,sha256=j0C4dtFi-FOEaQKR8dQWEG-dJtdQ15NBTiJg4nbIXNU,333
92
92
  basic_memory/models/base.py,sha256=4hAXJ8CE1RnjKhb23lPd-QM7G_FXIdTowMJ9bRixspU,225
93
93
  basic_memory/models/knowledge.py,sha256=AFxfKS8fRa43Kq3EjJCAufpte4VNC7fs9YfshDrB4o0,7087
94
94
  basic_memory/models/project.py,sha256=oUrQaUOu7_muSl-i38Dh0HzmCFrMAtwgxALDUTt9k5c,2773
95
95
  basic_memory/models/search.py,sha256=PhQ8w4taApSvjh1DpPhB4cH9GTt2E2po-DFZzhnoZkY,1300
96
96
  basic_memory/repository/__init__.py,sha256=MWK-o8QikqzOpe5SyPbKQ2ioB5BWA0Upz65tgg-E0DU,327
97
- basic_memory/repository/entity_repository.py,sha256=q5aYVsdBdJ8vvNP0J8zAlUSEgosu9y7rRlEHVQj--zk,10409
97
+ basic_memory/repository/entity_repository.py,sha256=4qjR66bI1kvGHXFo3w_owppnCFi_na6sRkoPRAJz-uA,10405
98
98
  basic_memory/repository/observation_repository.py,sha256=qhMvHLSjaoT3Fa_cQOKsT5jYPj66GXSytEBMwLAgygQ,2943
99
99
  basic_memory/repository/project_info_repository.py,sha256=8XLVAYKkBWQ6GbKj1iqA9OK0FGPHdTlOs7ZtfeUf9t8,338
100
100
  basic_memory/repository/project_repository.py,sha256=sgdKxKTSiiOZTzABwUNqli7K5mbXiPiQEAc5r0RD_jQ,3159
101
101
  basic_memory/repository/relation_repository.py,sha256=z7Oo5Zz_J-Bj6RvQDpSWR73ZLk2fxG7e7jrMbeFeJvQ,3179
102
102
  basic_memory/repository/repository.py,sha256=MJb-cb8QZQbL-Grq_iqv4Kq75aX2yQohLIqh5T4fFxw,15224
103
- basic_memory/repository/search_repository.py,sha256=CdALAsROY6W_rE1UB8Bn55ZdMv4DOmNOtShoXCwPI_Q,17897
103
+ basic_memory/repository/search_repository.py,sha256=qXL3PRtx2sV3Do6zeTxsmsROTnkvnatSj4xObGqAvKo,21936
104
104
  basic_memory/schemas/__init__.py,sha256=mEgIFcdTeb-v4y0gkOh_pA5zyqGbZk-9XbXqlSi6WMs,1674
105
105
  basic_memory/schemas/base.py,sha256=Fx97DEqzOr7y9zeeseO9qVBYbOft_4OQf9EiVfhOJn4,6738
106
106
  basic_memory/schemas/delete.py,sha256=UAR2JK99WMj3gP-yoGWlHD3eZEkvlTSRf8QoYIE-Wfw,1180
107
107
  basic_memory/schemas/directory.py,sha256=F9_LrJqRqb_kO08GDKJzXLb2nhbYG2PdVUo5eDD_Kf4,881
108
108
  basic_memory/schemas/importer.py,sha256=FAh-RGxuhFW2rz3HFxwLzENJOiGgbTR2hUeXZZpM3OA,663
109
- basic_memory/schemas/memory.py,sha256=6YjEyJ9GJLC4VrFD0EnoRDTfg-Sf6g0D4bhL9rwNBi4,5816
109
+ basic_memory/schemas/memory.py,sha256=rLSpU6VT_spnLEiVeYp9lI7FH5IvdbZt19VXFuO-vtM,5833
110
110
  basic_memory/schemas/project_info.py,sha256=fcNjUpe25_5uMmKy142ib3p5qEakzs1WJPLkgol5zyw,7047
111
111
  basic_memory/schemas/prompt.py,sha256=SpIVfZprQT8E5uP40j3CpBc2nHKflwOo3iZD7BFPIHE,3648
112
112
  basic_memory/schemas/request.py,sha256=Mv5EvrLZlFIiPr8dOjo_4QXvkseYhQI7cd_X2zDsxQM,3760
@@ -115,7 +115,7 @@ basic_memory/schemas/search.py,sha256=ywMsDGAQK2sO2TT5lc-da_k67OKW1x1TenXormHHWv
115
115
  basic_memory/services/__init__.py,sha256=XGt8WX3fX_0K9L37Msy8HF8nlMZYIG3uQ6mUX6_iJtg,259
116
116
  basic_memory/services/context_service.py,sha256=4ReLAF5qifA9ayOePGsVKusw1TWj8oBzRECjrsFiKPI,14462
117
117
  basic_memory/services/directory_service.py,sha256=_YOPXseQM4knd7PIFAho9LV_E-FljVE5WVJKQ0uflZs,6017
118
- basic_memory/services/entity_service.py,sha256=o5S0pE17lV-_mMq_7i78fpLamhdzKm_NMqlmzpY2z-c,30165
118
+ basic_memory/services/entity_service.py,sha256=fNUWPsprigdy6DjIyGnkeBZnY81qLXRbC5qlwlpluu4,30440
119
119
  basic_memory/services/exceptions.py,sha256=oVjQr50XQqnFq1-MNKBilI2ShtHDxypavyDk1UeyHhw,390
120
120
  basic_memory/services/file_service.py,sha256=jCrmnEkTQ4t9HF7L_M6BL7tdDqjjzty9hpTo9AzwhvM,10059
121
121
  basic_memory/services/initialization.py,sha256=HN1NhFTEPHjpzBwabVkvFbJ_ldXJXuNaww4ugh7MJos,9717
@@ -124,15 +124,15 @@ basic_memory/services/migration_service.py,sha256=pFJCSD7UgHLx1CHvtN4Df1CzDEp-CZ
124
124
  basic_memory/services/project_service.py,sha256=uLIrQB6T1DY3BXrEsLdB2ZlcKnPgjubyn-g6V9vMBzA,27928
125
125
  basic_memory/services/search_service.py,sha256=c5Ky0ufz7YPFgHhVzNRQ4OecF_JUrt7nALzpMjobW4M,12782
126
126
  basic_memory/services/service.py,sha256=V-d_8gOV07zGIQDpL-Ksqs3ZN9l3qf3HZOK1f_YNTag,336
127
- basic_memory/services/sync_status_service.py,sha256=PRAnYrsNJY8EIlxaxCrDsY0TjySDdhktjta8ReQZyiY,6838
127
+ basic_memory/services/sync_status_service.py,sha256=CgJdaJ6OFvFjKHIQSVIQX8kEU389Mrz_WS6x8dx2-7c,7504
128
128
  basic_memory/sync/__init__.py,sha256=CVHguYH457h2u2xoM8KvOilJC71XJlZ-qUh8lHcjYj4,156
129
129
  basic_memory/sync/background_sync.py,sha256=4CEx8oP6-qD33uCeowhpzhA8wivmWxaCmSBP37h3Fs8,714
130
130
  basic_memory/sync/sync_service.py,sha256=AxC5J1YTcPWTmA0HdzvOZBthi4-_LZ44kNF0KQoDRPw,23387
131
131
  basic_memory/sync/watch_service.py,sha256=JAumrHUjV1lF9NtEK32jgg0myWBfLXotNXxONeIV9SM,15316
132
132
  basic_memory/templates/prompts/continue_conversation.hbs,sha256=trrDHSXA5S0JCbInMoUJL04xvCGRB_ku1RHNQHtl6ZI,3076
133
133
  basic_memory/templates/prompts/search.hbs,sha256=H1cCIsHKp4VC1GrH2KeUB8pGe5vXFPqb2VPotypmeCA,3098
134
- basic_memory-0.13.8.dev1.dist-info/METADATA,sha256=04rwU_da1RIYuWb5CO4EjTQy3oS2NUDHRAU4sToEY7E,17229
135
- basic_memory-0.13.8.dev1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
136
- basic_memory-0.13.8.dev1.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
137
- basic_memory-0.13.8.dev1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
138
- basic_memory-0.13.8.dev1.dist-info/RECORD,,
134
+ basic_memory-0.14.0b1.dist-info/METADATA,sha256=J0wpc3nuaEntf0KMuMgnNhQZC4L0ap2g5ST8c07QLO0,17226
135
+ basic_memory-0.14.0b1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
136
+ basic_memory-0.14.0b1.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
137
+ basic_memory-0.14.0b1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
138
+ basic_memory-0.14.0b1.dist-info/RECORD,,