basic-memory 0.12.3__py3-none-any.whl → 0.13.0__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 +2 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +26 -7
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,19 +5,18 @@ to ensure consistent application startup across all entry points.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
|
-
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from loguru import logger
|
|
11
12
|
|
|
12
13
|
from basic_memory import db
|
|
13
|
-
from basic_memory.config import
|
|
14
|
-
from basic_memory.
|
|
14
|
+
from basic_memory.config import BasicMemoryConfig
|
|
15
|
+
from basic_memory.models import Project
|
|
16
|
+
from basic_memory.repository import ProjectRepository
|
|
15
17
|
|
|
16
|
-
# Import this inside functions to avoid circular imports
|
|
17
|
-
# from basic_memory.cli.commands.sync import get_sync_service
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
async def initialize_database(app_config: ProjectConfig) -> None:
|
|
19
|
+
async def initialize_database(app_config: BasicMemoryConfig) -> None:
|
|
21
20
|
"""Run database migrations to ensure schema is up to date.
|
|
22
21
|
|
|
23
22
|
Args:
|
|
@@ -34,110 +33,215 @@ async def initialize_database(app_config: ProjectConfig) -> None:
|
|
|
34
33
|
# more specific error if the database is actually unusable
|
|
35
34
|
|
|
36
35
|
|
|
36
|
+
async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
|
37
|
+
"""Ensure all projects in config.json exist in the projects table and vice versa.
|
|
38
|
+
|
|
39
|
+
This uses the ProjectService's synchronize_projects method to ensure bidirectional
|
|
40
|
+
synchronization between the configuration file and the database.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
app_config: The Basic Memory application configuration
|
|
44
|
+
"""
|
|
45
|
+
logger.info("Reconciling projects from config with database...")
|
|
46
|
+
|
|
47
|
+
# Get database session
|
|
48
|
+
_, session_maker = await db.get_or_create_db(
|
|
49
|
+
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
50
|
+
)
|
|
51
|
+
project_repository = ProjectRepository(session_maker)
|
|
52
|
+
|
|
53
|
+
# Import ProjectService here to avoid circular imports
|
|
54
|
+
from basic_memory.services.project_service import ProjectService
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Create project service and synchronize projects
|
|
58
|
+
project_service = ProjectService(repository=project_repository)
|
|
59
|
+
await project_service.synchronize_projects()
|
|
60
|
+
logger.info("Projects successfully reconciled between config and database")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
# Log the error but continue with initialization
|
|
63
|
+
logger.error(f"Error during project synchronization: {e}")
|
|
64
|
+
logger.info("Continuing with initialization despite synchronization error")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def migrate_legacy_projects(app_config: BasicMemoryConfig):
|
|
68
|
+
# Get database session
|
|
69
|
+
_, session_maker = await db.get_or_create_db(
|
|
70
|
+
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
71
|
+
)
|
|
72
|
+
logger.info("Migrating legacy projects...")
|
|
73
|
+
project_repository = ProjectRepository(session_maker)
|
|
74
|
+
|
|
75
|
+
# For each project in config.json, check if it has a .basic-memory dir
|
|
76
|
+
for project_name, project_path in app_config.projects.items():
|
|
77
|
+
legacy_dir = Path(project_path) / ".basic-memory"
|
|
78
|
+
if not legacy_dir.exists():
|
|
79
|
+
continue
|
|
80
|
+
logger.info(f"Detected legacy project directory: {legacy_dir}")
|
|
81
|
+
project = await project_repository.get_by_name(project_name)
|
|
82
|
+
if not project: # pragma: no cover
|
|
83
|
+
logger.error(f"Project {project_name} not found in database, skipping migration")
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
|
|
87
|
+
await migrate_legacy_project_data(project, legacy_dir)
|
|
88
|
+
logger.info(f"Completed migration for project: {project_name}")
|
|
89
|
+
logger.info("Legacy projects successfully migrated")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> bool:
|
|
93
|
+
"""Check if project has legacy .basic-memory dir and migrate if needed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
project: The project to check and potentially migrate
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if migration occurred, False otherwise
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# avoid circular imports
|
|
103
|
+
from basic_memory.cli.commands.sync import get_sync_service
|
|
104
|
+
|
|
105
|
+
sync_service = await get_sync_service(project)
|
|
106
|
+
sync_dir = Path(project.path)
|
|
107
|
+
|
|
108
|
+
logger.info(f"Sync starting project: {project.name}")
|
|
109
|
+
await sync_service.sync(sync_dir, project_name=project.name)
|
|
110
|
+
logger.info(f"Sync completed successfully for project: {project.name}")
|
|
111
|
+
|
|
112
|
+
# After successful sync, remove the legacy directory
|
|
113
|
+
try:
|
|
114
|
+
logger.info(f"Removing legacy directory: {legacy_dir}")
|
|
115
|
+
shutil.rmtree(legacy_dir)
|
|
116
|
+
return True
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Error removing legacy directory: {e}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
37
122
|
async def initialize_file_sync(
|
|
38
|
-
app_config:
|
|
39
|
-
)
|
|
40
|
-
"""Initialize file synchronization services.
|
|
123
|
+
app_config: BasicMemoryConfig,
|
|
124
|
+
):
|
|
125
|
+
"""Initialize file synchronization services. This function starts the watch service and does not return
|
|
41
126
|
|
|
42
127
|
Args:
|
|
43
128
|
app_config: The Basic Memory project configuration
|
|
44
129
|
|
|
45
130
|
Returns:
|
|
46
|
-
|
|
47
|
-
or (None, None, None) if sync is disabled
|
|
131
|
+
The watch service task that's monitoring file changes
|
|
48
132
|
"""
|
|
49
|
-
# Load app configuration
|
|
50
|
-
# Import here to avoid circular imports
|
|
51
|
-
from basic_memory.cli.commands.sync import get_sync_service
|
|
52
133
|
|
|
53
|
-
#
|
|
54
|
-
|
|
134
|
+
# delay import
|
|
135
|
+
from basic_memory.sync import WatchService
|
|
136
|
+
|
|
137
|
+
# Load app configuration
|
|
138
|
+
_, session_maker = await db.get_or_create_db(
|
|
139
|
+
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
140
|
+
)
|
|
141
|
+
project_repository = ProjectRepository(session_maker)
|
|
55
142
|
|
|
56
143
|
# Initialize watch service
|
|
57
144
|
watch_service = WatchService(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
config=app_config,
|
|
145
|
+
app_config=app_config,
|
|
146
|
+
project_repository=project_repository,
|
|
61
147
|
quiet=True,
|
|
62
148
|
)
|
|
63
149
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
150
|
+
# Get active projects
|
|
151
|
+
active_projects = await project_repository.get_active_projects()
|
|
152
|
+
|
|
153
|
+
# First, sync all projects sequentially
|
|
154
|
+
for project in active_projects:
|
|
155
|
+
# avoid circular imports
|
|
156
|
+
from basic_memory.cli.commands.sync import get_sync_service
|
|
157
|
+
|
|
158
|
+
logger.info(f"Starting sync for project: {project.name}")
|
|
159
|
+
sync_service = await get_sync_service(project)
|
|
160
|
+
sync_dir = Path(project.path)
|
|
69
161
|
|
|
70
|
-
|
|
71
|
-
|
|
162
|
+
try:
|
|
163
|
+
await sync_service.sync(sync_dir, project_name=project.name)
|
|
164
|
+
logger.info(f"Sync completed successfully for project: {project.name}")
|
|
72
165
|
|
|
73
|
-
|
|
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")
|
|
171
|
+
except Exception as e: # pragma: no cover
|
|
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))
|
|
177
|
+
# Continue with other projects even if one fails
|
|
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
|
+
|
|
189
|
+
# Then start the watch service in the background
|
|
190
|
+
logger.info("Starting watch service for all projects")
|
|
191
|
+
# run the watch service
|
|
192
|
+
try:
|
|
74
193
|
await watch_service.run()
|
|
194
|
+
logger.info("Watch service started")
|
|
195
|
+
except Exception as e: # pragma: no cover
|
|
196
|
+
logger.error(f"Error starting watch service: {e}")
|
|
75
197
|
|
|
76
|
-
|
|
77
|
-
logger.info("Watch service started")
|
|
78
|
-
return watch_task
|
|
198
|
+
return None
|
|
79
199
|
|
|
80
200
|
|
|
81
201
|
async def initialize_app(
|
|
82
|
-
app_config:
|
|
83
|
-
)
|
|
202
|
+
app_config: BasicMemoryConfig,
|
|
203
|
+
):
|
|
84
204
|
"""Initialize the Basic Memory application.
|
|
85
205
|
|
|
86
|
-
This function handles all initialization steps
|
|
87
|
-
For long running commands like mcp, a
|
|
206
|
+
This function handles all initialization steps:
|
|
88
207
|
- Running database migrations
|
|
208
|
+
- Reconciling projects from config.json with projects table
|
|
89
209
|
- Setting up file synchronization
|
|
210
|
+
- Starting background migration for legacy project data
|
|
90
211
|
|
|
91
212
|
Args:
|
|
92
213
|
app_config: The Basic Memory project configuration
|
|
93
214
|
"""
|
|
215
|
+
logger.info("Initializing app...")
|
|
94
216
|
# Initialize database first
|
|
95
217
|
await initialize_database(app_config)
|
|
96
218
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
logger.info(
|
|
100
|
-
f"Update permalinks on move enabled: {basic_memory_config.update_permalinks_on_move}"
|
|
101
|
-
)
|
|
102
|
-
if not basic_memory_config.sync_changes: # pragma: no cover
|
|
103
|
-
logger.info("Sync changes disabled. Skipping watch service.")
|
|
104
|
-
return
|
|
219
|
+
# Reconcile projects from config.json with projects table
|
|
220
|
+
await reconcile_projects_with_config(app_config)
|
|
105
221
|
|
|
106
|
-
#
|
|
107
|
-
|
|
222
|
+
# Start background migration for legacy project data (non-blocking)
|
|
223
|
+
from basic_memory.services.migration_service import migration_manager
|
|
108
224
|
|
|
225
|
+
await migration_manager.start_background_migration(app_config)
|
|
109
226
|
|
|
110
|
-
|
|
111
|
-
|
|
227
|
+
logger.info("App initialization completed (migration running in background if needed)")
|
|
228
|
+
return migration_manager
|
|
112
229
|
|
|
113
|
-
This is a wrapper for the async initialize_app function that can be
|
|
114
|
-
called from synchronous code like CLI entry points.
|
|
115
230
|
|
|
116
|
-
|
|
117
|
-
app_config: The Basic Memory project configuration
|
|
118
|
-
"""
|
|
119
|
-
try:
|
|
120
|
-
asyncio.run(initialize_app(app_config))
|
|
121
|
-
except Exception as e:
|
|
122
|
-
logger.error(f"Error during initialization: {e}")
|
|
123
|
-
# Continue execution even if initialization fails
|
|
124
|
-
# The command might still work, or will fail with a
|
|
125
|
-
# more specific error message
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def ensure_initialize_database(app_config: ProjectConfig) -> None:
|
|
231
|
+
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
|
129
232
|
"""Ensure initialization runs in a synchronous context.
|
|
130
233
|
|
|
131
|
-
This is a wrapper for the async
|
|
234
|
+
This is a wrapper for the async initialize_app function that can be
|
|
132
235
|
called from synchronous code like CLI entry points.
|
|
133
236
|
|
|
134
237
|
Args:
|
|
135
238
|
app_config: The Basic Memory project configuration
|
|
136
239
|
"""
|
|
137
240
|
try:
|
|
138
|
-
asyncio.run(
|
|
139
|
-
|
|
140
|
-
|
|
241
|
+
result = asyncio.run(initialize_app(app_config))
|
|
242
|
+
logger.info(f"Initialization completed successfully: result={result}")
|
|
243
|
+
except Exception as e: # pragma: no cover
|
|
244
|
+
logger.exception(f"Error during initialization: {e}")
|
|
141
245
|
# Continue execution even if initialization fails
|
|
142
246
|
# The command might still work, or will fail with a
|
|
143
247
|
# more specific error message
|
|
@@ -15,10 +15,10 @@ class LinkResolver:
|
|
|
15
15
|
|
|
16
16
|
Uses a combination of exact matching and search-based resolution:
|
|
17
17
|
1. Try exact permalink match (fastest)
|
|
18
|
-
2. Try
|
|
19
|
-
3. Try exact
|
|
20
|
-
4.
|
|
21
|
-
5.
|
|
18
|
+
2. Try exact title match
|
|
19
|
+
3. Try exact file path match
|
|
20
|
+
4. Try file path with .md extension (for folder/title patterns)
|
|
21
|
+
5. Fall back to search for fuzzy matching
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
24
|
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
|
|
@@ -26,9 +26,17 @@ class LinkResolver:
|
|
|
26
26
|
self.entity_repository = entity_repository
|
|
27
27
|
self.search_service = search_service
|
|
28
28
|
|
|
29
|
-
async def resolve_link(
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
"""
|
|
39
|
+
logger.trace(f"Resolving link: {link_text}")
|
|
32
40
|
|
|
33
41
|
# Clean link text and extract any alias
|
|
34
42
|
clean_text, alias = self._normalize_link_text(link_text)
|
|
@@ -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
|
|
@@ -52,17 +61,28 @@ class LinkResolver:
|
|
|
52
61
|
logger.debug(f"Found entity with path: {found_path.file_path}")
|
|
53
62
|
return found_path
|
|
54
63
|
|
|
55
|
-
#
|
|
64
|
+
# 4. Try file path with .md extension if not already present
|
|
65
|
+
if not clean_text.endswith(".md") and "/" in clean_text:
|
|
66
|
+
file_path_with_md = f"{clean_text}.md"
|
|
67
|
+
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
|
|
68
|
+
if found_path_md:
|
|
69
|
+
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
|
70
|
+
return found_path_md
|
|
71
|
+
|
|
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)
|
|
56
77
|
if use_search and "*" not in clean_text:
|
|
57
|
-
# 3. Fall back to search for fuzzy matching on title
|
|
58
78
|
results = await self.search_service.search(
|
|
59
|
-
query=SearchQuery(
|
|
79
|
+
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
|
|
60
80
|
)
|
|
61
81
|
|
|
62
82
|
if results:
|
|
63
83
|
# Look for best match
|
|
64
84
|
best_match = min(results, key=lambda x: x.score) # pyright: ignore
|
|
65
|
-
logger.
|
|
85
|
+
logger.trace(
|
|
66
86
|
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
|
67
87
|
)
|
|
68
88
|
if best_match.permalink:
|
|
@@ -93,5 +113,8 @@ class LinkResolver:
|
|
|
93
113
|
text, alias = text.split("|", 1)
|
|
94
114
|
text = text.strip()
|
|
95
115
|
alias = alias.strip()
|
|
116
|
+
else:
|
|
117
|
+
# Strip whitespace from text even if no alias
|
|
118
|
+
text = text.strip()
|
|
96
119
|
|
|
97
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()
|