basic-memory 0.13.0b3__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.
- basic_memory/__init__.py +1 -7
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/api/routers/knowledge_router.py +13 -0
- basic_memory/api/routers/memory_router.py +3 -4
- basic_memory/api/routers/project_router.py +9 -9
- basic_memory/api/routers/prompt_router.py +2 -2
- basic_memory/cli/commands/project.py +2 -2
- basic_memory/cli/commands/status.py +1 -1
- basic_memory/cli/commands/sync.py +1 -1
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/server.py +6 -6
- basic_memory/mcp/tools/__init__.py +4 -0
- basic_memory/mcp/tools/build_context.py +32 -7
- basic_memory/mcp/tools/canvas.py +2 -1
- basic_memory/mcp/tools/delete_note.py +159 -4
- basic_memory/mcp/tools/edit_note.py +17 -11
- basic_memory/mcp/tools/move_note.py +252 -40
- basic_memory/mcp/tools/project_management.py +35 -3
- basic_memory/mcp/tools/read_note.py +9 -2
- basic_memory/mcp/tools/search.py +180 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +47 -0
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +13 -2
- basic_memory/models/project.py +1 -3
- basic_memory/repository/search_repository.py +99 -26
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/memory.py +58 -1
- basic_memory/services/entity_service.py +4 -4
- basic_memory/services/initialization.py +32 -5
- basic_memory/services/link_resolver.py +20 -5
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +157 -56
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/sync_service.py +55 -2
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/METADATA +2 -2
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/RECORD +41 -35
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/WHEEL +0 -0
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/licenses/LICENSE +0 -0
|
@@ -128,34 +128,90 @@ class SearchRepository:
|
|
|
128
128
|
is_prefix: Whether to add prefix search capability (* suffix)
|
|
129
129
|
|
|
130
130
|
For FTS5:
|
|
131
|
-
- Special characters and phrases need to be quoted
|
|
132
|
-
- Terms with spaces or special chars need quotes
|
|
133
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
|
|
134
134
|
"""
|
|
135
|
-
if "*" in term:
|
|
136
|
-
return term
|
|
137
|
-
|
|
138
135
|
# Check for explicit boolean operators - if present, return the term as is
|
|
139
136
|
boolean_operators = [" AND ", " OR ", " NOT "]
|
|
140
137
|
if any(op in f" {term} " for op in boolean_operators):
|
|
141
138
|
return term
|
|
142
139
|
|
|
143
|
-
#
|
|
144
|
-
|
|
140
|
+
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
|
141
|
+
# e.g., "hello*", "test*world" - these should be left alone
|
|
142
|
+
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
|
143
|
+
return term
|
|
145
144
|
|
|
146
|
-
#
|
|
147
|
-
|
|
145
|
+
# Characters that can cause FTS5 syntax errors when used as operators
|
|
146
|
+
# We're more conservative here - only quote when we detect problematic patterns
|
|
147
|
+
problematic_chars = [
|
|
148
|
+
'"',
|
|
149
|
+
"'",
|
|
150
|
+
"(",
|
|
151
|
+
")",
|
|
152
|
+
"[",
|
|
153
|
+
"]",
|
|
154
|
+
"{",
|
|
155
|
+
"}",
|
|
156
|
+
"+",
|
|
157
|
+
"!",
|
|
158
|
+
"@",
|
|
159
|
+
"#",
|
|
160
|
+
"$",
|
|
161
|
+
"%",
|
|
162
|
+
"^",
|
|
163
|
+
"&",
|
|
164
|
+
"=",
|
|
165
|
+
"|",
|
|
166
|
+
"\\",
|
|
167
|
+
"~",
|
|
168
|
+
"`",
|
|
169
|
+
]
|
|
148
170
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
|
172
|
+
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
|
173
|
+
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
|
174
|
+
|
|
175
|
+
# Check if term needs quoting
|
|
176
|
+
has_problematic = any(c in term for c in problematic_chars)
|
|
177
|
+
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
|
178
|
+
|
|
179
|
+
if has_problematic or has_spaces_or_special:
|
|
180
|
+
# Handle multi-word queries differently from special character queries
|
|
181
|
+
if " " in term and not any(c in term for c in problematic_chars):
|
|
182
|
+
# Check if any individual word contains special characters that need quoting
|
|
183
|
+
words = term.strip().split()
|
|
184
|
+
has_special_in_words = any(
|
|
185
|
+
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not has_special_in_words:
|
|
189
|
+
# For multi-word queries with simple words (like "emoji unicode"),
|
|
190
|
+
# use boolean AND to handle word order variations
|
|
191
|
+
if is_prefix:
|
|
192
|
+
# Add prefix wildcard to each word for better matching
|
|
193
|
+
prepared_words = [f"{word}*" for word in words if word]
|
|
194
|
+
else:
|
|
195
|
+
prepared_words = words
|
|
196
|
+
term = " AND ".join(prepared_words)
|
|
197
|
+
else:
|
|
198
|
+
# If any word has special characters, quote the entire phrase
|
|
199
|
+
escaped_term = term.replace('"', '""')
|
|
200
|
+
if is_prefix and not ("/" in term and term.endswith(".md")):
|
|
201
|
+
term = f'"{escaped_term}"*'
|
|
202
|
+
else:
|
|
203
|
+
term = f'"{escaped_term}"'
|
|
156
204
|
else:
|
|
157
|
-
# For file paths, use exact matching
|
|
158
|
-
|
|
205
|
+
# For terms with problematic characters or file paths, use exact phrase matching
|
|
206
|
+
# Escape any existing quotes by doubling them
|
|
207
|
+
escaped_term = term.replace('"', '""')
|
|
208
|
+
# Quote the entire term to handle special characters safely
|
|
209
|
+
if is_prefix and not ("/" in term and term.endswith(".md")):
|
|
210
|
+
# For search terms (not file paths), add prefix matching
|
|
211
|
+
term = f'"{escaped_term}"*'
|
|
212
|
+
else:
|
|
213
|
+
# For file paths, use exact matching
|
|
214
|
+
term = f'"{escaped_term}"'
|
|
159
215
|
elif is_prefix:
|
|
160
216
|
# Only add wildcard for simple terms without special characters
|
|
161
217
|
term = f"{term}*"
|
|
@@ -208,15 +264,21 @@ class SearchRepository:
|
|
|
208
264
|
|
|
209
265
|
# Handle permalink match search, supports *
|
|
210
266
|
if permalink_match:
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
267
|
+
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
|
268
|
+
# GLOB patterns need to preserve their syntax
|
|
269
|
+
permalink_text = permalink_match.lower().strip()
|
|
215
270
|
params["permalink"] = permalink_text
|
|
216
271
|
if "*" in permalink_match:
|
|
217
272
|
conditions.append("permalink GLOB :permalink")
|
|
218
273
|
else:
|
|
219
|
-
|
|
274
|
+
# For exact matches without *, we can use FTS5 MATCH
|
|
275
|
+
# but only prepare the term if it doesn't look like a path
|
|
276
|
+
if "/" in permalink_text:
|
|
277
|
+
conditions.append("permalink = :permalink")
|
|
278
|
+
else:
|
|
279
|
+
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
|
280
|
+
params["permalink"] = permalink_text
|
|
281
|
+
conditions.append("permalink MATCH :permalink")
|
|
220
282
|
|
|
221
283
|
# Handle entity type filter
|
|
222
284
|
if search_item_types:
|
|
@@ -273,9 +335,20 @@ class SearchRepository:
|
|
|
273
335
|
"""
|
|
274
336
|
|
|
275
337
|
logger.trace(f"Search {sql} params: {params}")
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
338
|
+
try:
|
|
339
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
340
|
+
result = await session.execute(text(sql), params)
|
|
341
|
+
rows = result.fetchall()
|
|
342
|
+
except Exception as e:
|
|
343
|
+
# Handle FTS5 syntax errors and provide user-friendly feedback
|
|
344
|
+
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
|
345
|
+
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
|
346
|
+
# Return empty results rather than crashing
|
|
347
|
+
return []
|
|
348
|
+
else:
|
|
349
|
+
# Re-raise other database errors
|
|
350
|
+
logger.error(f"Database error during search: {e}")
|
|
351
|
+
raise
|
|
279
352
|
|
|
280
353
|
results = [
|
|
281
354
|
SearchIndexRow(
|
basic_memory/schemas/base.py
CHANGED
|
@@ -13,7 +13,7 @@ Key Concepts:
|
|
|
13
13
|
|
|
14
14
|
import mimetypes
|
|
15
15
|
import re
|
|
16
|
-
from datetime import datetime
|
|
16
|
+
from datetime import datetime, time
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import List, Optional, Annotated, Dict
|
|
19
19
|
|
|
@@ -46,15 +46,43 @@ def to_snake_case(name: str) -> str:
|
|
|
46
46
|
return s2.lower()
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def parse_timeframe(timeframe: str) -> datetime:
|
|
50
|
+
"""Parse timeframe with special handling for 'today' and other natural language expressions.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
datetime: The parsed datetime for the start of the timeframe
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
parse_timeframe('today') -> 2025-06-05 00:00:00 (start of today)
|
|
60
|
+
parse_timeframe('1d') -> 2025-06-04 14:50:00 (24 hours ago)
|
|
61
|
+
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00 (1 week ago)
|
|
62
|
+
"""
|
|
63
|
+
if timeframe.lower() == "today":
|
|
64
|
+
# Return start of today (00:00:00)
|
|
65
|
+
return datetime.combine(datetime.now().date(), time.min)
|
|
66
|
+
else:
|
|
67
|
+
# Use dateparser for other formats
|
|
68
|
+
parsed = parse(timeframe)
|
|
69
|
+
if not parsed:
|
|
70
|
+
raise ValueError(f"Could not parse timeframe: {timeframe}")
|
|
71
|
+
return parsed
|
|
72
|
+
|
|
73
|
+
|
|
49
74
|
def validate_timeframe(timeframe: str) -> str:
|
|
50
75
|
"""Convert human readable timeframes to a duration relative to the current time."""
|
|
51
76
|
if not isinstance(timeframe, str):
|
|
52
77
|
raise ValueError("Timeframe must be a string")
|
|
53
78
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
if
|
|
57
|
-
|
|
79
|
+
# Preserve special timeframe strings that need custom handling
|
|
80
|
+
special_timeframes = ["today"]
|
|
81
|
+
if timeframe.lower() in special_timeframes:
|
|
82
|
+
return timeframe.lower()
|
|
83
|
+
|
|
84
|
+
# Parse relative time expression using our enhanced parser
|
|
85
|
+
parsed = parse_timeframe(timeframe)
|
|
58
86
|
|
|
59
87
|
# Convert to duration
|
|
60
88
|
now = datetime.now()
|
basic_memory/schemas/memory.py
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
#
|
|
201
|
-
|
|
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(
|
|
30
|
-
|
|
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
|
|
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
|
|
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()
|