basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  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 +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  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 +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -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/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,22 +1,25 @@
1
1
  """Watch service for Basic Memory."""
2
2
 
3
- import dataclasses
4
-
5
- from loguru import logger
6
- from pydantic import BaseModel
3
+ import asyncio
4
+ import os
5
+ from collections import defaultdict
7
6
  from datetime import datetime
8
7
  from pathlib import Path
9
- from typing import List, Optional
8
+ from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHECKING
10
9
 
11
- from rich.console import Console
12
- from rich.live import Live
13
- from rich.table import Table
14
- from watchfiles import awatch, Change
15
- import os
10
+ if TYPE_CHECKING:
11
+ from basic_memory.sync.sync_service import SyncService
16
12
 
17
- from basic_memory.config import ProjectConfig
18
- from basic_memory.sync.sync_service import SyncService
19
- from basic_memory.services.file_service import FileService
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, Field
19
+ from rich.console import Console
20
+ from watchfiles import awatch
21
+ from watchfiles.main import FileChange, Change
22
+ import time
20
23
 
21
24
 
22
25
  class WatchEvent(BaseModel):
@@ -31,8 +34,8 @@ class WatchEvent(BaseModel):
31
34
  class WatchServiceState(BaseModel):
32
35
  # Service status
33
36
  running: bool = False
34
- start_time: datetime = dataclasses.field(default_factory=datetime.now)
35
- pid: int = dataclasses.field(default_factory=os.getpid)
37
+ start_time: datetime = Field(default_factory=datetime.now)
38
+ pid: int = Field(default_factory=os.getpid)
36
39
 
37
40
  # Stats
38
41
  error_count: int = 0
@@ -43,7 +46,7 @@ class WatchServiceState(BaseModel):
43
46
  synced_files: int = 0
44
47
 
45
48
  # Recent activity
46
- recent_events: List[WatchEvent] = dataclasses.field(default_factory=list)
49
+ recent_events: List[WatchEvent] = Field(default_factory=list)
47
50
 
48
51
  def add_event(
49
52
  self,
@@ -71,148 +74,442 @@ class WatchServiceState(BaseModel):
71
74
  self.last_error = datetime.now()
72
75
 
73
76
 
77
+ # Type alias for sync service factory function
78
+ SyncServiceFactory = Callable[[Project], Awaitable["SyncService"]]
79
+
80
+
74
81
  class WatchService:
75
- def __init__(self, sync_service: SyncService, file_service: FileService, config: ProjectConfig):
76
- self.sync_service = sync_service
77
- self.file_service = file_service
78
- self.config = config
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
79
91
  self.state = WatchServiceState()
80
- self.status_path = config.home / ".basic-memory" / "watch-status.json"
92
+ self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
81
93
  self.status_path.parent.mkdir(parents=True, exist_ok=True)
82
- self.console = Console()
83
-
84
- def generate_table(self) -> Table:
85
- """Generate status display table"""
86
- table = Table()
87
-
88
- # Add status row
89
- table.add_column("Status", style="cyan")
90
- table.add_column("Last Scan", style="cyan")
91
- table.add_column("Files", style="cyan")
92
- table.add_column("Errors", style="red")
93
-
94
- # Add main status row
95
- table.add_row(
96
- "✓ Running" if self.state.running else "✗ Stopped",
97
- self.state.last_scan.strftime("%H:%M:%S") if self.state.last_scan else "-",
98
- str(self.state.synced_files),
99
- f"{self.state.error_count} ({self.state.last_error.strftime('%H:%M:%S') if self.state.last_error else 'none'})",
100
- )
101
-
102
- if self.state.recent_events:
103
- # Add recent events
104
- table.add_section()
105
- table.add_row("Recent Events", "", "", "")
106
-
107
- for event in self.state.recent_events[:5]: # Show last 5 events
108
- color = {
109
- "new": "green",
110
- "modified": "yellow",
111
- "moved": "blue",
112
- "deleted": "red",
113
- "error": "red",
114
- }.get(event.action, "white")
115
-
116
- icon = {
117
- "new": "✚",
118
- "modified": "✎",
119
- "moved": "→",
120
- "deleted": "✖",
121
- "error": "!",
122
- }.get(event.action, "*")
123
-
124
- table.add_row(
125
- f"[{color}]{icon} {event.action}[/{color}]",
126
- event.timestamp.strftime("%H:%M:%S"),
127
- f"[{color}]{event.path}[/{color}]",
128
- f"[dim]{event.checksum[:8] if event.checksum else ''}[/dim]",
129
- )
130
-
131
- return table
132
-
133
- async def run(self, console_status: bool = False): # pragma: no cover
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
134
160
  """Watch for file changes and sync them"""
135
- logger.info("Watching for sync changes")
161
+
136
162
  self.state.running = True
137
163
  self.state.start_time = datetime.now()
138
164
  await self.write_status()
139
165
 
140
- if console_status:
141
- with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
142
- try:
143
- async for changes in awatch(
144
- self.config.home,
145
- watch_filter=self.filter_changes,
146
- debounce=self.config.sync_delay,
147
- recursive=True,
148
- ):
149
- # Process changes
150
- await self.handle_changes(self.config.home)
151
- # Update display
152
- live.update(self.generate_table())
166
+ logger.info(
167
+ "Watch service started",
168
+ f"debounce_ms={self.app_config.sync_delay}",
169
+ f"pid={os.getpid()}",
170
+ )
153
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)
154
191
  except Exception as e:
