basic-memory 0.22.1__py3-none-any.whl → 0.23.1.dev2__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 (334) hide show
  1. basic_memory/__init__.py +2 -2
  2. basic_memory/alembic/env.py +35 -25
  3. basic_memory/alembic/versions/2d26b287813b_dedupe_observations_and_purge_stale_.py +69 -0
  4. basic_memory/alembic/versions/o8j9k0l1m2n3_add_note_file_vacate_table.py +52 -0
  5. basic_memory/alembic/versions/p9k0l1m2n3o4_add_vector_index_manifest_state.py +132 -0
  6. basic_memory/alembic/versions/q0l1m2n3o4p5_add_relation_search_refresh_table.py +57 -0
  7. basic_memory/alembic/versions/r1m2n3o4p5q6_add_relation_generation.py +95 -0
  8. basic_memory/api/app.py +15 -19
  9. basic_memory/api/container.py +59 -20
  10. basic_memory/api/template_loader.py +16 -4
  11. basic_memory/api/v2/routers/__init__.py +2 -0
  12. basic_memory/api/v2/routers/directory_router.py +146 -25
  13. basic_memory/api/v2/routers/importer_router.py +95 -9
  14. basic_memory/api/v2/routers/inspect_router.py +205 -0
  15. basic_memory/api/v2/routers/knowledge_router.py +601 -248
  16. basic_memory/api/v2/routers/memory_router.py +37 -12
  17. basic_memory/api/v2/routers/project_router.py +77 -55
  18. basic_memory/api/v2/routers/prompt_router.py +11 -4
  19. basic_memory/api/v2/routers/resource_router.py +134 -284
  20. basic_memory/api/v2/routers/schema_router.py +211 -52
  21. basic_memory/api/v2/routers/search_router.py +154 -71
  22. basic_memory/api/v2/utils.py +28 -5
  23. basic_memory/cli/analytics.py +2 -2
  24. basic_memory/cli/app.py +43 -1
  25. basic_memory/cli/auth.py +8 -8
  26. basic_memory/cli/auto_update.py +87 -13
  27. basic_memory/cli/commands/__init__.py +4 -0
  28. basic_memory/cli/commands/cloud/__init__.py +2 -0
  29. basic_memory/cli/commands/cloud/api_client.py +8 -5
  30. basic_memory/cli/commands/cloud/bisync_commands.py +72 -4
  31. basic_memory/cli/commands/cloud/cloud_utils.py +6 -6
  32. basic_memory/cli/commands/cloud/project_sync.py +329 -63
  33. basic_memory/cli/commands/cloud/rclone_commands.py +178 -58
  34. basic_memory/cli/commands/cloud/rclone_installer.py +1 -1
  35. basic_memory/cli/commands/cloud/shares.py +533 -0
  36. basic_memory/cli/commands/cloud/transfer.py +58 -0
  37. basic_memory/cli/commands/cloud/upload.py +20 -4
  38. basic_memory/cli/commands/cloud/upload_command.py +10 -10
  39. basic_memory/cli/commands/cloud/webdav.py +388 -0
  40. basic_memory/cli/commands/cloud/webdav_transfer.py +600 -0
  41. basic_memory/cli/commands/command_utils.py +26 -16
  42. basic_memory/cli/commands/config.py +251 -0
  43. basic_memory/cli/commands/db.py +120 -83
  44. basic_memory/cli/commands/doctor.py +82 -17
  45. basic_memory/cli/commands/format.py +2 -1
  46. basic_memory/cli/commands/hook.py +1508 -0
  47. basic_memory/cli/commands/inspect.py +670 -0
  48. basic_memory/cli/commands/mcp.py +2 -2
  49. basic_memory/cli/commands/orphans.py +2 -2
  50. basic_memory/cli/commands/project.py +46 -25
  51. basic_memory/cli/commands/schema.py +4 -4
  52. basic_memory/cli/commands/status.py +63 -161
  53. basic_memory/cli/commands/tool.py +631 -8
  54. basic_memory/cli/container.py +1 -1
  55. basic_memory/cli/main.py +2 -0
  56. basic_memory/config.py +97 -985
  57. basic_memory/config_logging.py +67 -0
  58. basic_memory/config_migrations.py +90 -0
  59. basic_memory/config_models.py +1060 -0
  60. basic_memory/db.py +139 -38
  61. basic_memory/deps/__init__.py +62 -162
  62. basic_memory/deps/config.py +18 -12
  63. basic_memory/deps/db.py +21 -4
  64. basic_memory/deps/importers.py +21 -148
  65. basic_memory/deps/projects.py +24 -161
  66. basic_memory/deps/read_cache.py +48 -0
  67. basic_memory/deps/repositories.py +8 -106
  68. basic_memory/deps/services.py +360 -348
  69. basic_memory/file_utils.py +24 -3
  70. basic_memory/hooks/__init__.py +16 -0
  71. basic_memory/hooks/_uuid7.py +43 -0
  72. basic_memory/hooks/adapters/__init__.py +33 -0
  73. basic_memory/hooks/adapters/base.py +36 -0
  74. basic_memory/hooks/adapters/claude.py +39 -0
  75. basic_memory/hooks/adapters/codex.py +38 -0
  76. basic_memory/hooks/archive.py +97 -0
  77. basic_memory/hooks/envelope.py +170 -0
  78. basic_memory/hooks/inbox.py +205 -0
  79. basic_memory/hooks/project_ref.py +16 -0
  80. basic_memory/ignore_utils.py +2 -2
  81. basic_memory/importers/__init__.py +12 -1
  82. basic_memory/importers/base.py +27 -2
  83. basic_memory/importers/chatgpt_importer.py +52 -9
  84. basic_memory/importers/claude_conversations_importer.py +3 -1
  85. basic_memory/importers/claude_projects_importer.py +3 -1
  86. basic_memory/importers/memory_json_importer.py +3 -1
  87. basic_memory/importers/project_zip_import.py +181 -0
  88. basic_memory/index/__init__.py +5 -0
  89. basic_memory/index/event_operations.py +134 -0
  90. basic_memory/index/filesystem.py +215 -0
  91. basic_memory/index/inline_operations.py +103 -0
  92. basic_memory/index/local_dependencies.py +772 -0
  93. basic_memory/index/local_moves.py +424 -0
  94. basic_memory/index/local_notes.py +246 -0
  95. basic_memory/index/local_project.py +844 -0
  96. basic_memory/index/local_runtime.py +442 -0
  97. basic_memory/index/local_schedulers.py +268 -0
  98. basic_memory/index/local_watch.py +320 -0
  99. basic_memory/index/note_content_materialization.py +744 -0
  100. basic_memory/index/project_indexing.py +48 -0
  101. basic_memory/index/repository_project_resolution.py +98 -0
  102. basic_memory/index/schedulers.py +19 -0
  103. basic_memory/index/storage_events.py +165 -0
  104. basic_memory/index/watch_coordinator.py +139 -0
  105. basic_memory/index/watch_service.py +350 -0
  106. basic_memory/indexing/__init__.py +4 -30
  107. basic_memory/indexing/accepted_note_enqueue_runner.py +210 -0
  108. basic_memory/indexing/accepted_note_mutation_runner.py +1128 -0
  109. basic_memory/indexing/accepted_note_search.py +134 -0
  110. basic_memory/indexing/accepted_note_write_runner.py +793 -0
  111. basic_memory/indexing/batch_indexer.py +384 -139
  112. basic_memory/indexing/change_detector.py +184 -0
  113. basic_memory/indexing/change_planning.py +176 -0
  114. basic_memory/indexing/directory_delete_runner.py +647 -0
  115. basic_memory/indexing/embedding_index_planning.py +460 -0
  116. basic_memory/indexing/external_file_delete_runner.py +228 -0
  117. basic_memory/indexing/file_batch_runner.py +220 -0
  118. basic_memory/indexing/file_index_checking.py +463 -0
  119. basic_memory/indexing/file_index_planning.py +200 -0
  120. basic_memory/indexing/file_indexer.py +274 -0
  121. basic_memory/indexing/forward_reference_resolution.py +339 -0
  122. basic_memory/indexing/index_batch_runtime.py +188 -0
  123. basic_memory/indexing/index_file_runner.py +304 -0
  124. basic_memory/indexing/index_file_runtime.py +142 -0
  125. basic_memory/indexing/input_file_adaptation.py +56 -0
  126. basic_memory/indexing/job_payloads.py +491 -0
  127. basic_memory/indexing/link_resolution.py +112 -0
  128. basic_memory/indexing/models.py +795 -2
  129. basic_memory/indexing/note_content_batch_reconciliation.py +306 -0
  130. basic_memory/indexing/note_content_read_repair_runner.py +275 -0
  131. basic_memory/indexing/note_content_reconciler.py +456 -0
  132. basic_memory/indexing/note_content_reconciliation.py +342 -0
  133. basic_memory/indexing/note_file_delete_runner.py +124 -0
  134. basic_memory/indexing/note_materialization_runner.py +862 -0
  135. basic_memory/indexing/orphan_cleanup.py +118 -0
  136. basic_memory/indexing/progress.py +316 -0
  137. basic_memory/indexing/project_delete_acceptance.py +60 -0
  138. basic_memory/indexing/project_delete_runner.py +312 -0
  139. basic_memory/indexing/project_index_coordinator.py +369 -0
  140. basic_memory/indexing/project_index_maintenance.py +1384 -0
  141. basic_memory/indexing/project_index_progress.py +465 -0
  142. basic_memory/indexing/project_index_runtime.py +211 -0
  143. basic_memory/indexing/project_index_workflow.py +41 -0
  144. basic_memory/indexing/project_index_workflow_models.py +262 -0
  145. basic_memory/indexing/project_index_workflow_stale.py +105 -0
  146. basic_memory/indexing/project_index_workflow_updates.py +279 -0
  147. basic_memory/indexing/relation_persistence.py +191 -0
  148. basic_memory/indexing/relation_resolution.py +542 -0
  149. basic_memory/man/bm.1 +2 -2
  150. basic_memory/markdown/entity_parser.py +47 -5
  151. basic_memory/markdown/plugins.py +88 -26
  152. basic_memory/markdown/schemas.py +3 -1
  153. basic_memory/mcp/async_client.py +0 -22
  154. basic_memory/mcp/client_info.py +112 -0
  155. basic_memory/mcp/clients/__init__.py +2 -0
  156. basic_memory/mcp/clients/directory.py +21 -5
  157. basic_memory/mcp/clients/inspect.py +66 -0
  158. basic_memory/mcp/clients/knowledge.py +45 -24
  159. basic_memory/mcp/clients/memory.py +12 -8
  160. basic_memory/mcp/clients/project.py +10 -10
  161. basic_memory/mcp/clients/search.py +13 -4
  162. basic_memory/mcp/container.py +25 -22
  163. basic_memory/mcp/note_reads.py +106 -0
  164. basic_memory/mcp/project_context.py +92 -642
  165. basic_memory/mcp/project_context_identifiers.py +213 -0
  166. basic_memory/mcp/prompts/__init__.py +2 -0
  167. basic_memory/mcp/prompts/ai_assistant_guide.py +8 -55
  168. basic_memory/mcp/prompts/continue_conversation.py +2 -2
  169. basic_memory/mcp/prompts/getting_started.py +252 -0
  170. basic_memory/mcp/prompts/search.py +2 -2
  171. basic_memory/mcp/prompts/utils.py +3 -3
  172. basic_memory/mcp/resources/__init__.py +2 -24
  173. basic_memory/mcp/resources/ai_assistant_guide.md +28 -312
  174. basic_memory/mcp/resources/project_info.py +33 -41
  175. basic_memory/mcp/server.py +166 -79
  176. basic_memory/mcp/tools/__init__.py +2 -6
  177. basic_memory/mcp/tools/basic_memory_diagnostics.py +88 -0
  178. basic_memory/mcp/tools/build_context.py +46 -10
  179. basic_memory/mcp/tools/chatgpt_tools.py +74 -8
  180. basic_memory/mcp/tools/delete_note.py +23 -6
  181. basic_memory/mcp/tools/edit_note.py +186 -42
  182. basic_memory/mcp/tools/list_directory.py +105 -11
  183. basic_memory/mcp/tools/move_note.py +134 -100
  184. basic_memory/mcp/tools/project_management.py +204 -43
  185. basic_memory/mcp/tools/read_content.py +13 -5
  186. basic_memory/mcp/tools/read_note.py +91 -77
  187. basic_memory/mcp/tools/recent_activity.py +76 -15
  188. basic_memory/mcp/tools/schema.py +80 -13
  189. basic_memory/mcp/tools/search.py +100 -12
  190. basic_memory/mcp/tools/ui_sdk.py +12 -2
  191. basic_memory/mcp/tools/utils.py +146 -2
  192. basic_memory/mcp/tools/view_note.py +6 -1
  193. basic_memory/mcp/tools/workspaces.py +8 -3
  194. basic_memory/mcp/tools/write_note.py +10 -4
  195. basic_memory/mcp/workspace_project_index.py +358 -0
  196. basic_memory/migration_loop.py +34 -0
  197. basic_memory/models/__init__.py +10 -1
  198. basic_memory/models/knowledge.py +78 -5
  199. basic_memory/models/project.py +2 -1
  200. basic_memory/models/relation_search_refresh.py +45 -0
  201. basic_memory/models/search.py +8 -1
  202. basic_memory/{schema → picoschema}/__init__.py +5 -5
  203. basic_memory/{schema → picoschema}/diff.py +2 -2
  204. basic_memory/{schema → picoschema}/inference.py +4 -3
  205. basic_memory/{schema → picoschema}/parser.py +29 -5
  206. basic_memory/{schema → picoschema}/resolver.py +14 -5
  207. basic_memory/{schema → picoschema}/validator.py +25 -24
  208. basic_memory/read_cache/__init__.py +38 -0
  209. basic_memory/read_cache/contract.py +118 -0
  210. basic_memory/read_cache/invalidation.py +94 -0
  211. basic_memory/read_cache/keys.py +90 -0
  212. basic_memory/read_cache/lifecycle.py +46 -0
  213. basic_memory/read_cache/policy.py +6 -0
  214. basic_memory/read_cache/read_through.py +188 -0
  215. basic_memory/read_cache/redis.py +225 -0
  216. basic_memory/redaction.py +132 -0
  217. basic_memory/repository/__init__.py +11 -3
  218. basic_memory/repository/accepted_note_repositories.py +47 -0
  219. basic_memory/repository/accepted_note_search_repository.py +171 -0
  220. basic_memory/repository/accepted_note_search_row.py +22 -0
  221. basic_memory/repository/accepted_note_vector_cleanup.py +183 -0
  222. basic_memory/repository/embedding_provider.py +22 -3
  223. basic_memory/repository/embedding_provider_factory.py +117 -13
  224. basic_memory/repository/entity_repository.py +286 -145
  225. basic_memory/repository/fastembed_provider.py +3 -4
  226. basic_memory/repository/fastembed_rerank_provider.py +161 -0
  227. basic_memory/repository/litellm_provider.py +33 -12
  228. basic_memory/repository/litellm_rerank_provider.py +121 -0
  229. basic_memory/repository/milvus_config.py +81 -0
  230. basic_memory/repository/milvus_index.py +236 -0
  231. basic_memory/repository/milvus_repository.py +422 -0
  232. basic_memory/repository/note_content_repository.py +258 -73
  233. basic_memory/repository/note_file_vacate_repository.py +260 -0
  234. basic_memory/repository/observation_repository.py +87 -20
  235. basic_memory/repository/openai_provider.py +1 -2
  236. basic_memory/repository/pgvector_index.py +378 -0
  237. basic_memory/repository/postgres_search_repository.py +311 -242
  238. basic_memory/repository/prefixing_provider.py +97 -0
  239. basic_memory/repository/project_info_repository.py +3 -3
  240. basic_memory/repository/project_repository.py +112 -107
  241. basic_memory/repository/relation_repository.py +972 -78
  242. basic_memory/repository/repository.py +216 -220
  243. basic_memory/repository/rerank_provider.py +86 -0
  244. basic_memory/repository/rerank_provider_factory.py +113 -0
  245. basic_memory/repository/search_index_row.py +42 -2
  246. basic_memory/repository/search_query.py +302 -0
  247. basic_memory/repository/search_repository.py +107 -13
  248. basic_memory/repository/search_repository_base.py +1768 -1339
  249. basic_memory/repository/search_trace.py +630 -0
  250. basic_memory/repository/semantic_chunking.py +261 -0
  251. basic_memory/repository/semantic_errors.py +17 -0
  252. basic_memory/repository/semantic_vector_index.py +128 -0
  253. basic_memory/repository/semantic_vector_index_factory.py +132 -0
  254. basic_memory/repository/semantic_vector_sync.py +1441 -0
  255. basic_memory/repository/sqlite_search_repository.py +307 -199
  256. basic_memory/repository/sqlite_vec_index.py +375 -0
  257. basic_memory/runtime/__init__.py +5 -0
  258. basic_memory/runtime/accepted_note_changes.py +329 -0
  259. basic_memory/runtime/cleanup.py +400 -0
  260. basic_memory/runtime/job_payloads.py +161 -0
  261. basic_memory/runtime/jobs.py +308 -0
  262. basic_memory/{runtime.py → runtime/mode.py} +6 -6
  263. basic_memory/runtime/note_content.py +156 -0
  264. basic_memory/runtime/note_content_deletes.py +270 -0
  265. basic_memory/runtime/note_content_reads.py +142 -0
  266. basic_memory/runtime/note_content_responses.py +320 -0
  267. basic_memory/runtime/note_file_guards.py +101 -0
  268. basic_memory/runtime/note_materialization.py +135 -0
  269. basic_memory/runtime/note_materialization_planning.py +204 -0
  270. basic_memory/runtime/note_move.py +36 -0
  271. basic_memory/runtime/note_object_metadata.py +267 -0
  272. basic_memory/runtime/observed_index_files.py +42 -0
  273. basic_memory/runtime/process.py +43 -0
  274. basic_memory/runtime/projects.py +80 -0
  275. basic_memory/runtime/sample_notes.py +52 -0
  276. basic_memory/runtime/storage.py +373 -0
  277. basic_memory/runtime/storage_events.py +116 -0
  278. basic_memory/runtime/storage_project_resolution.py +140 -0
  279. basic_memory/runtime/vector_sync.py +36 -0
  280. basic_memory/runtime/workflows.py +289 -0
  281. basic_memory/schemas/__init__.py +9 -6
  282. basic_memory/schemas/base.py +9 -8
  283. basic_memory/schemas/cloud.py +5 -3
  284. basic_memory/schemas/directory.py +21 -1
  285. basic_memory/schemas/inspect.py +883 -0
  286. basic_memory/schemas/memory.py +6 -0
  287. basic_memory/schemas/project_index.py +61 -0
  288. basic_memory/schemas/project_info.py +13 -2
  289. basic_memory/schemas/request.py +9 -1
  290. basic_memory/schemas/schema.py +18 -2
  291. basic_memory/schemas/search.py +24 -4
  292. basic_memory/schemas/v2/__init__.py +11 -9
  293. basic_memory/schemas/v2/entity.py +62 -5
  294. basic_memory/schemas/v2/project_index.py +20 -0
  295. basic_memory/services/bulk_link_resolver.py +346 -0
  296. basic_memory/services/composition.py +192 -0
  297. basic_memory/services/context_service.py +21 -6
  298. basic_memory/services/directory_deletes.py +134 -0
  299. basic_memory/services/directory_service.py +121 -27
  300. basic_memory/services/entity_service.py +539 -1014
  301. basic_memory/services/exceptions.py +22 -0
  302. basic_memory/services/file_service.py +45 -28
  303. basic_memory/services/initialization.py +207 -55
  304. basic_memory/services/link_resolver.py +269 -108
  305. basic_memory/services/note_content_reads.py +222 -0
  306. basic_memory/services/note_content_writes.py +523 -0
  307. basic_memory/services/note_preparation.py +1032 -0
  308. basic_memory/services/project_deletes.py +167 -0
  309. basic_memory/services/project_service.py +766 -599
  310. basic_memory/services/retrieval_inspect.py +607 -0
  311. basic_memory/services/search_service.py +268 -276
  312. basic_memory/utils.py +11 -2
  313. {basic_memory-0.22.1.dist-info → basic_memory-0.23.1.dev2.dist-info}/METADATA +90 -20
  314. basic_memory-0.23.1.dev2.dist-info/RECORD +397 -0
  315. {basic_memory-0.22.1.dist-info → basic_memory-0.23.1.dev2.dist-info}/WHEEL +1 -1
  316. basic_memory/deps.py +0 -16
  317. basic_memory/mcp/formatting.py +0 -162
  318. basic_memory/mcp/resources/cloud_info.md +0 -16
  319. basic_memory/mcp/resources/discovery.py +0 -17
  320. basic_memory/mcp/resources/release_notes.md +0 -16
  321. basic_memory/mcp/resources/ui.py +0 -89
  322. basic_memory/mcp/tools/canvas.py +0 -164
  323. basic_memory/mcp/tools/cloud_info.py +0 -15
  324. basic_memory/mcp/tools/release_notes.py +0 -15
  325. basic_memory/schemas/sync_report.py +0 -72
  326. basic_memory/schemas/v2/resource.py +0 -47
  327. basic_memory/sync/__init__.py +0 -7
  328. basic_memory/sync/background_sync.py +0 -26
  329. basic_memory/sync/coordinator.py +0 -161
  330. basic_memory/sync/sync_service.py +0 -1778
  331. basic_memory/sync/watch_service.py +0 -606
  332. basic_memory-0.22.1.dist-info/RECORD +0 -254
  333. {basic_memory-0.22.1.dist-info → basic_memory-0.23.1.dev2.dist-info}/entry_points.txt +0 -0
  334. {basic_memory-0.22.1.dist-info → basic_memory-0.23.1.dev2.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.22.1"
4
+ __version__ = "0.23.0"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
- __api_version__ = "v0"
7
+ __api_version__ = "v2"
@@ -2,37 +2,34 @@
2
2
 
3
3
  import asyncio
4
4
  import os
5
+ import sys
5
6
  from contextlib import suppress
6
7
  from logging.config import fileConfig
7
8
 
8
9
  from loguru import logger
10
+ from sqlalchemy import engine_from_config, pool
11
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
12
 
10
- # Allow nested event loops (needed for pytest-asyncio and other async contexts)
11
- # Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
12
- import sys
13
+ from alembic import context
14
+
15
+ from basic_memory.config import ConfigManager
16
+ from basic_memory.migration_loop import running_on_uvloop
13
17
 
14
- if sys.version_info < (3, 14):
18
+ # Allow nested event loops (needed for pytest-asyncio and other async contexts).
19
+ # nest_asyncio cannot patch a uvloop loop or Python 3.14+; in those cases we skip
20
+ # it and rely on the thread-based fallback in run_migrations_online() instead
21
+ # (see basic_memory.migration_loop for why uvloop must be detected up front).
22
+ if sys.version_info < (3, 14) and not running_on_uvloop():
15
23
  try:
16
24
  import nest_asyncio
17
25
 
18
26
  nest_asyncio.apply()
19
27
  except (ImportError, ValueError) as exc:
20
28
  # Trigger: nest_asyncio is absent (ImportError) or refuses to patch the
21
- # running loop (ValueError, e.g. the uvloop policy installed for the
22
- # Postgres backend - #831/#877).
23
- # Why: the uvloop ValueError is now an *expected* path on every Postgres
24
- # startup, so swallowing it silently hides a routine branch.
29
+ # running loop (ValueError).
25
30
  # Outcome: log at DEBUG (observable, not noisy) and fall through to the
26
31
  # thread-based migration fallback.
27
32
  logger.debug(f"nest_asyncio not applied ({exc!r}); using thread-based migration fallback")
28
- # For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
29
-
30
- from sqlalchemy import engine_from_config, pool
31
- from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
32
-
33
- from alembic import context
34
-
35
- from basic_memory.config import ConfigManager
36
33
 
37
34
  # Trigger: only set test env when actually running under pytest
38
35
  # Why: alembic/env.py is imported during normal operations (MCP server startup, migrations)
@@ -170,17 +167,30 @@ def _run_async_migrations_in_thread(connectable) -> None:
170
167
  future.result() # Wait for completion and re-raise any exceptions
171
168
 
172
169
 
173
- def _run_async_engine_migrations(connectable) -> None:
174
- """Run async-engine migrations with a running-loop fallback."""
170
+ def _loop_is_running() -> bool:
171
+ """Check if an event loop is currently running."""
175
172
  try:
173
+ asyncio.get_running_loop()
174
+ except RuntimeError:
175
+ # Trigger: no running loop (get_running_loop raises RuntimeError)
176
+ # Outcome: return False to indicate no loop is running
177
+ return False
178
+ return True
179
+
180
+
181
+ def _run_async_engine_migrations(connectable) -> None:
182
+ """Run async migrations, adapting to whether an event loop is already running."""
183
+
184
+ # Trigger: caller may be inside a running event loop (e.g. pytest-asyncio, uvicorn).
185
+ # Why: asyncio.run() raises RuntimeError when nested inside a running loop, so we
186
+ # detect the condition up front via _loop_is_running() instead of catching the error.
187
+ # Outcome: running-loop context -> thread fallback; no loop -> asyncio.run() directly.
188
+ if _loop_is_running():
189
+ # Can't nest asyncio.run() inside a running loop -> offload to a thread.
190
+ _run_async_migrations_in_thread(connectable)
191
+ else:
192
+ # No running loop: asyncio.run() is safe; let any unexpected errors propagate.
176
193
  _run_async_migrations_with_asyncio_run(connectable)
177
- except RuntimeError as e:
178
- if "cannot be called from a running event loop" in str(e):
179
- # We're in a running event loop (likely uvloop or Python 3.14+ tests).
180
- # Switch to a dedicated thread so Alembic can finish without nesting loops.
181
- _run_async_migrations_in_thread(connectable)
182
- else:
183
- raise
184
194
 
185
195
 
186
196
  def run_migrations_online() -> None:
@@ -0,0 +1,69 @@
1
+ """Dedupe observations and purge stale observation search rows.
2
+
3
+ Revision ID: 2d26b287813b
4
+ Revises: r1m2n3o4p5q6
5
+ Create Date: 2026-08-10 19:22:17.344875
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ from sqlalchemy import inspect
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "2d26b287813b"
17
+ down_revision: Union[str, None] = "r1m2n3o4p5q6"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def _table_exists(connection, table_name: str) -> bool:
23
+ """Return whether a runtime-managed table is present for this database."""
24
+ return table_name in inspect(connection).get_table_names()
25
+
26
+
27
+ def upgrade() -> None:
28
+ """Repair duplicate observation projections and their orphaned search rows."""
29
+ connection = op.get_bind()
30
+ if connection.dialect.name == "postgresql":
31
+ # PostgreSQL JSON has no equality operator, so its textual representation is the
32
+ # grouping key. GROUP BY treats NULL context and tags values consistently.
33
+ op.execute(
34
+ """
35
+ DELETE FROM observation
36
+ WHERE id NOT IN (
37
+ SELECT MIN(id)
38
+ FROM observation
39
+ GROUP BY entity_id, category, content, context, tags::text
40
+ )
41
+ """
42
+ )
43
+ else:
44
+ op.execute(
45
+ """
46
+ DELETE FROM observation
47
+ WHERE id NOT IN (
48
+ SELECT MIN(id)
49
+ FROM observation
50
+ GROUP BY entity_id, category, content, context, tags
51
+ )
52
+ """
53
+ )
54
+
55
+ # SQLite creates search_index at runtime rather than through Alembic. A fresh database
56
+ # therefore has no table or stale rows to purge during this migration.
57
+ if _table_exists(connection, "search_index"):
58
+ op.execute(
59
+ """
60
+ DELETE FROM search_index
61
+ WHERE type = 'observation'
62
+ AND id NOT IN (SELECT id FROM observation)
63
+ """
64
+ )
65
+
66
+
67
+ def downgrade() -> None:
68
+ """No-op: removed duplicate and orphaned projection rows cannot be reconstructed."""
69
+ pass
@@ -0,0 +1,52 @@
1
+ """Add note_file_vacate table
2
+
3
+ Revision ID: o8j9k0l1m2n3
4
+ Revises: n7i8j9k0l1m2
5
+ Create Date: 2026-07-26 12:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "o8j9k0l1m2n3"
17
+ down_revision: Union[str, None] = "n7i8j9k0l1m2"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Create note_file_vacate: durable proof a source path was vacated by a move.
24
+
25
+ The indexer keys on (project_id, file_path) to tell a move's lingering source object (skip
26
+ re-import) from a legitimate byte-identical copy (index as new) — see
27
+ basic-memory-cloud#1601.
28
+ """
29
+ op.create_table(
30
+ "note_file_vacate",
31
+ sa.Column("id", sa.Integer(), nullable=False),
32
+ sa.Column("project_id", sa.Integer(), nullable=False),
33
+ sa.Column("entity_id", sa.Integer(), nullable=True),
34
+ sa.Column("file_path", sa.String(), nullable=False),
35
+ sa.Column("file_checksum", sa.String(), nullable=True),
36
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
37
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
38
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="SET NULL"),
39
+ sa.PrimaryKeyConstraint("id"),
40
+ sa.UniqueConstraint(
41
+ "project_id", "file_path", name="uix_note_file_vacate_project_file_path"
42
+ ),
43
+ )
44
+ op.create_index(
45
+ "ix_note_file_vacate_project_id", "note_file_vacate", ["project_id"], unique=False
46
+ )
47
+
48
+
49
+ def downgrade() -> None:
50
+ """Drop note_file_vacate and its supporting index."""
51
+ op.drop_index("ix_note_file_vacate_project_id", table_name="note_file_vacate")
52
+ op.drop_table("note_file_vacate")
@@ -0,0 +1,132 @@
1
+ """Add vector index identity and readiness to the semantic manifest.
2
+
3
+ Revision ID: p9k0l1m2n3o4
4
+ Revises: o8j9k0l1m2n3
5
+ Create Date: 2026-07-21 00:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+ from sqlalchemy import inspect
14
+
15
+
16
+ revision: str = "p9k0l1m2n3o4"
17
+ down_revision: Union[str, None] = "o8j9k0l1m2n3"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def _create_note_file_vacate_table() -> None:
23
+ """Backfill the canonical o8 schema when the duplicate vector revision ran."""
24
+ op.create_table(
25
+ "note_file_vacate",
26
+ sa.Column("id", sa.Integer(), nullable=False),
27
+ sa.Column("project_id", sa.Integer(), nullable=False),
28
+ sa.Column("entity_id", sa.Integer(), nullable=True),
29
+ sa.Column("file_path", sa.String(), nullable=False),
30
+ sa.Column("file_checksum", sa.String(), nullable=True),
31
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
32
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
33
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="SET NULL"),
34
+ sa.PrimaryKeyConstraint("id"),
35
+ sa.UniqueConstraint(
36
+ "project_id",
37
+ "file_path",
38
+ name="uix_note_file_vacate_project_file_path",
39
+ ),
40
+ )
41
+ op.create_index(
42
+ "ix_note_file_vacate_project_id",
43
+ "note_file_vacate",
44
+ ["project_id"],
45
+ unique=False,
46
+ )
47
+
48
+
49
+ def upgrade() -> None:
50
+ """Reconcile both historical o8 schemas, then add vector manifest state.
51
+
52
+ For a short period, note-file vacates and vector manifest state shared the
53
+ o8 revision ID. A database can therefore arrive here with either schema
54
+ while Alembic reports the same predecessor. Restore the canonical o8
55
+ note-file table first, then idempotently apply the PostgreSQL vector state.
56
+ SQLite creates its derived vector table lazily at runtime, so it only needs
57
+ the missing canonical table repaired.
58
+ """
59
+ connection = op.get_bind()
60
+ inspector = inspect(connection)
61
+ tables = set(inspector.get_table_names())
62
+
63
+ if "note_file_vacate" not in tables:
64
+ _create_note_file_vacate_table()
65
+
66
+ if connection.dialect.name != "postgresql":
67
+ return
68
+ if "search_vector_chunks" not in tables:
69
+ return
70
+
71
+ op.execute("ALTER TABLE search_vector_chunks ADD COLUMN IF NOT EXISTS vector_index TEXT")
72
+ op.execute("ALTER TABLE search_vector_chunks ADD COLUMN IF NOT EXISTS embedding_status TEXT")
73
+ op.execute(
74
+ """
75
+ UPDATE search_vector_chunks
76
+ SET vector_index = 'pgvector'
77
+ WHERE vector_index IS NULL
78
+ """
79
+ )
80
+
81
+ if "search_vector_embeddings" in tables:
82
+ op.execute(
83
+ """
84
+ UPDATE search_vector_chunks AS chunks
85
+ SET embedding_status = CASE
86
+ WHEN EXISTS (
87
+ SELECT 1 FROM search_vector_embeddings AS embeddings
88
+ WHERE embeddings.chunk_id = chunks.id
89
+ ) THEN 'ready'
90
+ ELSE 'pending'
91
+ END
92
+ WHERE embedding_status IS NULL
93
+ """
94
+ )
95
+ else:
96
+ op.execute(
97
+ """
98
+ UPDATE search_vector_chunks
99
+ SET embedding_status = 'pending'
100
+ WHERE embedding_status IS NULL
101
+ """
102
+ )
103
+
104
+ op.execute("ALTER TABLE search_vector_chunks ALTER COLUMN vector_index SET NOT NULL")
105
+ op.execute("ALTER TABLE search_vector_chunks ALTER COLUMN embedding_status SET NOT NULL")
106
+ constraint_names = {
107
+ constraint.get("name")
108
+ for constraint in inspector.get_check_constraints("search_vector_chunks")
109
+ }
110
+ if "ck_search_vector_chunks_embedding_status" not in constraint_names:
111
+ op.create_check_constraint(
112
+ "ck_search_vector_chunks_embedding_status",
113
+ "search_vector_chunks",
114
+ "embedding_status IN ('pending', 'ready')",
115
+ )
116
+
117
+
118
+ def downgrade() -> None:
119
+ """Remove vector index manifest state from PostgreSQL."""
120
+ connection = op.get_bind()
121
+ if connection.dialect.name != "postgresql":
122
+ return
123
+ if "search_vector_chunks" not in inspect(connection).get_table_names():
124
+ return
125
+
126
+ op.drop_constraint(
127
+ "ck_search_vector_chunks_embedding_status",
128
+ "search_vector_chunks",
129
+ type_="check",
130
+ )
131
+ op.execute("ALTER TABLE search_vector_chunks DROP COLUMN IF EXISTS embedding_status")
132
+ op.execute("ALTER TABLE search_vector_chunks DROP COLUMN IF EXISTS vector_index")
@@ -0,0 +1,57 @@
1
+ """Add durable relation search refresh work.
2
+
3
+ Revision ID: q0l1m2n3o4p5
4
+ Revises: p9k0l1m2n3o4
5
+ Create Date: 2026-07-27 18:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+
15
+ revision: str = "q0l1m2n3o4p5"
16
+ down_revision: Union[str, None] = "p9k0l1m2n3o4"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Create durable work items for relation-derived search refreshes."""
23
+ op.create_table(
24
+ "relation_search_refresh",
25
+ sa.Column("id", sa.Integer(), nullable=False),
26
+ sa.Column("project_id", sa.Integer(), nullable=False),
27
+ sa.Column("entity_id", sa.Integer(), nullable=False),
28
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
29
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
30
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
31
+ sa.PrimaryKeyConstraint("id"),
32
+ )
33
+ op.create_index(
34
+ "ix_relation_search_refresh_project_id",
35
+ "relation_search_refresh",
36
+ ["project_id"],
37
+ unique=False,
38
+ )
39
+ op.create_index(
40
+ "ix_relation_search_refresh_entity_id",
41
+ "relation_search_refresh",
42
+ ["entity_id"],
43
+ unique=False,
44
+ )
45
+
46
+
47
+ def downgrade() -> None:
48
+ """Drop durable relation-search refresh work."""
49
+ op.drop_index(
50
+ "ix_relation_search_refresh_entity_id",
51
+ table_name="relation_search_refresh",
52
+ )
53
+ op.drop_index(
54
+ "ix_relation_search_refresh_project_id",
55
+ table_name="relation_search_refresh",
56
+ )
57
+ op.drop_table("relation_search_refresh")
@@ -0,0 +1,95 @@
1
+ """Add generation ownership to relation projections.
2
+
3
+ Revision ID: r1m2n3o4p5q6
4
+ Revises: q0l1m2n3o4p5
5
+ Create Date: 2026-08-09 17:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+
15
+ revision: str = "r1m2n3o4p5q6"
16
+ down_revision: Union[str, None] = "q0l1m2n3o4p5"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Backfill relation rows with their source note's accepted generation."""
23
+ op.add_column(
24
+ "relation_search_refresh",
25
+ sa.Column("publication_generation", sa.BigInteger(), nullable=True),
26
+ )
27
+ op.create_index(
28
+ "ix_relation_search_refresh_project_publication_generation",
29
+ "relation_search_refresh",
30
+ ["project_id", "publication_generation"],
31
+ unique=False,
32
+ )
33
+ op.add_column(
34
+ "relation",
35
+ sa.Column(
36
+ "generation",
37
+ sa.BigInteger(),
38
+ nullable=False,
39
+ server_default=sa.text("0"),
40
+ ),
41
+ )
42
+
43
+ connection = op.get_bind()
44
+ if connection.dialect.name == "postgresql":
45
+ op.execute(
46
+ """
47
+ UPDATE relation
48
+ SET generation = note_content.db_version
49
+ FROM note_content
50
+ WHERE relation.from_id = note_content.entity_id
51
+ AND relation.project_id = note_content.project_id
52
+ """
53
+ )
54
+ else:
55
+ op.execute(
56
+ """
57
+ UPDATE relation
58
+ SET generation = COALESCE(
59
+ (
60
+ SELECT note_content.db_version
61
+ FROM note_content
62
+ WHERE note_content.entity_id = relation.from_id
63
+ AND note_content.project_id = relation.project_id
64
+ ),
65
+ 0
66
+ )
67
+ """
68
+ )
69
+
70
+ op.create_index(
71
+ "ix_relation_project_from_generation",
72
+ "relation",
73
+ ["project_id", "from_id", "generation"],
74
+ unique=False,
75
+ )
76
+
77
+
78
+ def downgrade() -> None:
79
+ """Remove relation generation ownership."""
80
+ op.drop_index("ix_relation_project_from_generation", table_name="relation")
81
+ if op.get_bind().dialect.name == "postgresql":
82
+ op.drop_column("relation", "generation")
83
+ else:
84
+ with op.batch_alter_table("relation") as batch_op:
85
+ batch_op.drop_column("generation")
86
+
87
+ op.drop_index(
88
+ "ix_relation_search_refresh_project_publication_generation",
89
+ table_name="relation_search_refresh",
90
+ )
91
+ if op.get_bind().dialect.name == "postgresql":
92
+ op.drop_column("relation_search_refresh", "publication_generation")
93
+ else:
94
+ with op.batch_alter_table("relation_search_refresh") as batch_op:
95
+ batch_op.drop_column("publication_generation")
basic_memory/api/app.py CHANGED
@@ -5,7 +5,6 @@ from contextlib import asynccontextmanager
5
5
  from fastapi import FastAPI, HTTPException, Request
