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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Service for search operations."""
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
from typing import List, Optional, Set
|
|
5
6
|
|
|
@@ -66,7 +67,7 @@ class SearchService:
|
|
|
66
67
|
logger.debug("no criteria passed to query")
|
|
67
68
|
return []
|
|
68
69
|
|
|
69
|
-
logger.
|
|
70
|
+
logger.trace(f"Searching with query: {query}")
|
|
70
71
|
|
|
71
72
|
after_date = (
|
|
72
73
|
(
|
|
@@ -85,7 +86,7 @@ class SearchService:
|
|
|
85
86
|
permalink_match=query.permalink_match,
|
|
86
87
|
title=query.title,
|
|
87
88
|
types=query.types,
|
|
88
|
-
|
|
89
|
+
search_item_types=query.entity_types,
|
|
89
90
|
after_date=after_date,
|
|
90
91
|
limit=limit,
|
|
91
92
|
offset=offset,
|
|
@@ -117,6 +118,38 @@ class SearchService:
|
|
|
117
118
|
|
|
118
119
|
return variants
|
|
119
120
|
|
|
121
|
+
def _extract_entity_tags(self, entity: Entity) -> List[str]:
|
|
122
|
+
"""Extract tags from entity metadata for search indexing.
|
|
123
|
+
|
|
124
|
+
Handles multiple tag formats:
|
|
125
|
+
- List format: ["tag1", "tag2"]
|
|
126
|
+
- String format: "['tag1', 'tag2']" or "[tag1, tag2]"
|
|
127
|
+
- Empty: [] or "[]"
|
|
128
|
+
|
|
129
|
+
Returns a list of tag strings for search indexing.
|
|
130
|
+
"""
|
|
131
|
+
if not entity.entity_metadata or "tags" not in entity.entity_metadata:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
tags = entity.entity_metadata["tags"]
|
|
135
|
+
|
|
136
|
+
# Handle list format (preferred)
|
|
137
|
+
if isinstance(tags, list):
|
|
138
|
+
return [str(tag) for tag in tags if tag]
|
|
139
|
+
|
|
140
|
+
# Handle string format (legacy)
|
|
141
|
+
if isinstance(tags, str):
|
|
142
|
+
try:
|
|
143
|
+
# Parse string representation of list
|
|
144
|
+
parsed_tags = ast.literal_eval(tags)
|
|
145
|
+
if isinstance(parsed_tags, list):
|
|
146
|
+
return [str(tag) for tag in parsed_tags if tag]
|
|
147
|
+
except (ValueError, SyntaxError):
|
|
148
|
+
# If parsing fails, treat as single tag
|
|
149
|
+
return [tags] if tags.strip() else []
|
|
150
|
+
|
|
151
|
+
return [] # pragma: no cover
|
|
152
|
+
|
|
120
153
|
async def index_entity(
|
|
121
154
|
self,
|
|
122
155
|
entity: Entity,
|
|
@@ -156,6 +189,7 @@ class SearchService:
|
|
|
156
189
|
},
|
|
157
190
|
created_at=entity.created_at,
|
|
158
191
|
updated_at=entity.updated_at,
|
|
192
|
+
project_id=entity.project_id,
|
|
159
193
|
)
|
|
160
194
|
)
|
|
161
195
|
|
|
@@ -169,16 +203,20 @@ class SearchService:
|
|
|
169
203
|
1. Entities
|
|
170
204
|
- permalink: direct from entity (e.g., "specs/search")
|
|
171
205
|
- file_path: physical file location
|
|
206
|
+
- project_id: project context for isolation
|
|
172
207
|
|
|
173
208
|
2. Observations
|
|
174
209
|
- permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
|
|
175
210
|
- file_path: parent entity's file (where observation is defined)
|
|
211
|
+
- project_id: inherited from parent entity
|
|
176
212
|
|
|
177
213
|
3. Relations (only index outgoing relations defined in this file)
|
|
178
214
|
- permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
|
|
179
215
|
- file_path: source entity's file (where relation is defined)
|
|
216
|
+
- project_id: inherited from source entity
|
|
180
217
|
|
|
181
218
|
Each type gets its own row in the search index with appropriate metadata.
|
|
219
|
+
The project_id is automatically added by the repository when indexing.
|
|
182
220
|
"""
|
|
183
221
|
|
|
184
222
|
content_stems = []
|
|
@@ -196,6 +234,11 @@ class SearchService:
|
|
|
196
234
|
|
|
197
235
|
content_stems.extend(self._generate_variants(entity.file_path))
|
|
198
236
|
|
|
237
|
+
# Add entity tags from frontmatter to search content
|
|
238
|
+
entity_tags = self._extract_entity_tags(entity)
|
|
239
|
+
if entity_tags:
|
|
240
|
+
content_stems.extend(entity_tags)
|
|
241
|
+
|
|
199
242
|
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
|
|
200
243
|
|
|
201
244
|
# Index entity
|
|
@@ -214,6 +257,7 @@ class SearchService:
|
|
|
214
257
|
},
|
|
215
258
|
created_at=entity.created_at,
|
|
216
259
|
updated_at=entity.updated_at,
|
|
260
|
+
project_id=entity.project_id,
|
|
217
261
|
)
|
|
218
262
|
)
|
|
219
263
|
|
|
@@ -239,6 +283,7 @@ class SearchService:
|
|
|
239
283
|
},
|
|
240
284
|
created_at=entity.created_at,
|
|
241
285
|
updated_at=entity.updated_at,
|
|
286
|
+
project_id=entity.project_id,
|
|
242
287
|
)
|
|
243
288
|
)
|
|
244
289
|
|
|
@@ -268,6 +313,7 @@ class SearchService:
|
|
|
268
313
|
relation_type=rel.relation_type,
|
|
269
314
|
created_at=entity.created_at,
|
|
270
315
|
updated_at=entity.updated_at,
|
|
316
|
+
project_id=entity.project_id,
|
|
271
317
|
)
|
|
272
318
|
)
|
|
273
319
|
|
|
@@ -278,3 +324,32 @@ class SearchService:
|
|
|
278
324
|
async def delete_by_entity_id(self, entity_id: int):
|
|
279
325
|
"""Delete an item from the search index."""
|
|
280
326
|
await self.repository.delete_by_entity_id(entity_id)
|
|
327
|
+
|
|
328
|
+
async def handle_delete(self, entity: Entity):
|
|
329
|
+
"""Handle complete entity deletion from search index including observations and relations.
|
|
330
|
+
|
|
331
|
+
This replicates the logic from sync_service.handle_delete() to properly clean up
|
|
332
|
+
all search index entries for an entity and its related data.
|
|
333
|
+
"""
|
|
334
|
+
logger.debug(
|
|
335
|
+
f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
|
|
336
|
+
f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Clean up search index - same logic as sync_service.handle_delete()
|
|
340
|
+
permalinks = (
|
|
341
|
+
[entity.permalink]
|
|
342
|
+
+ [o.permalink for o in entity.observations]
|
|
343
|
+
+ [r.permalink for r in entity.outgoing_relations]
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
logger.debug(
|
|
347
|
+
f"Deleting search index entries for entity_id={entity.id}, "
|
|
348
|
+
f"index_entries={len(permalinks)}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
for permalink in permalinks:
|
|
352
|
+
if permalink:
|
|
353
|
+
await self.delete_by_permalink(permalink)
|
|
354
|
+
else:
|
|
355
|
+
await self.delete_by_entity_id(entity.id)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Simple sync status tracking service."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyncStatus(Enum):
|
|
9
|
+
"""Status of sync operations."""
|
|
10
|
+
|
|
11
|
+
IDLE = "idle"
|
|
12
|
+
SCANNING = "scanning"
|
|
13
|
+
SYNCING = "syncing"
|
|
14
|
+
COMPLETED = "completed"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
WATCHING = "watching"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProjectSyncStatus:
|
|
21
|
+
"""Sync status for a single project."""
|
|
22
|
+
|
|
23
|
+
project_name: str
|
|
24
|
+
status: SyncStatus
|
|
25
|
+
message: str = ""
|
|
26
|
+
files_total: int = 0
|
|
27
|
+
files_processed: int = 0
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncStatusTracker:
|
|
32
|
+
"""Global tracker for all sync operations."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self._project_statuses: Dict[str, ProjectSyncStatus] = {}
|
|
36
|
+
self._global_status: SyncStatus = SyncStatus.IDLE
|
|
37
|
+
|
|
38
|
+
def start_project_sync(self, project_name: str, files_total: int = 0) -> None:
|
|
39
|
+
"""Start tracking sync for a project."""
|
|
40
|
+
self._project_statuses[project_name] = ProjectSyncStatus(
|
|
41
|
+
project_name=project_name,
|
|
42
|
+
status=SyncStatus.SCANNING,
|
|
43
|
+
message="Scanning files",
|
|
44
|
+
files_total=files_total,
|
|
45
|
+
files_processed=0,
|
|
46
|
+
)
|
|
47
|
+
self._update_global_status()
|
|
48
|
+
|
|
49
|
+
def update_project_progress( # pragma: no cover
|
|
50
|
+
self,
|
|
51
|
+
project_name: str,
|
|
52
|
+
status: SyncStatus,
|
|
53
|
+
message: str = "",
|
|
54
|
+
files_processed: int = 0,
|
|
55
|
+
files_total: Optional[int] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Update progress for a project."""
|
|
58
|
+
if project_name not in self._project_statuses: # pragma: no cover
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
project_status = self._project_statuses[project_name]
|
|
62
|
+
project_status.status = status
|
|
63
|
+
project_status.message = message
|
|
64
|
+
project_status.files_processed = files_processed
|
|
65
|
+
|
|
66
|
+
if files_total is not None:
|
|
67
|
+
project_status.files_total = files_total
|
|
68
|
+
|
|
69
|
+
self._update_global_status()
|
|
70
|
+
|
|
71
|
+
def complete_project_sync(self, project_name: str) -> None:
|
|
72
|
+
"""Mark project sync as completed."""
|
|
73
|
+
if project_name in self._project_statuses:
|
|
74
|
+
self._project_statuses[project_name].status = SyncStatus.COMPLETED
|
|
75
|
+
self._project_statuses[project_name].message = "Sync completed"
|
|
76
|
+
self._update_global_status()
|
|
77
|
+
|
|
78
|
+
def fail_project_sync(self, project_name: str, error: str) -> None:
|
|
79
|
+
"""Mark project sync as failed."""
|
|
80
|
+
if project_name in self._project_statuses:
|
|
81
|
+
self._project_statuses[project_name].status = SyncStatus.FAILED
|
|
82
|
+
self._project_statuses[project_name].error = error
|
|
83
|
+
self._update_global_status()
|
|
84
|
+
|
|
85
|
+
def start_project_watch(self, project_name: str) -> None:
|
|
86
|
+
"""Mark project as watching for changes (steady state after sync)."""
|
|
87
|
+
if project_name in self._project_statuses:
|
|
88
|
+
self._project_statuses[project_name].status = SyncStatus.WATCHING
|
|
89
|
+
self._project_statuses[project_name].message = "Watching for changes"
|
|
90
|
+
self._update_global_status()
|
|
91
|
+
else:
|
|
92
|
+
# Create new status if project isn't tracked yet
|
|
93
|
+
self._project_statuses[project_name] = ProjectSyncStatus(
|
|
94
|
+
project_name=project_name,
|
|
95
|
+
status=SyncStatus.WATCHING,
|
|
96
|
+
message="Watching for changes",
|
|
97
|
+
files_total=0,
|
|
98
|
+
files_processed=0,
|
|
99
|
+
)
|
|
100
|
+
self._update_global_status()
|
|
101
|
+
|
|
102
|
+
def _update_global_status(self) -> None:
|
|
103
|
+
"""Update global status based on project statuses."""
|
|
104
|
+
if not self._project_statuses: # pragma: no cover
|
|
105
|
+
self._global_status = SyncStatus.IDLE
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
statuses = [p.status for p in self._project_statuses.values()]
|
|
109
|
+
|
|
110
|
+
if any(s == SyncStatus.FAILED for s in statuses):
|
|
111
|
+
self._global_status = SyncStatus.FAILED
|
|
112
|
+
elif any(s in (SyncStatus.SCANNING, SyncStatus.SYNCING) for s in statuses):
|
|
113
|
+
self._global_status = SyncStatus.SYNCING
|
|
114
|
+
elif all(s in (SyncStatus.COMPLETED, SyncStatus.WATCHING) for s in statuses):
|
|
115
|
+
self._global_status = SyncStatus.COMPLETED
|
|
116
|
+
else:
|
|
117
|
+
self._global_status = SyncStatus.SYNCING
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def global_status(self) -> SyncStatus:
|
|
121
|
+
"""Get overall sync status."""
|
|
122
|
+
return self._global_status
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_syncing(self) -> bool:
|
|
126
|
+
"""Check if any sync operation is in progress."""
|
|
127
|
+
return self._global_status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_ready(self) -> bool: # pragma: no cover
|
|
131
|
+
"""Check if system is ready (no sync in progress)."""
|
|
132
|
+
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
|
|
133
|
+
|
|
134
|
+
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
|
|
135
|
+
"""Get status for a specific project."""
|
|
136
|
+
return self._project_statuses.get(project_name)
|
|
137
|
+
|
|
138
|
+
def get_all_projects(self) -> Dict[str, ProjectSyncStatus]:
|
|
139
|
+
"""Get all project statuses."""
|
|
140
|
+
return self._project_statuses.copy()
|
|
141
|
+
|
|
142
|
+
def get_summary(self) -> str: # pragma: no cover
|
|
143
|
+
"""Get a user-friendly summary of sync status."""
|
|
144
|
+
if self._global_status == SyncStatus.IDLE:
|
|
145
|
+
return "✅ System ready"
|
|
146
|
+
elif self._global_status == SyncStatus.COMPLETED:
|
|
147
|
+
return "✅ All projects synced successfully"
|
|
148
|
+
elif self._global_status == SyncStatus.FAILED:
|
|
149
|
+
failed_projects = [
|
|
150
|
+
p.project_name
|
|
151
|
+
for p in self._project_statuses.values()
|
|
152
|
+
if p.status == SyncStatus.FAILED
|
|
153
|
+
]
|
|
154
|
+
return f"❌ Sync failed for: {', '.join(failed_projects)}"
|
|
155
|
+
else:
|
|
156
|
+
active_projects = [
|
|
157
|
+
p.project_name
|
|
158
|
+
for p in self._project_statuses.values()
|
|
159
|
+
if p.status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
|
|
160
|
+
]
|
|
161
|
+
total_files = sum(p.files_total for p in self._project_statuses.values())
|
|
162
|
+
processed_files = sum(p.files_processed for p in self._project_statuses.values())
|
|
163
|
+
|
|
164
|
+
if total_files > 0:
|
|
165
|
+
progress_pct = (processed_files / total_files) * 100
|
|
166
|
+
return f"🔄 Syncing {len(active_projects)} projects ({processed_files}/{total_files} files, {progress_pct:.0f}%)"
|
|
167
|
+
else:
|
|
168
|
+
return f"🔄 Syncing {len(active_projects)} projects"
|
|
169
|
+
|
|
170
|
+
def clear_completed(self) -> None:
|
|
171
|
+
"""Remove completed project statuses to clean up memory."""
|
|
172
|
+
self._project_statuses = {
|
|
173
|
+
name: status
|
|
174
|
+
for name, status in self._project_statuses.items()
|
|
175
|
+
if status.status != SyncStatus.COMPLETED
|
|
176
|
+
}
|
|
177
|
+
self._update_global_status()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Global sync status tracker instance
|
|
181
|
+
sync_status_tracker = SyncStatusTracker()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
from basic_memory.config import config as project_config
|
|
6
|
+
from basic_memory.sync import SyncService, WatchService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def sync_and_watch(
|
|
10
|
+
sync_service: SyncService, watch_service: WatchService
|
|
11
|
+
): # pragma: no cover
|
|
12
|
+
"""Run sync and watch service."""
|
|
13
|
+
|
|
14
|
+
logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
|
|
15
|
+
# full sync
|
|
16
|
+
await sync_service.sync(project_config.home)
|
|
17
|
+
|
|
18
|
+
# watch changes
|
|
19
|
+
await watch_service.run()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def create_background_sync_task(
|
|
23
|
+
sync_service: SyncService, watch_service: WatchService
|
|
24
|
+
): # pragma: no cover
|
|
25
|
+
return asyncio.create_task(sync_and_watch(sync_service, watch_service))
|
|
@@ -10,13 +10,14 @@ from typing import Dict, Optional, Set, Tuple
|
|
|
10
10
|
from loguru import logger
|
|
11
11
|
from sqlalchemy.exc import IntegrityError
|
|
12
12
|
|
|
13
|
-
from basic_memory.config import
|
|
13
|
+
from basic_memory.config import BasicMemoryConfig
|
|
14
14
|
from basic_memory.file_utils import has_frontmatter
|
|
15
15
|
from basic_memory.markdown import EntityParser
|
|
16
16
|
from basic_memory.models import Entity
|
|
17
17
|
from basic_memory.repository import EntityRepository, RelationRepository
|
|
18
18
|
from basic_memory.services import EntityService, FileService
|
|
19
19
|
from basic_memory.services.search_service import SearchService
|
|
20
|
+
from basic_memory.services.sync_status_service import sync_status_tracker, SyncStatus
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
@dataclass
|
|
@@ -64,7 +65,7 @@ class SyncService:
|
|
|
64
65
|
|
|
65
66
|
def __init__(
|
|
66
67
|
self,
|
|
67
|
-
|
|
68
|
+
app_config: BasicMemoryConfig,
|
|
68
69
|
entity_service: EntityService,
|
|
69
70
|
entity_parser: EntityParser,
|
|
70
71
|
entity_repository: EntityRepository,
|
|
@@ -72,7 +73,7 @@ class SyncService:
|
|
|
72
73
|
search_service: SearchService,
|
|
73
74
|
file_service: FileService,
|
|
74
75
|
):
|
|
75
|
-
self.
|
|
76
|
+
self.app_config = app_config
|
|
76
77
|
self.entity_service = entity_service
|
|
77
78
|
self.entity_parser = entity_parser
|
|
78
79
|
self.entity_repository = entity_repository
|
|
@@ -80,23 +81,38 @@ class SyncService:
|
|
|
80
81
|
self.search_service = search_service
|
|
81
82
|
self.file_service = file_service
|
|
82
83
|
|
|
83
|
-
async def sync(self, directory: Path) -> SyncReport:
|
|
84
|
+
async def sync(self, directory: Path, project_name: Optional[str] = None) -> SyncReport:
|
|
84
85
|
"""Sync all files with database."""
|
|
85
86
|
|
|
86
87
|
start_time = time.time()
|
|
87
88
|
logger.info(f"Sync operation started for directory: {directory}")
|
|
88
89
|
|
|
90
|
+
# Start tracking sync for this project if project name provided
|
|
91
|
+
if project_name:
|
|
92
|
+
sync_status_tracker.start_project_sync(project_name)
|
|
93
|
+
|
|
89
94
|
# initial paths from db to sync
|
|
90
95
|
# path -> checksum
|
|
91
96
|
report = await self.scan(directory)
|
|
92
97
|
|
|
93
|
-
#
|
|
98
|
+
# Update progress with file counts
|
|
99
|
+
if project_name:
|
|
100
|
+
sync_status_tracker.update_project_progress(
|
|
101
|
+
project_name=project_name,
|
|
102
|
+
status=SyncStatus.SYNCING,
|
|
103
|
+
message="Processing file changes",
|
|
104
|
+
files_total=report.total,
|
|
105
|
+
files_processed=0,
|
|
106
|
+
)
|
|
107
|
+
|
|
94
108
|
# order of sync matters to resolve relations effectively
|
|
95
109
|
logger.info(
|
|
96
110
|
f"Sync changes detected: new_files={len(report.new)}, modified_files={len(report.modified)}, "
|
|
97
111
|
+ f"deleted_files={len(report.deleted)}, moved_files={len(report.moves)}"
|
|
98
112
|
)
|
|
99
113
|
|
|
114
|
+
files_processed = 0
|
|
115
|
+
|
|
100
116
|
# sync moves first
|
|
101
117
|
for old_path, new_path in report.moves.items():
|
|
102
118
|
# in the case where a file has been deleted and replaced by another file
|
|
@@ -109,19 +125,56 @@ class SyncService:
|
|
|
109
125
|
else:
|
|
110
126
|
await self.handle_move(old_path, new_path)
|
|
111
127
|
|
|
128
|
+
files_processed += 1
|
|
129
|
+
if project_name:
|
|
130
|
+
sync_status_tracker.update_project_progress( # pragma: no cover
|
|
131
|
+
project_name=project_name,
|
|
132
|
+
status=SyncStatus.SYNCING,
|
|
133
|
+
message="Processing moves",
|
|
134
|
+
files_processed=files_processed,
|
|
135
|
+
)
|
|
136
|
+
|
|
112
137
|
# deleted next
|
|
113
138
|
for path in report.deleted:
|
|
114
139
|
await self.handle_delete(path)
|
|
140
|
+
files_processed += 1
|
|
141
|
+
if project_name:
|
|
142
|
+
sync_status_tracker.update_project_progress( # pragma: no cover
|
|
143
|
+
project_name=project_name,
|
|
144
|
+
status=SyncStatus.SYNCING,
|
|
145
|
+
message="Processing deletions",
|
|
146
|
+
files_processed=files_processed,
|
|
147
|
+
)
|
|
115
148
|
|
|
116
149
|
# then new and modified
|
|
117
150
|
for path in report.new:
|
|
118
151
|
await self.sync_file(path, new=True)
|
|
152
|
+
files_processed += 1
|
|
153
|
+
if project_name:
|
|
154
|
+
sync_status_tracker.update_project_progress(
|
|
155
|
+
project_name=project_name,
|
|
156
|
+
status=SyncStatus.SYNCING,
|
|
157
|
+
message="Processing new files",
|
|
158
|
+
files_processed=files_processed,
|
|
159
|
+
)
|
|
119
160
|
|
|
120
161
|
for path in report.modified:
|
|
121
162
|
await self.sync_file(path, new=False)
|
|
163
|
+
files_processed += 1
|
|
164
|
+
if project_name:
|
|
165
|
+
sync_status_tracker.update_project_progress( # pragma: no cover
|
|
166
|
+
project_name=project_name,
|
|
167
|
+
status=SyncStatus.SYNCING,
|
|
168
|
+
message="Processing modified files",
|
|
169
|
+
files_processed=files_processed,
|
|
170
|
+
)
|
|
122
171
|
|
|
123
172
|
await self.resolve_relations()
|
|
124
173
|
|
|
174
|
+
# Mark sync as completed
|
|
175
|
+
if project_name:
|
|
176
|
+
sync_status_tracker.complete_project_sync(project_name)
|
|
177
|
+
|
|
125
178
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
126
179
|
logger.info(
|
|
127
180
|
f"Sync operation completed: directory={directory}, total_changes={report.total}, duration_ms={duration_ms}"
|
|
@@ -133,7 +186,7 @@ class SyncService:
|
|
|
133
186
|
"""Scan directory for changes compared to database state."""
|
|
134
187
|
|
|
135
188
|
db_paths = await self.get_db_file_state()
|
|
136
|
-
logger.
|
|
189
|
+
logger.info(f"Scanning directory {directory}. Found {len(db_paths)} db paths")
|
|
137
190
|
|
|
138
191
|
# Track potentially moved files by checksum
|
|
139
192
|
scan_result = await self.scan_directory(directory)
|
|
@@ -173,6 +226,7 @@ class SyncService:
|
|
|
173
226
|
# deleted
|
|
174
227
|
else:
|
|
175
228
|
report.deleted.add(db_path)
|
|
229
|
+
logger.info(f"Completed scan for directory {directory}, found {report.total} changes.")
|
|
176
230
|
return report
|
|
177
231
|
|
|
178
232
|
async def get_db_file_state(self) -> Dict[str, str]:
|
|
@@ -218,7 +272,7 @@ class SyncService:
|
|
|
218
272
|
return entity, checksum
|
|
219
273
|
|
|
220
274
|
except Exception as e: # pragma: no cover
|
|
221
|
-
logger.
|
|
275
|
+
logger.error(f"Failed to sync file: path={path}, error={str(e)}")
|
|
222
276
|
return None, None
|
|
223
277
|
|
|
224
278
|
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
|
|
@@ -310,18 +364,43 @@ class SyncService:
|
|
|
310
364
|
content_type = self.file_service.content_type(path)
|
|
311
365
|
|
|
312
366
|
file_path = Path(path)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
367
|
+
try:
|
|
368
|
+
entity = await self.entity_repository.add(
|
|
369
|
+
Entity(
|
|
370
|
+
entity_type="file",
|
|
371
|
+
file_path=path,
|
|
372
|
+
checksum=checksum,
|
|
373
|
+
title=file_path.name,
|
|
374
|
+
created_at=created,
|
|
375
|
+
updated_at=modified,
|
|
376
|
+
content_type=content_type,
|
|
377
|
+
)
|
|
322
378
|
)
|
|
323
|
-
|
|
324
|
-
|
|
379
|
+
return entity, checksum
|
|
380
|
+
except IntegrityError as e:
|
|
381
|
+
# Handle race condition where entity was created by another process
|
|
382
|
+
if "UNIQUE constraint failed: entity.file_path" in str(e):
|
|
383
|
+
logger.info(
|
|
384
|
+
f"Entity already exists for file_path={path}, updating instead of creating"
|
|
385
|
+
)
|
|
386
|
+
# Treat as update instead of create
|
|
387
|
+
entity = await self.entity_repository.get_by_file_path(path)
|
|
388
|
+
if entity is None: # pragma: no cover
|
|
389
|
+
logger.error(f"Entity not found after constraint violation, path={path}")
|
|
390
|
+
raise ValueError(f"Entity not found after constraint violation: {path}")
|
|
391
|
+
|
|
392
|
+
updated = await self.entity_repository.update(
|
|
393
|
+
entity.id, {"file_path": path, "checksum": checksum}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if updated is None: # pragma: no cover
|
|
397
|
+
logger.error(f"Failed to update entity, entity_id={entity.id}, path={path}")
|
|
398
|
+
raise ValueError(f"Failed to update entity with ID {entity.id}")
|
|
399
|
+
|
|
400
|
+
return updated, checksum
|
|
401
|
+
else:
|
|
402
|
+
# Re-raise if it's a different integrity error
|
|
403
|
+
raise
|
|
325
404
|
else:
|
|
326
405
|
entity = await self.entity_repository.get_by_file_path(path)
|
|
327
406
|
if entity is None: # pragma: no cover
|
|
@@ -378,7 +457,9 @@ class SyncService:
|
|
|
378
457
|
updates = {"file_path": new_path}
|
|
379
458
|
|
|
380
459
|
# If configured, also update permalink to match new path
|
|
381
|
-
if self.
|
|
460
|
+
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(
|
|
461
|
+
new_path
|
|
462
|
+
):
|
|
382
463
|
# generate new permalink value
|
|
383
464
|
new_permalink = await self.entity_service.resolve_permalink(new_path)
|
|
384
465
|
|
|
@@ -426,7 +507,7 @@ class SyncService:
|
|
|
426
507
|
logger.info("Resolving forward references", count=len(unresolved_relations))
|
|
427
508
|
|
|
428
509
|
for relation in unresolved_relations:
|
|
429
|
-
logger.
|
|
510
|
+
logger.trace(
|
|
430
511
|
"Attempting to resolve relation "
|
|
431
512
|
f"relation_id={relation.id} "
|
|
432
513
|
f"from_id={relation.from_id} "
|
|
@@ -494,7 +575,7 @@ class SyncService:
|
|
|
494
575
|
result.files[rel_path] = checksum
|
|
495
576
|
result.checksums[checksum] = rel_path
|
|
496
577
|
|
|
497
|
-
logger.
|
|
578
|
+
logger.trace(f"Found file, path={rel_path}, checksum={checksum}")
|
|
498
579
|
|
|
499
580
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
500
581
|
logger.debug(
|