basic-memory 0.17.1__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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,510 @@
1
+ """Watch service for Basic Memory."""
2
+
3
+ import asyncio
4
+ import os
5
+ from collections import defaultdict
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from basic_memory.sync.sync_service import SyncService
12
+
13
+ from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
14
+ from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
15
+ from basic_memory.models import Project
16
+ from basic_memory.repository import ProjectRepository
17
+ from loguru import logger
18
+ from pydantic import BaseModel
19
+ from rich.console import Console
20
+ from watchfiles import awatch
21
+ from watchfiles.main import FileChange, Change
22
+ import time
23
+
24
+
25
+ class WatchEvent(BaseModel):
26
+ timestamp: datetime
27
+ path: str
28
+ action: str # new, delete, etc
29
+ status: str # success, error
30
+ checksum: Optional[str]
31
+ error: Optional[str] = None
32
+
33
+
34
+ class WatchServiceState(BaseModel):
35
+ # Service status
36
+ running: bool = False
37
+ start_time: datetime = datetime.now() # Use directly with Pydantic model
38
+ pid: int = os.getpid() # Use directly with Pydantic model
39
+
40
+ # Stats
41
+ error_count: int = 0
42
+ last_error: Optional[datetime] = None
43
+ last_scan: Optional[datetime] = None
44
+
45
+ # File counts
46
+ synced_files: int = 0
47
+
48
+ # Recent activity
49
+ recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
50
+
51
+ def add_event(
52
+ self,
53
+ path: str,
54
+ action: str,
55
+ status: str,
56
+ checksum: Optional[str] = None,
57
+ error: Optional[str] = None,
58
+ ) -> WatchEvent:
59
+ event = WatchEvent(
60
+ timestamp=datetime.now(),
61
+ path=path,
62
+ action=action,
63
+ status=status,
64
+ checksum=checksum,
65
+ error=error,
66
+ )
67
+ self.recent_events.insert(0, event)
68
+ self.recent_events = self.recent_events[:100] # Keep last 100
69
+ return event
70
+
71
+ def record_error(self, error: str):
72
+ self.error_count += 1
73
+ self.add_event(path="", action="sync", status="error", error=error)
74
+ self.last_error = datetime.now()
75
+
76
+
77
+ # Type alias for sync service factory function
78
+ SyncServiceFactory = Callable[[Project], Awaitable["SyncService"]]
79
+
80
+
81
+ class WatchService:
82
+ def __init__(
83
+ self,
84
+ app_config: BasicMemoryConfig,
85
+ project_repository: ProjectRepository,
86
+ quiet: bool = False,
87
+ sync_service_factory: Optional[SyncServiceFactory] = None,
88
+ ):
89
+ self.app_config = app_config
90
+ self.project_repository = project_repository
91
+ self.state = WatchServiceState()
92
+ self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
93
+ self.status_path.parent.mkdir(parents=True, exist_ok=True)
94
+ self._ignore_patterns_cache: dict[Path, Set[str]] = {}
95
+ self._sync_service_factory = sync_service_factory
96
+
97
+ # quiet mode for mcp so it doesn't mess up stdout
98
+ self.console = Console(quiet=quiet)
99
+
100
+ async def _get_sync_service(self, project: Project) -> "SyncService":
101
+ """Get sync service for a project, using factory if provided."""
102
+ if self._sync_service_factory:
103
+ return await self._sync_service_factory(project)
104
+ # Fall back to default factory
105
+ from basic_memory.sync.sync_service import get_sync_service
106
+
107
+ return await get_sync_service(project)
108
+
109
+ async def _schedule_restart(self, stop_event: asyncio.Event):
110
+ """Schedule a restart of the watch service after the configured interval."""
111
+ await asyncio.sleep(self.app_config.watch_project_reload_interval)
112
+ stop_event.set()
113
+
114
+ def _get_ignore_patterns(self, project_path: Path) -> Set[str]:
115
+ """Get or load ignore patterns for a project path."""
116
+ if project_path not in self._ignore_patterns_cache:
117
+ self._ignore_patterns_cache[project_path] = load_gitignore_patterns(project_path)
118
+ return self._ignore_patterns_cache[project_path]
119
+
120
+ async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: asyncio.Event):
121
+ """Run one cycle of watching the given projects until stop_event is set."""
122
+ project_paths = [project.path for project in projects]
123
+
124
+ async for changes in awatch(
125
+ *project_paths,
126
+ debounce=self.app_config.sync_delay,
127
+ watch_filter=self.filter_changes,
128
+ recursive=True,
129
+ stop_event=stop_event,
130
+ ):
131
+ # group changes by project and filter using ignore patterns
132
+ project_changes = defaultdict(list)
133
+ for change, path in changes:
134
+ for project in projects:
135
+ if self.is_project_path(project, path):
136
+ # Check if the file should be ignored based on gitignore patterns
137
+ project_path = Path(project.path)
138
+ file_path = Path(path)
139
+ ignore_patterns = self._get_ignore_patterns(project_path)
140
+
141
+ if should_ignore_path(file_path, project_path, ignore_patterns):
142
+ logger.trace(
143
+ f"Ignoring watched file change: {file_path.relative_to(project_path)}"
144
+ )
145
+ continue
146
+
147
+ project_changes[project].append((change, path))
148
+ break
149
+
150
+ # create coroutines to handle changes
151
+ change_handlers = [
152
+ self.handle_changes(project, changes) # pyright: ignore
153
+ for project, changes in project_changes.items()
154
+ ]
155
+
156
+ # process changes
157
+ await asyncio.gather(*change_handlers)
158
+
159
+ async def run(self): # pragma: no cover
160
+ """Watch for file changes and sync them"""
161
+
162
+ self.state.running = True
163
+ self.state.start_time = datetime.now()
164
+ await self.write_status()
165
+
166
+ logger.info(
167
+ "Watch service started",
168
+ f"debounce_ms={self.app_config.sync_delay}",
169
+ f"pid={os.getpid()}",
170
+ )
171
+
172
+ try:
173
+ while self.state.running:
174
+ # Clear ignore patterns cache to pick up any .gitignore changes
175
+ self._ignore_patterns_cache.clear()
176
+
177
+ # Reload projects to catch any new/removed projects
178
+ projects = await self.project_repository.get_active_projects()
179
+
180
+ project_paths = [project.path for project in projects]
181
+ logger.debug(f"Starting watch cycle for directories: {project_paths}")
182
+
183
+ # Create stop event for this watch cycle
184
+ stop_event = asyncio.Event()
185
+
186
+ # Schedule restart after configured interval to reload projects
187
+ timer_task = asyncio.create_task(self._schedule_restart(stop_event))
188
+
189
+ try:
190
+ await self._watch_projects_cycle(projects, stop_event)
191
+ except Exception as e:
192
+ logger.exception("Watch service error during cycle", error=str(e))
193
+ self.state.record_error(str(e))
194
+ await self.write_status()
195
+ # Continue to next cycle instead of exiting
196
+ await asyncio.sleep(5) # Brief pause before retry
197
+ finally:
198
+ # Cancel timer task if it's still running
199
+ if not timer_task.done():
200
+ timer_task.cancel()
201
+ try:
202
+ await timer_task
203
+ except asyncio.CancelledError:
204
+ pass
205
+
206
+ except Exception as e:
207
+ logger.exception("Watch service error", error=str(e))
208
+ self.state.record_error(str(e))
209
+ await self.write_status()
210
+ raise
211
+
212
+ finally:
213
+ logger.info(
214
+ "Watch service stopped",
215
+ f"runtime_seconds={int((datetime.now() - self.state.start_time).total_seconds())}",
216
+ )
217
+
218
+ self.state.running = False
219
+ await self.write_status()
220
+
221
+ def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
222
+ """Filter to only watch non-hidden files and directories.
223
+
224
+ Returns:
225
+ True if the file should be watched, False if it should be ignored
226
+ """
227
+
228
+ # Skip hidden directories and files
229
+ path_parts = Path(path).parts
230
+ for part in path_parts:
231
+ if part.startswith("."):
232
+ return False
233
+
234
+ # Skip temp files used in atomic operations
235
+ if path.endswith(".tmp"):
236
+ return False
237
+
238
+ return True
239
+
240
+ async def write_status(self):
241
+ """Write current state to status file"""
242
+ self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
243
+
244
+ def is_project_path(self, project: Project, path):
245
+ """
246
+ Checks if path is a subdirectory or file within a project
247
+ """
248
+ project_path = Path(project.path).resolve()
249
+ sub_path = Path(path).resolve()
250
+ return project_path in sub_path.parents
251
+
252
+ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
253
+ """Process a batch of file changes"""
254
+ # Check if project still exists in configuration before processing
255
+ # This prevents deleted projects from being recreated by background sync
256
+ from basic_memory.config import ConfigManager
257
+
258
+ config_manager = ConfigManager()
259
+ if (
260
+ project.name not in config_manager.projects
261
+ and project.permalink not in config_manager.projects
262
+ ):
263
+ logger.info(
264
+ f"Skipping sync for deleted project: {project.name}, change_count={len(changes)}"
265
+ )
266
+ return
267
+
268
+ sync_service = await self._get_sync_service(project)
269
+ file_service = sync_service.file_service
270
+
271
+ start_time = time.time()
272
+ directory = Path(project.path).resolve()
273
+ logger.info(
274
+ f"Processing project: {project.name} changes, change_count={len(changes)}, directory={directory}"
275
+ )
276
+
277
+ # Group changes by type
278
+ adds: List[str] = []
279
+ deletes: List[str] = []
280
+ modifies: List[str] = []
281
+
282
+ for change, path in changes:
283
+ # convert to relative path
284
+ relative_path = Path(path).relative_to(directory).as_posix()
285
+
286
+ # Skip .tmp files - they're temporary and shouldn't be synced
287
+ if relative_path.endswith(".tmp"):
288
+ continue
289
+
290
+ if change == Change.added:
291
+ adds.append(relative_path)
292
+ elif change == Change.deleted:
293
+ deletes.append(relative_path)
294
+ elif change == Change.modified:
295
+ modifies.append(relative_path)
296
+
297
+ logger.debug(
298
+ f"Grouped file changes, added={len(adds)}, deleted={len(deletes)}, modified={len(modifies)}"
299
+ )
300
+
301
+ # because of our atomic writes on updates, an add may be an existing file
302
+ for added_path in adds: # pragma: no cover TODO add test
303
+ entity = await sync_service.entity_repository.get_by_file_path(added_path)
304
+ if entity is not None:
305
+ logger.debug(f"Existing file will be processed as modified, path={added_path}")
306
+ adds.remove(added_path)
307
+ modifies.append(added_path)
308
+
309
+ # Track processed files to avoid duplicates
310
+ processed: Set[str] = set()
311
+
312
+ # First handle potential moves
313
+ for added_path in adds:
314
+ if added_path in processed:
315
+ continue # pragma: no cover
316
+
317
+ # Skip directories for added paths
318
+ # We don't need to process directories, only the files inside them
319
+ # This prevents errors when trying to compute checksums or read directories as files
320
+ added_full_path = directory / added_path
321
+ if not added_full_path.exists() or added_full_path.is_dir():
322
+ logger.debug("Skipping non-existent or directory path", path=added_path)
323
+ processed.add(added_path)
324
+ continue
325
+
326
+ for deleted_path in deletes:
327
+ if deleted_path in processed:
328
+ continue # pragma: no cover
329
+
330
+ # Skip directories for deleted paths (based on entity type in db)
331
+ deleted_entity = await sync_service.entity_repository.get_by_file_path(deleted_path)
332
+ if deleted_entity is None:
333
+ # If this was a directory, it wouldn't have an entity
334
+ logger.debug("Skipping unknown path for move detection", path=deleted_path)
335
+ continue
336
+
337
+ if added_path != deleted_path:
338
+ # Compare checksums to detect moves
339
+ try:
340
+ added_checksum = await file_service.compute_checksum(added_path)
341
+
342
+ if deleted_entity and deleted_entity.checksum == added_checksum:
343
+ await sync_service.handle_move(deleted_path, added_path)
344
+ self.state.add_event(
345
+ path=f"{deleted_path} -> {added_path}",
346
+ action="moved",
347
+ status="success",
348
+ )
349
+ self.console.print(f"[blue]→[/blue] {deleted_path} → {added_path}")
350
+ logger.info(f"move: {deleted_path} -> {added_path}")
351
+ processed.add(added_path)
352
+ processed.add(deleted_path)
353
+ break
354
+ except Exception as e: # pragma: no cover
355
+ logger.warning(
356
+ "Error checking for move",
357
+ f"old_path={deleted_path}",
358
+ f"new_path={added_path}",
359
+ f"error={str(e)}",
360
+ )
361
+
362
+ # Handle remaining changes - group them by type for concise output
363
+ moved_count = len([p for p in processed if p in deletes or p in adds])
364
+ delete_count = 0
365
+ add_count = 0
366
+ modify_count = 0
367
+
368
+ # Process deletes
369
+ for path in deletes:
370
+ if path not in processed:
371
+ # Check if file still exists on disk (vim atomic write edge case)
372
+ full_path = directory / path
373
+ if full_path.exists() and full_path.is_file():
374
+ # File still exists despite DELETE event - treat as modification
375
+ logger.debug(
376
+ "File exists despite DELETE event, treating as modification", path=path
377
+ )
378
+ entity, checksum = await sync_service.sync_file(path, new=False)
379
+ self.state.add_event(
380
+ path=path, action="modified", status="success", checksum=checksum
381
+ )
382
+ self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
383
+ logger.info(f"atomic write detected: {path}")
384
+ processed.add(path)
385
+ modify_count += 1
386
+ else:
387
+ # Check if this was a directory - skip if so
388
+ # (we can't tell if the deleted path was a directory since it no longer exists,
389
+ # so we check if there's an entity in the database for it)
390
+ entity = await sync_service.entity_repository.get_by_file_path(path)
391
+ if entity is None:
392
+ # No entity means this was likely a directory - skip it
393
+ logger.debug(
394
+ f"Skipping deleted path with no entity (likely directory), path={path}"
395
+ )
396
+ processed.add(path)
397
+ continue
398
+
399
+ # File truly deleted
400
+ logger.debug("Processing deleted file", path=path)
401
+ await sync_service.handle_delete(path)
402
+ self.state.add_event(path=path, action="deleted", status="success")
403
+ self.console.print(f"[red]✕[/red] {path}")
404
+ logger.info(f"deleted: {path}")
405
+ processed.add(path)
406
+ delete_count += 1
407
+
408
+ # Process adds
409
+ for path in adds:
410
+ if path not in processed:
411
+ # Skip directories - only process files
412
+ full_path = directory / path
413
+ if not full_path.exists() or full_path.is_dir():
414
+ logger.debug(
415
+ f"Skipping non-existent or directory path, path={path}"
416
+ ) # pragma: no cover
417
+ processed.add(path) # pragma: no cover
418
+ continue # pragma: no cover
419
+
420
+ logger.debug(f"Processing new file, path={path}")
421
+ entity, checksum = await sync_service.sync_file(path, new=True)
422
+ if checksum:
423
+ self.state.add_event(
424
+ path=path, action="new", status="success", checksum=checksum
425
+ )
426
+ self.console.print(f"[green]✓[/green] {path}")
427
+ logger.info(
428
+ "new file processed",
429
+ f"path={path}",
430
+ f"checksum={checksum}",
431
+ )
432
+ processed.add(path)
433
+ add_count += 1
434
+ else: # pragma: no cover
435
+ logger.warning(f"Error syncing new file, path={path}") # pragma: no cover
436
+ self.console.print(
437
+ f"[orange]?[/orange] Error syncing: {path}"
438
+ ) # pragma: no cover
439
+
440
+ # Process modifies - detect repeats
441
+ last_modified_path = None
442
+ repeat_count = 0
443
+
444
+ for path in modifies:
445
+ if path not in processed:
446
+ # Skip directories - only process files
447
+ full_path = directory / path
448
+ if not full_path.exists() or full_path.is_dir():
449
+ logger.debug("Skipping non-existent or directory path", path=path)
450
+ processed.add(path)
451
+ continue
452
+
453
+ logger.debug(f"Processing modified file: path={path}")
454
+ entity, checksum = await sync_service.sync_file(path, new=False)
455
+ self.state.add_event(
456
+ path=path, action="modified", status="success", checksum=checksum
457
+ )
458
+
459
+ # Check if this is a repeat of the last modified file
460
+ if path == last_modified_path: # pragma: no cover
461
+ repeat_count += 1 # pragma: no cover
462
+ # Only show a message for the first repeat
463
+ if repeat_count == 1: # pragma: no cover
464
+ self.console.print(
465
+ f"[yellow]...[/yellow] Repeated changes to {path}"
466
+ ) # pragma: no cover
467
+ else:
468
+ # haven't processed this file
469
+ self.console.print(f"[yellow]✎[/yellow] {path}")
470
+ logger.info(f"modified: {path}")
471
+ last_modified_path = path
472
+ repeat_count = 0
473
+ modify_count += 1
474
+
475
+ logger.debug( # pragma: no cover
476
+ "Modified file processed, "
477
+ f"path={path} "
478
+ f"entity_id={entity.id if entity else None} "
479
+ f"checksum={checksum}",
480
+ )
481
+ processed.add(path)
482
+
483
+ # Add a concise summary instead of a divider
484
+ if processed:
485
+ changes = [] # pyright: ignore
486
+ if add_count > 0:
487
+ changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
488
+ if modify_count > 0:
489
+ changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
490
+ if moved_count > 0:
491
+ changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
492
+ if delete_count > 0:
493
+ changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
494
+
495
+ if changes:
496
+ self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
497
+ logger.info(f"changes: {len(changes)}")
498
+
499
+ duration_ms = int((time.time() - start_time) * 1000)
500
+ self.state.last_scan = datetime.now()
501
+ self.state.synced_files += len(processed)
502
+
503
+ logger.info(
504
+ "File change processing completed, "
505
+ f"processed_files={len(processed)}, "
506
+ f"total_synced_files={self.state.synced_files}, "
507
+ f"duration_ms={duration_ms}"
508
+ )
509
+
510
+ await self.write_status()