192
+ logger.exception("Watch service error during cycle", error=str(e))
155
193
  self.state.record_error(str(e))
156
194
  await self.write_status()
157
- raise
195
+ # Continue to next cycle instead of exiting
196
+ await asyncio.sleep(5) # Brief pause before retry
158
197
  finally:
159
- self.state.running = False
160
- await self.write_status()
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
161
233
 
162
- else:
163
- try:
164
- async for changes in awatch(
165
- self.config.home,
166
- watch_filter=self.filter_changes,
167
- debounce=self.config.sync_delay,
168
- recursive=True,
169
- ):
170
- # Process changes
171
- await self.handle_changes(self.config.home)
172
- # Update display
173
-
174
- except Exception as e:
175
- self.state.record_error(str(e))
176
- await self.write_status()
177
- raise
178
- finally:
179
- self.state.running = False
180
- await self.write_status()
234
+ # Skip temp files used in atomic operations
235
+ if path.endswith(".tmp"):
236
+ return False
237
+
238
+ return True
181
239
 
182
240
  async def write_status(self):
183
241
  """Write current state to status file"""
184
242
  self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
185
243
 
186
- def filter_changes(self, change: Change, path: str) -> bool:
187
- """Filter to only watch markdown files"""
188
- return path.endswith(".md") and not Path(path).name.startswith(".")
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
189
251
 
190
- async def handle_changes(self, directory: Path):
252
+ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
191
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] = []
192
281
 