6
6
  from fastapi.exception_handlers import http_exception_handler
7
7
  from fastapi.responses import JSONResponse
8
- from fastapi.routing import APIRouter
9
8
  from loguru import logger
10
9
 
11
10
  from basic_memory import __version__ as version
@@ -20,14 +19,12 @@ from basic_memory.api.v2.routers import (
20
19
  prompt_router as v2_prompt,
21
20
  importer_router as v2_importer,
22
21
  schema_router as v2_schema,
23
- )
24
- from basic_memory.api.v2.routers.project_router import (
25
- add_project,
26
- list_projects,
27
- synchronize_projects,
22
+ inspect_router as v2_inspect,
28
23
  )
29
24
  import logfire
25
+ from basic_memory.index.note_content_materialization import drain_pending_materializations
30
26
  from basic_memory.config import init_api_logging
27
+ from basic_memory.index.local_schedulers import drain_background_tasks
31
28
  from basic_memory.services.exceptions import EntityAlreadyExistsError
32
29
  from basic_memory.services.initialization import initialize_app
33
30
  from basic_memory.workspace_context import (
@@ -67,10 +64,10 @@ async def lifespan(app: FastAPI): # pragma: no cover
67
64
  app.state.session_maker = session_maker
68
65
  logger.info("Database connections cached in app state")
69
66
 
70
- # Create and start sync coordinator (lifecycle centralized in coordinator)
71
- sync_coordinator = container.create_sync_coordinator()
72
- await sync_coordinator.start()
73
- app.state.sync_coordinator = sync_coordinator
67
+ # Create and start local watch coordinator (lifecycle centralized in coordinator)
68
+ watch_coordinator = container.create_watch_coordinator()
69
+ await watch_coordinator.start()
70
+ app.state.watch_coordinator = watch_coordinator
74
71
 
75
72
  # Proceed with startup
76
73
  yield
@@ -82,7 +79,13 @@ async def lifespan(app: FastAPI): # pragma: no cover
82
79
  mode=container.mode.name.lower(),
83
80
  ):
