basic-memory 0.16.1__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -4,6 +4,7 @@ import ast
4
4
  from datetime import datetime
5
5
  from typing import List, Optional, Set
6
6
 
7
+
7
8
  from dateparser import parse
8
9
  from fastapi import BackgroundTasks
9
10
  from loguru import logger
@@ -15,6 +16,21 @@ from basic_memory.repository.search_repository import SearchRepository, SearchIn
15
16
  from basic_memory.schemas.search import SearchQuery, SearchItemType
16
17
  from basic_memory.services import FileService
17
18
 
19
+ # Maximum size for content_stems field to stay under Postgres's 8KB index row limit.
20
+ # We use 6000 characters to leave headroom for other indexed columns and overhead.
21
+ MAX_CONTENT_STEMS_SIZE = 6000
22
+
23
+
24
+ def _mtime_to_datetime(entity: Entity) -> datetime:
25
+ """Convert entity mtime (file modification time) to datetime.
26
+
27
+ Returns the file's actual modification time, falling back to updated_at
28
+ if mtime is not available.
29
+ """
30
+ if entity.mtime:
31
+ return datetime.fromtimestamp(entity.mtime).astimezone()
32
+ return entity.updated_at
33
+
18
34
 
19
35
  class SearchService:
20
36
  """Service for search operations.
@@ -156,23 +172,43 @@ class SearchService:
156
172
  self,
157
173
  entity: Entity,
158
174
  background_tasks: Optional[BackgroundTasks] = None,
175
+ content: str | None = None,
159
176
  ) -> None:
160
177
  if background_tasks:
161
- background_tasks.add_task(self.index_entity_data, entity)
178
+ background_tasks.add_task(self.index_entity_data, entity, content)
162
179
  else:
163
- await self.index_entity_data(entity)
180
+ await self.index_entity_data(entity, content)
164
181
 
165
182
  async def index_entity_data(
166
183
  self,
167
184
  entity: Entity,
185
+ content: str | None = None,
168
186
  ) -> None:
169
- # delete all search index data associated with entity
170
- await self.repository.delete_by_entity_id(entity_id=entity.id)
171
-
172
- # reindex
173
- await self.index_entity_markdown(
174
- entity
175
- ) if entity.is_markdown else await self.index_entity_file(entity)
187
+ logger.info(
188
+ f"[BackgroundTask] Starting search index for entity_id={entity.id} "
189
+ f"permalink={entity.permalink} project_id={entity.project_id}"
190
+ )
191
+ try:
192
+ # delete all search index data associated with entity
193
+ await self.repository.delete_by_entity_id(entity_id=entity.id)
194
+
195
+ # reindex
196
+ await self.index_entity_markdown(
197
+ entity, content
198
+ ) if entity.is_markdown else await self.index_entity_file(entity)
199
+
200
+ logger.info(
201
+ f"[BackgroundTask] Completed search index for entity_id={entity.id} "
202
+ f"permalink={entity.permalink}"
203
+ )
204
+ except Exception as e: # pragma: no cover
205
+ # Background task failure logging; exceptions are re-raised.
206
+ # Avoid forcing synthetic failures just for line coverage.
207
+ logger.error( # pragma: no cover
208
+ f"[BackgroundTask] Failed search index for entity_id={entity.id} "
209
+ f"permalink={entity.permalink} error={e}"
210
+ )
211
+ raise # pragma: no cover
176
212
 
177
213
  async def index_entity_file(
178
214
  self,
@@ -185,12 +221,13 @@ class SearchService:
185
221
  entity_id=entity.id,
186
222
  type=SearchItemType.ENTITY.value,
187
223
  title=entity.title,
224
+ permalink=entity.permalink, # Required for Postgres NOT NULL constraint
188
225
  file_path=entity.file_path,
189
226
  metadata={
190
227
  "entity_type": entity.entity_type,
191
228
  },
192
229
  created_at=entity.created_at,
193
- updated_at=entity.updated_at,
230
+ updated_at=_mtime_to_datetime(entity),
194
231
  project_id=entity.project_id,
195
232
  )
196
233
  )
@@ -198,9 +235,14 @@ class SearchService:
198
235
  async def index_entity_markdown(
199
236
  self,
200
237
  entity: Entity,
238
+ content: str | None = None,
201
239
  ) -> None:
202
240
  """Index an entity and all its observations and relations.
203
241
 
242
+ Args:
243
+ entity: The entity to index
244
+ content: Optional pre-loaded content (avoids file read). If None, will read from file.
245
+
204
246
  Indexing structure:
205
247
  1. Entities
206
248
  - permalink: direct from entity (e.g., "specs/search")
