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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {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
|
|
4
|
-
|
|
5
|
-
from
|
|
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
|
-
|
|
12
|
-
from
|
|
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
|
|
18
|
-
from basic_memory.
|
|
19
|
-
from basic_memory.
|
|
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 =
|
|
35
|
-
pid: int =
|
|
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] =
|
|
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__(
|
|
76
|
-
self
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
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.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
161
|
+
|
|
136
162
|
self.state.running = True
|
|
137
163
|
self.state.start_time = datetime.now()
|
|
138
164
|
await self.write_status()
|
|
139
165
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
195
|
+
# Continue to next cycle instead of exiting
|
|
196
|
+
await asyncio.sleep(5) # Brief pause before retry
|
|
158
197
|
finally:
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
187
|
-
"""
|
|
188
|
-
|
|
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,
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
506
|
+
self.state.synced_files += len(processed)
|
|
198
507
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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()
|