84
81
  logger.info("Shutting down Basic Memory API")
85
- await sync_coordinator.stop()
82
+ await watch_coordinator.stop()
83
+ # A local note write returns 202 before its markdown file is written;
84
+ # SIGTERM can land while that materialization (and the vector sync /
85
+ # relation resolution it schedules) is still queued. Drain both queues
86
+ # before the engine closes so an accepted write is never lost.
87
+ await drain_pending_materializations()
88
+ await drain_background_tasks()
86
89
  await container.shutdown_database()
87
90
 
88
91
 
@@ -129,19 +132,12 @@ app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
129
132
  app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
130
133
  app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
131
134
  app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
135
+ app.include_router(v2_inspect, prefix="/v2/projects/{project_id}")
132
136
  app.include_router(v2_project, prefix="/v2")
133
137
 
134
138
  # Legacy web app proxy paths (compat with /proxy/projects/projects)
135
139
  app.include_router(v2_project, prefix="/proxy/projects")
136
140
 
137
- # Legacy v1 compat: older CLI versions (v0.18.0 and earlier) call /projects/...
138
- # Using router mount causes 307 redirect which proxy doesn't follow, so add explicit routes
139
- legacy_router = APIRouter(tags=["legacy"])
140
- legacy_router.add_api_route("/projects/projects", list_projects, methods=["GET"])
141
- legacy_router.add_api_route("/projects/projects", add_project, methods=["POST"])
142
- legacy_router.add_api_route("/projects/config/sync", synchronize_projects, methods=["POST"])
143
- app.include_router(legacy_router)
144
-
145
141
  # V2 routers are the only public API surface
146
142
 
147
143