@@ -229,7 +271,9 @@ class SearchService:
229
271
  title_variants = self._generate_variants(entity.title)
230
272
  content_stems.extend(title_variants)
231
273
 
232
- content = await self.file_service.read_entity_content(entity)
274
+ # Use provided content or read from file
275
+ if content is None:
276
+ content = await self.file_service.read_entity_content(entity)
233
277
  if content:
234
278
  content_stems.append(content)
235
279
  content_snippet = f"{content[:250]}"
@@ -246,6 +290,10 @@ class SearchService:
246
290
 
247
291
  entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
248
292
 
293
+ # Truncate to stay under Postgres's 8KB index row limit
294
+ if len(entity_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
295
+ entity_content_stems = entity_content_stems[:MAX_CONTENT_STEMS_SIZE] # pragma: no cover
296
+
249
297
  # Add entity row
250
298
  rows_to_index.append(
251
299
  SearchIndexRow(
@@ -261,17 +309,28 @@ class SearchService:
261
309
  "entity_type": entity.entity_type,
262
310
  },
263
311
  created_at=entity.created_at,
264
- updated_at=entity.updated_at,
312
+ updated_at=_mtime_to_datetime(entity),
265
313
  project_id=entity.project_id,
266
314
  )
267
315
  )
268
316
 
269
- # Add observation rows
317
+ # Add observation rows - dedupe by permalink to avoid unique constraint violations
318
+ # Two observations with same entity/category/content generate identical permalinks
319
+ seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
270
320
  for obs in entity.observations:
321
+ obs_permalink = obs.permalink
322
+ if obs_permalink in seen_permalinks:
323
+ logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
324
+ continue
325
+ seen_permalinks.add(obs_permalink)
326
+
271
327
  # Index with parent entity's file path since that's where it's defined
272
328
  obs_content_stems = "\n".join(
273
329
  p for p in self._generate_variants(obs.content) if p and p.strip()
274
330
  )
331
+ # Truncate to stay under Postgres's 8KB index row limit
332
+ if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
333
+ obs_content_stems = obs_content_stems[:MAX_CONTENT_STEMS_SIZE] # pragma: no cover
275
334
  rows_to_index.append(
276
335
  SearchIndexRow(
277
336
  id=obs.id,
@@ -279,7 +338,7 @@ class SearchService:
279
338
  title=f"{obs.category}: {obs.content[:100]}...",
280
339
  content_stems=obs_content_stems,
281
340
  content_snippet=obs.content,
282
- permalink=obs.permalink,
341
+ permalink=obs_permalink,
283
342
  file_path=entity.file_path,
284
343
  category=obs.category,
285
344
  entity_id=entity.id,
@@ -287,7 +346,7 @@ class SearchService:
287
346
  "tags": obs.tags,
288
347
  },
289
348
  created_at=entity.created_at,
290
- updated_at=entity.updated_at,
349
+ updated_at=_mtime_to_datetime(entity),
291
350
  project_id=entity.project_id,
292
351
  )
293
352
  )
@@ -317,7 +376,7 @@ class SearchService:
317
376
  to_id=rel.to_id,
318
377
  relation_type=rel.relation_type,
319
378
  created_at=entity.created_at,
320
- updated_at=entity.updated_at,
379
+ updated_at=_mtime_to_datetime(entity),
321
380
  project_id=entity.project_id,
322
381
  )
323
382
  )
@@ -1,6 +1,7 @@
1
1
  """Basic Memory sync services."""
2
2
 
3
+ from .coordinator import SyncCoordinator, SyncStatus
3
4
  from .sync_service import SyncService
4
5
  from .watch_service import WatchService
5
6
 