193
- logger.debug(f"handling change in directory: {directory} ...")
194
- # Process changes with timeout
195
- report = await self.sync_service.sync(directory)
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
+ # Avoid mutating `adds` while iterating (can skip items).
303
+ reclassified_as_modified: List[str] = []
304
+ for added_path in list(adds): # pragma: no cover TODO add test
305
+ entity = await sync_service.entity_repository.get_by_file_path(added_path)
306
+ if entity is not None:
307
+ logger.debug(f"Existing file will be processed as modified, path={added_path}")
308
+ reclassified_as_modified.append(added_path)
309
+
310
+ if reclassified_as_modified:
311
+ adds = [p for p in adds if p not in reclassified_as_modified]
312
+ modifies.extend(reclassified_as_modified)
313
+
314
+ # Track processed files to avoid duplicates
315
+ processed: Set[str] = set()
316
+
317
+ # First handle potential moves
318
+ for added_path in adds:
319
+ if added_path in processed:
320
+ continue # pragma: no cover
321
+
322
+ # Skip directories for added paths
323
+ # We don't need to process directories, only the files inside them
324
+ # This prevents errors when trying to compute checksums or read directories as files
325
+ added_full_path = directory / added_path
326
+ if not added_full_path.exists() or added_full_path.is_dir():
327
+ logger.debug("Skipping non-existent or directory path", path=added_path)
328
+ processed.add(added_path)
329
+ continue
330
+
331
+ for deleted_path in deletes:
332
+ if deleted_path in processed:
333
+ continue # pragma: no cover
334
+
335
+ # Skip directories for deleted paths (based on entity type in db)
336
+ deleted_entity = await sync_service.entity_repository.get_by_file_path(deleted_path)
337
+ if deleted_entity is None:
338
+ # If this was a directory, it wouldn't have an entity
339
+ logger.debug("Skipping unknown path for move detection", path=deleted_path)
340
+ continue
341
+
342
+ if added_path != deleted_path:
343
+ # Compare checksums to detect moves
344
+ try:
345
+ added_checksum = await file_service.compute_checksum(added_path)
346
+
347
+ if deleted_entity and deleted_entity.checksum == added_checksum:
348
+ await sync_service.handle_move(deleted_path, added_path)
349
+ self.state.add_event(
350
+ path=f"{deleted_path} -> {added_path}",
351
+ action="moved",
352
+ status="success",
353
+ )
354
+ self.console.print(f"[blue]→[/blue] {deleted_path} → {added_path}")
355
+ logger.info(f"move: {deleted_path} -> {added_path}")
356
+ processed.add(added_path)
357
+ processed.add(deleted_path)
358
+ break
359
+ except Exception as e: # pragma: no cover
360
+ logger.warning(
361
+ "Error checking for move",
362
+ f"old_path={deleted_path}",
363
+ f"new_path={added_path}",
364
+ f"error={str(e)}",
365
+ )
366
+
367
+ # Handle remaining changes - group them by type for concise output
368
+ moved_count = len([p for p in processed if p in deletes or p in adds])
369
+ delete_count = 0
370
+ add_count = 0
371
+ modify_count = 0
372
+
373
+ # Process deletes
374
+ for path in deletes:
375
+ if path not in processed:
376
+ # Check if file still exists on disk (vim atomic write edge case)
377
+ full_path = directory / path
378
+ if full_path.exists() and full_path.is_file():
379
+ # File still exists despite DELETE event - treat as modification
380
+ logger.debug(
381
+ "File exists despite DELETE event, treating as modification", path=path
382
+ )
383
+ entity, checksum = await sync_service.sync_file(path, new=False)
384
+ self.state.add_event(
385
+ path=path, action="modified", status="success", checksum=checksum
386
+ )
387
+ self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
388
+ logger.info(f"atomic write detected: {path}")
389
+ processed.add(path)
390
+ modify_count += 1
391
+ else:
392
+ # Check if this was a directory - skip if so
393
+ # (we can't tell if the deleted path was a directory since it no longer exists,
394
+ # so we check if there's an entity in the database for it)
395
+ entity = await sync_service.entity_repository.get_by_file_path(path)
396
+ if entity is None:
397
+ # No entity means this was likely a directory - skip it
398
+ logger.debug(
399
+ f"Skipping deleted path with no entity (likely directory), path={path}"
400
+ )
401
+ processed.add(path)
402
+ continue
403
+
404
+ # File truly deleted
405
+ logger.debug("Processing deleted file", path=path)
406
+ await sync_service.handle_delete(path)
407
+ self.state.add_event(path=path, action="deleted", status="success")
408
+ self.console.print(f"[red]✕[/red] {path}")
409
+ logger.info(f"deleted: {path}")
410
+ processed.add(path)
411
+ delete_count += 1
412
+
413
+ # Process adds
414
+ for path in adds:
415
+ if path not in processed:
416
+ # Skip directories - only process files
417
+ full_path = directory / path
418
+ if not full_path.exists() or full_path.is_dir():
419
+ logger.debug(
420
+ f"Skipping non-existent or directory path, path={path}"
421
+ ) # pragma: no cover
422
+ processed.add(path) # pragma: no cover
423
+ continue # pragma: no cover
424
+
425
+ logger.debug(f"Processing new file, path={path}")
426
+ entity, checksum = await sync_service.sync_file(path, new=True)
427
+ if checksum:
428
+ self.state.add_event(
429
+ path=path, action="new", status="success", checksum=checksum
430
+ )
431
+ self.console.print(f"[green]✓[/green] {path}")
432
+ logger.info(
433
+ "new file processed",
434
+ f"path={path}",
435
+ f"checksum={checksum}",
436
+ )
437
+ processed.add(path)
438
+ add_count += 1
439
+ else: # pragma: no cover
440
+ logger.warning(f"Error syncing new file, path={path}") # pragma: no cover
441
+ self.console.print(
442
+ f"[orange]?[/orange] Error syncing: {path}"
443
+ ) # pragma: no cover
444
+
445
+ # Process modifies - detect repeats
446
+ last_modified_path = None
447
+ repeat_count = 0
448
+
449
+ for path in modifies:
450
+ if path not in processed:
451
+ # Skip directories - only process files
452
+ full_path = directory / path
453
+ if not full_path.exists() or full_path.is_dir():
454
+ logger.debug("Skipping non-existent or directory path", path=path)
455
+ processed.add(path)
456
+ continue
457
+
458
+ logger.debug(f"Processing modified file: path={path}")
459
+ entity, checksum = await sync_service.sync_file(path, new=False)
460
+ self.state.add_event(
461
+ path=path, action="modified", status="success", checksum=checksum
462
+ )
463
+
464
+ # Check if this is a repeat of the last modified file
465
+ if path == last_modified_path: # pragma: no cover
466
+ repeat_count += 1 # pragma: no cover
467
+ # Only show a message for the first repeat
468
+ if repeat_count == 1: # pragma: no cover
469
+ self.console.print(
470
+ f"[yellow]...[/yellow] Repeated changes to {path}"
471
+ ) # pragma: no cover
472
+ else:
473
+ # haven't processed this file
474
+ self.console.print(f"[yellow]✎[/yellow] {path}")
475
+ logger.info(f"modified: {path}")
476
+ last_modified_path = path
477
+ repeat_count = 0
478
+ modify_count += 1
479
+
480
+ logger.debug( # pragma: no cover
481
+ "Modified file processed, "
482
+ f"path={path} "
483
+ f"entity_id={entity.id if entity else None} "
484
+ f"checksum={checksum}",
485
+ )
486
+ processed.add(path)
487
+
488
+ # Add a concise summary instead of a divider
489
+ if processed:
490
+ changes = [] # pyright: ignore
491
+ if add_count > 0:
492
+ changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
493
+ if modify_count > 0:
494
+ changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
495
+ if moved_count > 0:
496
+ changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
497
+ if delete_count > 0:
498
+ changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
499
+
500
+ if changes:
501
+ self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
502
+ logger.info(f"changes: {len(changes)}")
503
+
504
+ duration_ms = int((time.time() - start_time) * 1000)
196
505
  self.state.last_scan = datetime.now()
197
- self.state.synced_files = report.total
506
+ self.state.synced_files += len(processed)
198
507
 
199
- # Update stats
200
- for path in report.new:
201
- self.state.add_event(
202
- path=path, action="new", status="success", checksum=report.checksums[path]
203
- )
204
- for path in report.modified:
205
- self.state.add_event(
206
- path=path, action="modified", status="success", checksum=report.checksums[path]
207
- )
208
- for old_path, new_path in report.moves.items():
209
- self.state.add_event(
210
- path=f"{old_path} -> {new_path}",
211
- action="moved",
212
- status="success",
213
- checksum=report.checksums[new_path],
214
- )
215
- for path in report.deleted:
216
- self.state.add_event(path=path, action="deleted", status="success")
508
+ logger.info(
509
+ "File change processing completed, "
510
+ f"processed_files={len(processed)}, "
511
+ f"total_synced_files={self.state.synced_files}, "
512
+ f"duration_ms={duration_ms}"
513
+ )
217
514
 
218
515
  await self.write_status()