6
- __all__ = ["SyncService", "WatchService"]
7
+ __all__ = ["SyncService", "WatchService", "SyncCoordinator", "SyncStatus"]
@@ -0,0 +1,160 @@
1
+ """SyncCoordinator - centralized sync/watch lifecycle management.
2
+
3
+ This module provides a single coordinator that manages the lifecycle of
4
+ file synchronization and watch services across all entry points (API, MCP, CLI).
5
+
6
+ The coordinator handles:
7
+ - Starting/stopping watch service
8
+ - Scheduling background sync
9
+ - Reporting status
10
+ - Clean shutdown behavior
11
+ """
12
+
13
+ import asyncio
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum, auto
16
+ from typing import Optional
17
+
18
+ from loguru import logger
19
+
20
+ from basic_memory.config import BasicMemoryConfig
21
+
22
+
23
+ class SyncStatus(Enum):
24
+ """Status of the sync coordinator."""
25
+
26
+ NOT_STARTED = auto()
27
+ STARTING = auto()
28
+ RUNNING = auto()
29
+ STOPPING = auto()
30
+ STOPPED = auto()
31
+ ERROR = auto()
32
+
33
+
34
+ @dataclass
35
+ class SyncCoordinator:
36
+ """Centralized coordinator for sync/watch lifecycle.
37
+
38
+ Manages the lifecycle of file synchronization services, providing:
39
+ - Unified start/stop interface
40
+ - Status tracking
41
+ - Clean shutdown with proper task cancellation
42
+
43
+ Args:
44
+ config: BasicMemoryConfig with sync settings
45
+ should_sync: Whether sync should be enabled (from container decision)
46
+ skip_reason: Human-readable reason if sync is skipped
47
+
48
+ Usage:
49
+ coordinator = SyncCoordinator(config=config, should_sync=True)
50
+ await coordinator.start()
51
+ # ... application runs ...
52
+ await coordinator.stop()
53
+ """
54
+
55
+ config: BasicMemoryConfig
56
+ should_sync: bool = True
57
+ skip_reason: Optional[str] = None
58
+
59
+ # Internal state (not constructor args)
60
+ _status: SyncStatus = field(default=SyncStatus.NOT_STARTED, init=False)
61
+ _sync_task: Optional[asyncio.Task] = field(default=None, init=False)
62
+
63
+ @property
64
+ def status(self) -> SyncStatus:
65
+ """Current status of the coordinator."""
66
+ return self._status
67
+
68
+ @property
69
+ def is_running(self) -> bool:
70
+ """Whether sync is currently running."""
71
+ return self._status == SyncStatus.RUNNING
72
+
73
+ async def start(self) -> None:
74
+ """Start the sync/watch service if enabled.
75
+
76
+ This is a non-blocking call that starts the sync task in the background.
77
+ Use stop() to cleanly shut down.
78
+ """
79
+ if not self.should_sync:
80
+ if self.skip_reason:
81
+ logger.info(f"{self.skip_reason} - skipping local file sync")
82
+ self._status = SyncStatus.STOPPED
83
+ return
84
+
85
+ if self._status in (SyncStatus.RUNNING, SyncStatus.STARTING):
86
+ logger.warning("Sync coordinator already running or starting")
87
+ return
88
+
89
+ self._status = SyncStatus.STARTING
90
+ logger.info("Starting file sync in background")
91
+
92
+ try:
93
+ # Deferred import to avoid circular dependency
94
+ from basic_memory.services.initialization import initialize_file_sync
95
+
96
+ async def _file_sync_runner() -> None: # pragma: no cover
97
+ """Run the file sync service."""
98
+ try:
99
+ await initialize_file_sync(self.config)
100
+ except asyncio.CancelledError:
101
+ logger.debug("File sync cancelled")
102
+ raise
103
+ except Exception as e:
104
+ logger.error(f"Error in file sync: {e}")
105
+ self._status = SyncStatus.ERROR
106
+ raise
107
+
108
+ self._sync_task = asyncio.create_task(_file_sync_runner())
109
+ self._status = SyncStatus.RUNNING
110
+ logger.info("Sync coordinator started successfully")
111
+
112
+ except Exception as e: # pragma: no cover
113
+ logger.error(f"Failed to start sync coordinator: {e}")
114
+ self._status = SyncStatus.ERROR
115
+ raise
116
+
117
+ async def stop(self) -> None:
118
+ """Stop the sync/watch service cleanly.
119
+
120
+ Cancels the background task and waits for it to complete.
121
+ Safe to call even if not running.
122
+ """
123
+ if self._status in (SyncStatus.NOT_STARTED, SyncStatus.STOPPED):
124
+ return
125
+
126
+ if self._sync_task is None: # pragma: no cover
127
+ self._status = SyncStatus.STOPPED
128
+ return
129
+
130
+ self._status = SyncStatus.STOPPING
131
+ logger.info("Stopping sync coordinator...")
132
+
133
+ self._sync_task.cancel()
134
+ try:
135
+ await self._sync_task
136
+ except asyncio.CancelledError:
137
+ logger.info("File sync task cancelled successfully")
138
+
139
+ self._sync_task = None
140
+ self._status = SyncStatus.STOPPED
141
+ logger.info("Sync coordinator stopped")
142
+
143
+ def get_status_info(self) -> dict:
144
+ """Get status information for reporting.
145
+
146
+ Returns:
147
+ Dictionary with status details for diagnostics
148
+ """
149
+ return {
150
+ "status": self._status.name,
151
+ "should_sync": self.should_sync,
152
+ "skip_reason": self.skip_reason,
153
+ "has_task": self._sync_task is not None,
154
+ }
155
+
156
+
157
+ __all__ = [
158
+ "SyncCoordinator",
159
+ "SyncStatus",
160
+ ]