basic-memory 0.23.3.dev8__py3-none-any.whl → 0.24.0__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 (399) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/alembic.ini +3 -1
  3. basic_memory/alembic/env.py +7 -0
  4. basic_memory/alembic/migrations.py +86 -2
  5. basic_memory/alembic/versions/0040_vector_chunk_location_columns.py +114 -0
  6. basic_memory/alembic/versions/0041_index_vector_chunk_location.py +48 -0
  7. basic_memory/alembic/versions/0042_nfc_file_paths.py +291 -0
  8. basic_memory/alembic/versions/bcdbd5a942ca_relation_to_id_set_null_on_delete.py +77 -0
  9. basic_memory/alembic/versions/d2e3f4a5b6c7_add_script_ngrams_to_full_text_search.py +123 -0
  10. basic_memory/alembic/versions/s2p3e4c5w6k7_add_project_partition_position.py +88 -0
  11. basic_memory/alembic/versions/t3n4o5t6e7s8_add_note_section_table.py +64 -0
  12. basic_memory/alembic/versions/u4t5e6m7p8o9_add_memory_time_index_table.py +103 -0
  13. basic_memory/alembic/versions/v5o6b7s8d9e0_add_observation_duplicate_index.py +49 -0
  14. basic_memory/alembic/versions/w6k7i8n9d0a1_search_index_row_kind_uniqueness.py +93 -0
  15. basic_memory/alembic/versions/w6r7e8a9d0y1_add_project_last_indexed_at.py +51 -0
  16. basic_memory/alembic/versions/x7d8e9f0a1b2_add_entity_vector_sync_deferred_at.py +51 -0
  17. basic_memory/alembic/versions/y8f9a0b1c2d3_merge_row_kind_and_deferral_heads.py +36 -0
  18. basic_memory/alembic/versions/z9a0b1c2d3e4_add_entity_sync_checksum.py +43 -0
  19. basic_memory/api/app.py +23 -0
  20. basic_memory/api/container.py +7 -2
  21. basic_memory/api/v2/routers/__init__.py +9 -0
  22. basic_memory/api/v2/routers/accepted_content_router.py +58 -0
  23. basic_memory/api/v2/routers/directory_router.py +47 -116
  24. basic_memory/api/v2/routers/importer_router.py +3 -3
  25. basic_memory/api/v2/routers/knowledge_router.py +447 -289
  26. basic_memory/api/v2/routers/memory_router.py +43 -15
  27. basic_memory/api/v2/routers/note_write_router.py +139 -0
  28. basic_memory/api/v2/routers/project_router.py +48 -16
  29. basic_memory/api/v2/routers/prompt_router.py +2 -2
  30. basic_memory/api/v2/routers/resource_router.py +167 -118
  31. basic_memory/api/v2/routers/schema_router.py +157 -55
  32. basic_memory/api/v2/routers/scoped_search_router.py +117 -0
  33. basic_memory/api/v2/routers/search_router.py +155 -138
  34. basic_memory/api/v2/utils.py +137 -2
  35. basic_memory/cli/app.py +47 -24
  36. basic_memory/cli/auth.py +11 -10
  37. basic_memory/cli/auto_update.py +7 -3
  38. basic_memory/cli/commands/__init__.py +18 -1
  39. basic_memory/cli/commands/cache.py +37 -0
  40. basic_memory/cli/commands/ci.py +11 -10
  41. basic_memory/cli/commands/cloud/api_client.py +1 -1
  42. basic_memory/cli/commands/cloud/bisync_commands.py +5 -4
  43. basic_memory/cli/commands/cloud/core_commands.py +25 -20
  44. basic_memory/cli/commands/cloud/project_sync.py +153 -79
  45. basic_memory/cli/commands/cloud/rclone_commands.py +5 -4
  46. basic_memory/cli/commands/cloud/rclone_config.py +4 -3
  47. basic_memory/cli/commands/cloud/rclone_installer.py +10 -6
  48. basic_memory/cli/commands/cloud/restore.py +23 -16
  49. basic_memory/cli/commands/cloud/shares.py +42 -31
  50. basic_memory/cli/commands/cloud/snapshot.py +50 -39
  51. basic_memory/cli/commands/cloud/upload_command.py +18 -12
  52. basic_memory/cli/commands/cloud/webdav.py +325 -101
  53. basic_memory/cli/commands/cloud/webdav_transfer.py +134 -39
  54. basic_memory/cli/commands/cloud/workspace.py +12 -9
  55. basic_memory/cli/commands/command_utils.py +39 -3
  56. basic_memory/cli/commands/config.py +35 -10
  57. basic_memory/cli/commands/db.py +161 -42
  58. basic_memory/cli/commands/doctor.py +3 -2
  59. basic_memory/cli/commands/format.py +5 -4
  60. basic_memory/cli/commands/hook.py +243 -43
  61. basic_memory/cli/commands/import_chatgpt.py +4 -1
  62. basic_memory/cli/commands/import_claude_conversations.py +4 -1
  63. basic_memory/cli/commands/import_claude_projects.py +4 -1
  64. basic_memory/cli/commands/import_document.py +137 -0
  65. basic_memory/cli/commands/import_memory_json.py +2 -1
  66. basic_memory/cli/commands/install.py +487 -0
  67. basic_memory/cli/commands/man.py +189 -6
  68. basic_memory/cli/commands/mcp.py +38 -1
  69. basic_memory/cli/commands/okf.py +77 -0
  70. basic_memory/cli/commands/orphans.py +4 -3
  71. basic_memory/cli/commands/posix.py +1024 -0
  72. basic_memory/cli/commands/project.py +560 -156
  73. basic_memory/cli/commands/prune.py +113 -0
  74. basic_memory/cli/commands/schema.py +17 -14
  75. basic_memory/cli/commands/status.py +68 -20
  76. basic_memory/cli/commands/tool.py +107 -24
  77. basic_memory/cli/commands/wiki.py +421 -0
  78. basic_memory/cli/main.py +6 -0
  79. basic_memory/cli/markup.py +19 -0
  80. basic_memory/config.py +134 -15
  81. basic_memory/config_models.py +47 -35
  82. basic_memory/data/pi/package/LICENSE +661 -0
  83. basic_memory/data/pi/package/README.md +143 -0
  84. basic_memory/data/pi/package/SKILLS.md +33 -0
  85. basic_memory/data/pi/package/extensions/bm-cli.ts +103 -0
  86. basic_memory/data/pi/package/extensions/config.ts +210 -0
  87. basic_memory/data/pi/package/extensions/index.ts +550 -0
  88. basic_memory/data/pi/package/extensions/session.ts +149 -0
  89. basic_memory/data/pi/package/package-lock.json +2973 -0
  90. basic_memory/data/pi/package/package.json +59 -0
  91. basic_memory/data/pi/package/skill-references/manifest.json +22 -0
  92. basic_memory/data/pi/package/skill-references/memory-capture/REFERENCE.md +284 -0
  93. basic_memory/data/pi/package/skill-references/memory-continue/REFERENCE.md +165 -0
  94. basic_memory/data/pi/package/skill-references/memory-notes/REFERENCE.md +401 -0
  95. basic_memory/data/pi/package/skill-references/memory-tasks/REFERENCE.md +199 -0
  96. basic_memory/data/pi/package/skills/basic-memory-pi/SKILL.md +33 -0
  97. basic_memory/data/pi/package/skills/basic-memory-pi-setup/SKILL.md +71 -0
  98. basic_memory/data/skills/memory-agents/SKILL.md +170 -0
  99. basic_memory/data/skills/memory-capture/SKILL.md +277 -0
  100. basic_memory/data/skills/memory-comark/SKILL.md +204 -0
  101. basic_memory/data/skills/memory-comark/references/components.md +740 -0
  102. basic_memory/data/skills/memory-continue/SKILL.md +158 -0
  103. basic_memory/data/skills/memory-curate/SKILL.md +236 -0
  104. basic_memory/data/skills/memory-defrag/SKILL.md +135 -0
  105. basic_memory/data/skills/memory-ingest/SKILL.md +320 -0
  106. basic_memory/data/skills/memory-lifecycle/SKILL.md +182 -0
  107. basic_memory/data/skills/memory-literary-analysis/SKILL.md +744 -0
  108. basic_memory/data/skills/memory-metadata-search/SKILL.md +209 -0
  109. basic_memory/data/skills/memory-notes/SKILL.md +394 -0
  110. basic_memory/data/skills/memory-okf/SKILL.md +135 -0
  111. basic_memory/data/skills/memory-onboarding/SKILL.md +142 -0
  112. basic_memory/data/skills/memory-onboarding/references/assistant-setup.md +72 -0
  113. basic_memory/data/skills/memory-onboarding/references/conventions.md +118 -0
  114. basic_memory/data/skills/memory-onboarding/references/domain-playbooks.md +159 -0
  115. basic_memory/data/skills/memory-onboarding/references/schema-guide.md +136 -0
  116. basic_memory/data/skills/memory-quest/SKILL.md +129 -0
  117. basic_memory/data/skills/memory-reflect/SKILL.md +154 -0
  118. basic_memory/data/skills/memory-research/SKILL.md +229 -0
  119. basic_memory/data/skills/memory-schema/SKILL.md +247 -0
  120. basic_memory/data/skills/memory-tasks/SKILL.md +192 -0
  121. basic_memory/data/tau/extension/Basic Memory.py +10 -0
  122. basic_memory/data/tau/extension/README.md +322 -0
  123. basic_memory/data/tau/extension/bridge.py +171 -0
  124. basic_memory/data/tau/extension/continuity.py +681 -0
  125. basic_memory/data/tau/extension/extension.py +106 -0
  126. basic_memory/data/tau/extension/knowledge.py +158 -0
  127. basic_memory/data/tau/extension/privacy.py +27 -0
  128. basic_memory/data/tau/extension/pyproject.toml +29 -0
  129. basic_memory/data/tau/extension/results.py +51 -0
  130. basic_memory/data/tau/extension/schemas/coding-session.md +66 -0
  131. basic_memory/data/tau/extension/schemas/decision.md +43 -0
  132. basic_memory/data/tau/extension/schemas/session.md +58 -0
  133. basic_memory/data/tau/extension/schemas/task.md +39 -0
  134. basic_memory/data/tau/extension/uv.lock +1060 -0
  135. basic_memory/data/tau/prompts/bm-decide.md +12 -0
  136. basic_memory/data/tau/prompts/bm-plan.md +12 -0
  137. basic_memory/data/tau/prompts/bm-resume.md +12 -0
  138. basic_memory/data/tau/prompts/bm-wrap-up.md +15 -0
  139. basic_memory/data/tau/skills/basic-memory-setup/SKILL.md +290 -0
  140. basic_memory/data/tau/skills/memory-continue/SKILL.md +158 -0
  141. basic_memory/data/tau/skills/memory-notes/SKILL.md +394 -0
  142. basic_memory/data/tau/skills/memory-schema/SKILL.md +247 -0
  143. basic_memory/data/tau/skills/memory-tasks/SKILL.md +192 -0
  144. basic_memory/db.py +7 -4
  145. basic_memory/deps/__init__.py +8 -2
  146. basic_memory/deps/db.py +7 -0
  147. basic_memory/deps/read_cache.py +9 -27
  148. basic_memory/deps/repositories.py +16 -0
  149. basic_memory/deps/services.py +42 -2
  150. basic_memory/document_ingestion/__init__.py +30 -0
  151. basic_memory/document_ingestion/bounded_process.py +166 -0
  152. basic_memory/document_ingestion/csv_extractor.py +184 -0
  153. basic_memory/document_ingestion/local_runtime.py +391 -0
  154. basic_memory/document_ingestion/markitdown_extractor.py +240 -0
  155. basic_memory/document_ingestion/markitdown_worker.py +186 -0
  156. basic_memory/document_ingestion/pdf_inspector.py +183 -0
  157. basic_memory/document_ingestion/pdf_inspector_worker.py +159 -0
  158. basic_memory/document_ingestion/raw_document.py +563 -0
  159. basic_memory/document_ingestion/worker_limits.py +35 -0
  160. basic_memory/file_types.py +155 -0
  161. basic_memory/file_utils.py +67 -17
  162. basic_memory/hooks/adapters/__init__.py +2 -1
  163. basic_memory/hooks/adapters/pi.py +35 -0
  164. basic_memory/ignore_utils.py +48 -10
  165. basic_memory/importers/base.py +1 -1
  166. basic_memory/importers/chatgpt_importer.py +54 -6
  167. basic_memory/importers/claude_conversations_importer.py +1 -1
  168. basic_memory/importers/claude_projects_importer.py +3 -3
  169. basic_memory/index/filesystem.py +7 -1
  170. basic_memory/index/local_dependencies.py +83 -17
  171. basic_memory/index/local_moves.py +21 -7
  172. basic_memory/index/local_notes.py +66 -16
  173. basic_memory/index/local_project.py +151 -18
  174. basic_memory/index/local_prune.py +108 -0
  175. basic_memory/index/local_runtime.py +41 -18
  176. basic_memory/index/local_schedulers.py +30 -7
  177. basic_memory/index/local_wiki_projection.py +395 -0
  178. basic_memory/index/note_content_materialization.py +232 -22
  179. basic_memory/index/watch_coordinator.py +2 -2
  180. basic_memory/index/watch_service.py +62 -6
  181. basic_memory/indexing/accepted_note_mutation_runner.py +467 -68
  182. basic_memory/indexing/accepted_note_write_runner.py +139 -25
  183. basic_memory/indexing/batch_indexer.py +414 -107
  184. basic_memory/indexing/change_detector.py +38 -13
  185. basic_memory/indexing/change_planning.py +12 -8
  186. basic_memory/indexing/directory_delete_runner.py +29 -1
  187. basic_memory/indexing/embedding_index_planning.py +45 -1
  188. basic_memory/indexing/file_index_checking.py +64 -22
  189. basic_memory/indexing/file_index_planning.py +45 -4
  190. basic_memory/indexing/file_indexer.py +32 -8
  191. basic_memory/indexing/index_file_runner.py +21 -7
  192. basic_memory/indexing/input_file_adaptation.py +12 -1
  193. basic_memory/indexing/models.py +189 -97
  194. basic_memory/indexing/note_content_reconciler.py +15 -1
  195. basic_memory/indexing/note_file_delete_runner.py +34 -40
  196. basic_memory/indexing/note_materialization_runner.py +14 -0
  197. basic_memory/indexing/project_index_coordinator.py +31 -3
  198. basic_memory/indexing/relation_persistence.py +207 -23
  199. basic_memory/indexing/relation_resolution.py +72 -15
  200. basic_memory/indexing/wiki_projector.py +946 -0
  201. basic_memory/man/__init__.py +718 -0
  202. basic_memory/man/bm.1 +27 -2
  203. basic_memory/man/man1/apropos(1).md +53 -0
  204. basic_memory/man/man1/cat(1).md +73 -0
  205. basic_memory/man/man1/find(1).md +174 -0
  206. basic_memory/man/man1/grep(1).md +77 -0
  207. basic_memory/man/man1/head(1).md +52 -0
  208. basic_memory/man/man1/ls(1).md +68 -0
  209. basic_memory/man/man1/okf-check(1).md +56 -0
  210. basic_memory/man/man1/okf-export(1).md +88 -0
  211. basic_memory/man/man1/tail(1).md +53 -0
  212. basic_memory/man/man1/tree(1).md +56 -0
  213. basic_memory/man/man3/basic-memory-diagnostics(3).md +68 -0
  214. basic_memory/man/man3/build-context(3).md +91 -0
  215. basic_memory/man/man3/chatgpt-fetch(3).md +58 -0
  216. basic_memory/man/man3/chatgpt-search(3).md +60 -0
  217. basic_memory/man/man3/cloud-info(3).md +46 -0
  218. basic_memory/man/man3/create-memory-project(3).md +80 -0
  219. basic_memory/man/man3/delete-note(3).md +73 -0
  220. basic_memory/man/man3/delete-project(3).md +79 -0
  221. basic_memory/man/man3/edit-note(3).md +122 -0
  222. basic_memory/man/man3/list-directory(3).md +73 -0
  223. basic_memory/man/man3/list-memory-projects(3).md +75 -0
  224. basic_memory/man/man3/list-workspaces(3).md +67 -0
  225. basic_memory/man/man3/move-note(3).md +75 -0
  226. basic_memory/man/man3/read-content(3).md +62 -0
  227. basic_memory/man/man3/read-note(3).md +131 -0
  228. basic_memory/man/man3/recent-activity(3).md +86 -0
  229. basic_memory/man/man3/schema-diff(3).md +66 -0
  230. basic_memory/man/man3/schema-infer(3).md +67 -0
  231. basic_memory/man/man3/schema-validate(3).md +84 -0
  232. basic_memory/man/man3/search-notes(3).md +164 -0
  233. basic_memory/man/man3/view-note(3).md +55 -0
  234. basic_memory/man/man3/write-note(3).md +131 -0
  235. basic_memory/man/man7/comark(7).md +169 -0
  236. basic_memory/markdown/__init__.py +20 -0
  237. basic_memory/markdown/entity_parser.py +106 -18
  238. basic_memory/markdown/line_scanning.py +84 -0
  239. basic_memory/markdown/note_lock.py +23 -0
  240. basic_memory/markdown/path_links.py +81 -0
  241. basic_memory/markdown/plugins.py +11 -0
  242. basic_memory/markdown/schemas.py +32 -2
  243. basic_memory/markdown/sections.py +527 -0
  244. basic_memory/markdown/temporal_qualifier.py +354 -0
  245. basic_memory/markdown/utils.py +12 -1
  246. basic_memory/mcp/async_client.py +101 -17
  247. basic_memory/mcp/clients/__init__.py +2 -1
  248. basic_memory/mcp/clients/knowledge.py +188 -17
  249. basic_memory/mcp/clients/memory.py +4 -0
  250. basic_memory/mcp/clients/project.py +2 -2
  251. basic_memory/mcp/clients/search.py +75 -2
  252. basic_memory/mcp/container.py +2 -2
  253. basic_memory/mcp/empty_results.py +39 -0
  254. basic_memory/mcp/index_readiness.py +37 -0
  255. basic_memory/mcp/note_reads.py +69 -4
  256. basic_memory/mcp/project_context.py +1076 -109
  257. basic_memory/mcp/project_context_identifiers.py +127 -29
  258. basic_memory/mcp/prompts/continue_conversation.py +6 -9
  259. basic_memory/mcp/prompts/recent_activity.py +19 -29
  260. basic_memory/mcp/resources/__init__.py +3 -1
  261. basic_memory/mcp/resources/man.py +83 -0
  262. basic_memory/mcp/resources/notes.py +148 -0
  263. basic_memory/mcp/resources/project_info.py +38 -5
  264. basic_memory/mcp/server.py +70 -37
  265. basic_memory/mcp/tools/__init__.py +9 -0
  266. basic_memory/mcp/tools/basic_memory_diagnostics.py +67 -1
  267. basic_memory/mcp/tools/build_context.py +113 -18
  268. basic_memory/mcp/tools/chatgpt_tools.py +15 -6
  269. basic_memory/mcp/tools/delete_note.py +158 -184
  270. basic_memory/mcp/tools/edit_note.py +440 -136
  271. basic_memory/mcp/tools/move_note.py +258 -151
  272. basic_memory/mcp/tools/posix_tools.py +1490 -0
  273. basic_memory/mcp/tools/project_management.py +52 -41
  274. basic_memory/mcp/tools/read_content.py +31 -10
  275. basic_memory/mcp/tools/read_note.py +246 -144
  276. basic_memory/mcp/tools/recent_activity.py +39 -38
  277. basic_memory/mcp/tools/schema.py +19 -9
  278. basic_memory/mcp/tools/search.py +784 -326
  279. basic_memory/mcp/tools/utils.py +257 -80
  280. basic_memory/mcp/tools/view_note.py +14 -9
  281. basic_memory/mcp/tools/write_note.py +544 -103
  282. basic_memory/mcp/workspace_project_index.py +112 -28
  283. basic_memory/models/__init__.py +6 -1
  284. basic_memory/models/knowledge.py +284 -18
  285. basic_memory/models/project.py +93 -1
  286. basic_memory/models/search.py +146 -7
  287. basic_memory/okf/__init__.py +1 -0
  288. basic_memory/okf/export.py +202 -0
  289. basic_memory/okf/render.py +415 -0
  290. basic_memory/okf/validation.py +225 -0
  291. basic_memory/picoschema/__init__.py +9 -1
  292. basic_memory/picoschema/resolver.py +59 -33
  293. basic_memory/read_cache/__init__.py +25 -18
  294. basic_memory/read_cache/contract.py +140 -70
  295. basic_memory/read_cache/invalidation.py +102 -26
  296. basic_memory/read_cache/keys.py +40 -58
  297. basic_memory/read_cache/lifecycle.py +2 -5
  298. basic_memory/read_cache/policy.py +12 -4
  299. basic_memory/read_cache/redis.py +228 -163
  300. basic_memory/read_cache/routes.py +354 -0
  301. basic_memory/repository/__init__.py +9 -0
  302. basic_memory/repository/accepted_note_repositories.py +13 -1
  303. basic_memory/repository/accepted_note_search_repository.py +89 -10
  304. basic_memory/repository/embedding_provider_factory.py +9 -7
  305. basic_memory/repository/entity_repository.py +106 -19
  306. basic_memory/repository/fastembed_rerank_provider.py +12 -2
  307. basic_memory/repository/memory_time_index_repository.py +147 -0
  308. basic_memory/repository/metadata_filters.py +203 -54
  309. basic_memory/repository/milvus_index.py +76 -58
  310. basic_memory/repository/note_section_repository.py +121 -0
  311. basic_memory/repository/note_type_filters.py +80 -0
  312. basic_memory/repository/observation_repository.py +47 -12
  313. basic_memory/repository/pgvector_index.py +328 -163
  314. basic_memory/repository/postgres_fts_chunks.py +19 -0
  315. basic_memory/repository/postgres_search_query.py +1053 -0
  316. basic_memory/repository/postgres_search_repository.py +168 -921
  317. basic_memory/repository/project_repository.py +142 -2
  318. basic_memory/repository/relation_repository.py +149 -10
  319. basic_memory/repository/script_ngrams.py +127 -0
  320. basic_memory/repository/search_filters.py +345 -0
  321. basic_memory/repository/search_index_row.py +60 -1
  322. basic_memory/repository/search_query.py +52 -0
  323. basic_memory/repository/search_reader.py +1647 -0
  324. basic_memory/repository/search_refresh_race.py +39 -0
  325. basic_memory/repository/search_repository.py +100 -27
  326. basic_memory/repository/search_repository_base.py +355 -1207
  327. basic_memory/repository/search_scope.py +61 -0
  328. basic_memory/repository/search_trace.py +20 -22
  329. basic_memory/repository/semantic_chunking.py +104 -69
  330. basic_memory/repository/semantic_runtime.py +40 -0
  331. basic_memory/repository/semantic_vector_index.py +60 -19
  332. basic_memory/repository/semantic_vector_index_factory.py +5 -5
  333. basic_memory/repository/semantic_vector_sync.py +184 -36
  334. basic_memory/repository/sqlite_search_query.py +715 -0
  335. basic_memory/repository/sqlite_search_repository.py +70 -824
  336. basic_memory/repository/sqlite_vec_index.py +112 -55
  337. basic_memory/repository/temporal_filters.py +150 -0
  338. basic_memory/runtime/accepted_note_changes.py +7 -4
  339. basic_memory/runtime/cleanup.py +16 -57
  340. basic_memory/runtime/job_payloads.py +7 -0
  341. basic_memory/runtime/note_content_deletes.py +2 -0
  342. basic_memory/runtime/note_file_guards.py +6 -1
  343. basic_memory/runtime/note_materialization.py +26 -2
  344. basic_memory/runtime/note_materialization_planning.py +4 -2
  345. basic_memory/runtime/note_move.py +2 -1
  346. basic_memory/runtime/note_object_metadata.py +49 -41
  347. basic_memory/runtime/project_partition.py +83 -0
  348. basic_memory/runtime/vector_sync.py +6 -1
  349. basic_memory/schemas/__init__.py +72 -0
  350. basic_memory/schemas/base.py +5 -3
  351. basic_memory/schemas/directory.py +3 -0
  352. basic_memory/schemas/document.py +1064 -0
  353. basic_memory/schemas/document_page_map.py +57 -0
  354. basic_memory/schemas/inspect.py +8 -18
  355. basic_memory/schemas/memory.py +16 -0
  356. basic_memory/schemas/project_index.py +12 -1
  357. basic_memory/schemas/project_info.py +26 -2
  358. basic_memory/schemas/project_readiness.py +162 -0
  359. basic_memory/schemas/request.py +1 -1
  360. basic_memory/schemas/search.py +186 -3
  361. basic_memory/schemas/v2/accepted_content.py +35 -0
  362. basic_memory/schemas/v2/entity.py +26 -0
  363. basic_memory/schemas/v2/note_placement.py +53 -0
  364. basic_memory/schemas/v2/note_write.py +80 -0
  365. basic_memory/services/bulk_link_resolver.py +57 -6
  366. basic_memory/services/context_service.py +17 -3
  367. basic_memory/services/directory_deletes.py +1 -0
  368. basic_memory/services/directory_moves.py +150 -0
  369. basic_memory/services/directory_service.py +13 -0
  370. basic_memory/services/entity_service.py +57 -189
  371. basic_memory/services/file_service.py +79 -60
  372. basic_memory/services/initialization.py +41 -16
  373. basic_memory/services/link_resolver.py +131 -34
  374. basic_memory/services/note_authorship.py +135 -0
  375. basic_memory/services/note_content_reads.py +42 -4
  376. basic_memory/services/note_content_writes.py +469 -115
  377. basic_memory/services/note_placement.py +127 -0
  378. basic_memory/services/note_preparation.py +237 -58
  379. basic_memory/services/note_write_outcomes.py +70 -0
  380. basic_memory/services/project_readiness.py +356 -0
  381. basic_memory/services/project_service.py +159 -29
  382. basic_memory/services/read_cache_changes.py +85 -0
  383. basic_memory/services/retrieval_inspect.py +4 -7
  384. basic_memory/services/schema_validation_hooks.py +70 -0
  385. basic_memory/services/scoped_search_service.py +156 -0
  386. basic_memory/services/search_guidance.py +36 -0
  387. basic_memory/services/search_service.py +442 -250
  388. basic_memory/templates/prompts/continue_conversation.hbs +6 -49
  389. basic_memory/templates/prompts/search.hbs +9 -58
  390. basic_memory/temporal.py +1165 -0
  391. basic_memory/utils.py +134 -14
  392. {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/METADATA +33 -29
  393. basic_memory-0.24.0.dist-info/RECORD +576 -0
  394. {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/WHEEL +1 -1
  395. basic_memory/mcp/prompts/utils.py +0 -161
  396. basic_memory/read_cache/read_through.py +0 -188
  397. basic_memory-0.23.3.dev8.dist-info/RECORD +0 -397
  398. {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/entry_points.txt +0 -0
  399. {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.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.23.2"
4
+ __version__ = "0.24.0"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v2"
@@ -28,7 +28,9 @@ prepend_sys_path = .
28
28
 
29
29
  # set to 'true' to run the environment during
30
30
  # the 'revision' command, regardless of autogenerate
31
- # revision_environment = false
31
+ # Basic Memory: env.py assigns sequential revision IDs (migrations.assign_sequential_revision_ids),
32
+ # so hand-written revisions must run it too, not only --autogenerate ones.
33
+ revision_environment = true
32
34
 
33
35
  # set to 'true' to allow .pyc and .pyo files without
34
36
  # a source .py file to be detected as revisions in the
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
10
10
 
11
11
  from alembic import context
12
12
 
13
+ from basic_memory.alembic.migrations import assign_sequential_revision_ids
13
14
  from basic_memory.config import ConfigManager
14
15
 
15
16
  # Constraint: never apply nest_asyncio here. It replaces asyncio.run and
@@ -88,6 +89,9 @@ def run_migrations_offline() -> None:
88
89
  dialect_opts={"paramstyle": "named"},
89
90
  include_object=include_object,
90
91
  render_as_batch=True,
92
+ # `alembic revision` (the `just migration` recipe) names each new revision by its
93
+ # schema version instead of a random ID; see migrations.LAST_UNNUMBERED_REVISION.
94
+ process_revision_directives=assign_sequential_revision_ids,
91
95
  )
92
96
 
93
97
  with context.begin_transaction():
@@ -102,6 +106,9 @@ def do_run_migrations(connection):
102
106
  include_object=include_object,
103
107
  render_as_batch=True,
104
108
  compare_type=True,
109
+ # `alembic revision` (the `just migration` recipe) names each new revision by its
110
+ # schema version instead of a random ID; see migrations.LAST_UNNUMBERED_REVISION.
111
+ process_revision_directives=assign_sequential_revision_ids,
105
112
  )
106
113
  with context.begin_transaction():
107
114
  context.run_migrations()
@@ -1,9 +1,23 @@
1
1
  """Functions for managing database migrations."""
2
2
 
3
3
  from pathlib import Path
4
- from loguru import logger
5
- from alembic.config import Config
4
+
6
5
  from alembic import command
6
+ from alembic.config import Config
7
+ from alembic.operations.ops import MigrationScript
8
+ from alembic.runtime.migration import MigrationContext
9
+ from alembic.script import ScriptDirectory
10
+ from loguru import logger
11
+
12
+ # --- Sequential revision IDs ---
13
+ # Revisions up to and including this one carry unnumbered IDs: some are random hex from
14
+ # Alembic, many were hand-picked (z9a0b1c2d3e4 itself is not hex). Every revision after it
15
+ # is named by its schema version, zero-padded to four digits: "0040" revises
16
+ # "z9a0b1c2d3e4", "0041" revises "0040". A version number orders schemas at a glance
17
+ # (cloud tenants report which one they run), where an unnumbered ID only says "different".
18
+ # The padding keeps migration files in version order in a directory listing.
19
+ LAST_UNNUMBERED_REVISION = "z9a0b1c2d3e4"
20
+ REVISION_ID_DIGITS = 4
7
21
 
8
22
 
9
23
  def get_alembic_config() -> Config: # pragma: no cover
@@ -16,6 +30,76 @@ def get_alembic_config() -> Config: # pragma: no cover
16
30
  return config
17
31
 
18
32
 
33
+ def get_script_directory() -> ScriptDirectory:
34
+ """Read the migration graph shipped with this package."""
35
+ config = Config()
36
+ config.set_main_option("script_location", str(Path(__file__).parent))
37
+ return ScriptDirectory.from_config(config)
38
+
39
+
40
+ def schema_version(revision: str, script: ScriptDirectory | None = None) -> int:
41
+ """Return the schema version a revision produces: how many revisions it applies.
42
+
43
+ The count is taken from the graph, so the unnumbered revisions get a number too
44
+ (z9a0b1c2d3e4 is 39). For sequential revisions it equals int(revision): "0040" is 40.
45
+ """
46
+ script = script or get_script_directory()
47
+
48
+ # Trigger: Alembic resolves unique prefixes ("z9a" finds z9a0b1c2d3e4).
49
+ # Why: a version must name exactly one revision; a prefix that happens to resolve
50
+ # today becomes ambiguous when a later revision shares it.
51
+ # Outcome: only a full, known revision ID gets a version.
52
+ if revision not in {known.revision for known in script.walk_revisions()}:
53
+ raise ValueError(f"unknown alembic revision: {revision!r}")
54
+
55
+ return sum(1 for _ in script.walk_revisions("base", revision))
56
+
57
+
58
+ def next_revision_id(script: ScriptDirectory) -> str:
59
+ """Return the ID for a new revision on top of the single current head."""
60
+ heads = script.get_heads()
61
+ if len(heads) != 1:
62
+ raise ValueError(
63
+ f"alembic has {len(heads)} heads {sorted(heads)}; if two branches added the "
64
+ "same numbered revision, renumber yours to the next ID and point its "
65
+ "down_revision at the new head (no merge revisions)"
66
+ )
67
+ return f"{schema_version(heads[0], script) + 1:0{REVISION_ID_DIGITS}d}"
68
+
69
+
70
+ def assign_sequential_revision_ids(
71
+ context: MigrationContext,
72
+ revision: object,
73
+ directives: list[MigrationScript],
74
+ ) -> None:
75
+ """env.py `process_revision_directives` hook: name each new revision by its version."""
76
+ # Trigger: Alembic always hands the hook a context built from the script directory.
77
+ # Why: without the graph there is no head to count from; guessing an ID would
78
+ # silently fork the history.
79
+ # Outcome: fail fast instead of writing a revision with an unchecked ID.
80
+ if context.script is None:
81
+ raise RuntimeError("alembic revision context has no script directory")
82
+
83
+ rev_id = next_revision_id(context.script)
84
+ (head,) = context.script.get_heads()
85
+
86
+ for directive in directives:
87
+ # Trigger: `alembic revision --head <older rev> --splice` (or --head base).
88
+ # Why: the next ID only means "one past the head" if the revision revises that
89
+ # head; built on anything else it forks the graph into two heads.
90
+ # Outcome: refuse before Alembic writes the file.
91
+ # The CLI leaves `head` as None when --head is omitted; ScriptDirectory.generate_revision
92
+ # reads None as "head", so resolve it the same way.
93
+ requested = directive.head or "head"
94
+ built_on = [rev.revision for rev in context.script.get_revisions(requested) if rev]
95
+ if built_on != [head]:
96
+ raise ValueError(
97
+ f"new revision builds on {built_on or 'base'}, not the current head {head!r}; "
98
+ "build on the head (omit --head, or pass --head head)"
99
+ )
100
+ directive.rev_id = rev_id
101
+
102
+
19
103
  def reset_database(): # pragma: no cover
20
104
  """Drop and recreate all tables."""
21
105
  logger.info("Resetting database...")
@@ -0,0 +1,114 @@
1
+ """Key vector chunks by content and store their search row location in columns.
2
+
3
+ A chunk key used to embed the search_index row id it was cut from
4
+ ("observation:95276:0"). Updating a note deletes and recreates its observations and
5
+ relations, and Postgres never reuses ids, so every rewrite gave every observation and
6
+ relation chunk a new key. Nothing matched the previous manifest and the whole note was
7
+ re-embedded on each edit: about 86 chunks per edit for an agent maintaining large ledger
8
+ notes (basic-memory-cloud tenant 0fffb994, 2026-10-08).
9
+
10
+ The row id and chunk position move into ``source_type``, ``source_row_id`` and
11
+ ``chunk_index``, and the key becomes ``<type>:<sha256 of the chunk text>:<occurrence>``.
12
+ Built-in pgvector embeddings are stored by chunk row id, so re-keying in place keeps
13
+ every existing embedding: nothing is re-embedded by this migration.
14
+
15
+ Revision ID: 0040
16
+ Revises: z9a0b1c2d3e4
17
+ Create Date: 2026-10-08 19:00:00.000000
18
+
19
+ """
20
+
21
+ from typing import Sequence, Union
22
+
23
+ from alembic import op
24
+
25
+
26
+ revision: str = "0040"
27
+ down_revision: Union[str, None] = "z9a0b1c2d3e4"
28
+ branch_labels: Union[str, Sequence[str], None] = None
29
+ depends_on: Union[str, Sequence[str], None] = None
30
+
31
+
32
+ def upgrade() -> None:
33
+ """Split chunk location into columns and re-key Postgres chunks by content.
34
+
35
+ SQLite chunk tables are created at runtime by the search repository, which
36
+ applies the same upgrade (``SQLITE_CHUNK_LOCATION_UPGRADE``) when it finds them.
37
+ """
38
+ connection = op.get_bind()
39
+ if connection.dialect.name != "postgresql":
40
+ return
41
+
42
+ op.execute(
43
+ """
44
+ ALTER TABLE search_vector_chunks
45
+ ADD COLUMN IF NOT EXISTS source_type TEXT,
46
+ ADD COLUMN IF NOT EXISTS source_row_id INTEGER,
47
+ ADD COLUMN IF NOT EXISTS chunk_index INTEGER
48
+ """
49
+ )
50
+ op.execute(
51
+ """
52
+ UPDATE search_vector_chunks SET
53
+ source_type = split_part(chunk_key, ':', 1),
54
+ source_row_id = split_part(chunk_key, ':', 2)::integer,
55
+ chunk_index = split_part(chunk_key, ':', 3)::integer
56
+ WHERE source_type IS NULL
57
+ """
58
+ )
59
+ # Old keys ("type:id:n") and new keys ("type:<64 hex>:n") cannot collide, so the
60
+ # unique (project_id, entity_id, chunk_key) constraint holds throughout.
61
+ op.execute(
62
+ """
63
+ UPDATE search_vector_chunks AS chunks
64
+ SET chunk_key = ranked.source_type || ':' || ranked.source_hash || ':'
65
+ || ranked.occurrence
66
+ FROM (
67
+ SELECT id, source_type, source_hash,
68
+ ROW_NUMBER() OVER (
69
+ PARTITION BY project_id, entity_id, source_type, source_hash
70
+ ORDER BY source_row_id, chunk_index, id
71
+ ) - 1 AS occurrence
72
+ FROM search_vector_chunks
73
+ ) AS ranked
74
+ WHERE ranked.id = chunks.id
75
+ """
76
+ )
77
+ # pgvector finds embeddings by chunk row id, so re-keying keeps them. An external
78
+ # index (Milvus) stores the old key itself; re-embed those chunks.
79
+ op.execute(
80
+ """
81
+ UPDATE search_vector_chunks SET embedding_status = 'pending'
82
+ WHERE vector_index NOT IN ('', 'sqlite-vec', 'pgvector')
83
+ """
84
+ )
85
+ op.execute(
86
+ """
87
+ ALTER TABLE search_vector_chunks
88
+ ALTER COLUMN source_type SET NOT NULL,
89
+ ALTER COLUMN source_row_id SET NOT NULL,
90
+ ALTER COLUMN chunk_index SET NOT NULL
91
+ """
92
+ )
93
+
94
+
95
+ def downgrade() -> None:
96
+ """Restore row-id chunk keys and drop the location columns."""
97
+ connection = op.get_bind()
98
+ if connection.dialect.name != "postgresql":
99
+ return
100
+
101
+ op.execute(
102
+ """
103
+ UPDATE search_vector_chunks
104
+ SET chunk_key = source_type || ':' || source_row_id || ':' || chunk_index
105
+ """
106
+ )
107
+ op.execute(
108
+ """
109
+ ALTER TABLE search_vector_chunks
110
+ DROP COLUMN IF EXISTS chunk_index,
111
+ DROP COLUMN IF EXISTS source_row_id,
112
+ DROP COLUMN IF EXISTS source_type
113
+ """
114
+ )
@@ -0,0 +1,48 @@
1
+ """Index vector chunks by the search row they were cut from.
2
+
3
+ A filtered vector search hands the index the filter as SQL over the chunk manifest and
4
+ admits chunks by their search row: ``(project_id, source_type, source_row_id)`` (#1746).
5
+ Without an index on those columns, every filtered search read the whole chunk table
6
+ (373,000 rows in the largest production project) to find the admitted chunks.
7
+
8
+ SQLite creates its chunk table and indexes at runtime in the search repository, which
9
+ creates this index too.
10
+
11
+ Revision ID: 0041
12
+ Revises: 0040
13
+ Create Date: 2026-10-11 15:30:00.000000
14
+
15
+ """
16
+
17
+ from typing import Sequence, Union
18
+
19
+ from alembic import op
20
+
21
+
22
+ revision: str = "0041"
23
+ down_revision: Union[str, None] = "0040"
24
+ branch_labels: Union[str, Sequence[str], None] = None
25
+ depends_on: Union[str, Sequence[str], None] = None
26
+
27
+
28
+ def upgrade() -> None:
29
+ """Create the chunk location index on Postgres."""
30
+ connection = op.get_bind()
31
+ if connection.dialect.name != "postgresql":
32
+ return
33
+
34
+ op.execute(
35
+ """
36
+ CREATE INDEX IF NOT EXISTS idx_search_vector_chunks_location
37
+ ON search_vector_chunks (project_id, source_type, source_row_id)
38
+ """
39
+ )
40
+
41
+
42
+ def downgrade() -> None:
43
+ """Drop the chunk location index on Postgres."""
44
+ connection = op.get_bind()
45
+ if connection.dialect.name != "postgresql":
46
+ return
47
+
48
+ op.execute("DROP INDEX IF EXISTS idx_search_vector_chunks_location")
@@ -0,0 +1,291 @@
1
+ """Normalize stored file paths to Unicode NFC and merge NFC/NFD twin entities.
2
+
3
+ macOS filesystems resolve the NFC and NFD spellings of a name to one file but
4
+ report whichever form is on disk, and file-sync tools rewrite that form
5
+ (Syncthing keeps NFD names on macOS). Before #1275 the index keyed entities by
6
+ the raw path, so a vault could hold two rows for one file -- the second with a
7
+ `-1` permalink -- and notes whose stored key was NFD. Paths now enter the index
8
+ in NFC on macOS (`basic_memory.utils.nfc_path`); this migration brings existing
9
+ rows to the same form so the first index pass after upgrading does not see a
10
+ wave of moves.
11
+
12
+ For each group of entities whose paths are equal after NFC normalization, the
13
+ oldest row (lowest id, which holds the original permalink) is kept:
14
+
15
+ - an accepted write that has not reached disk yet (`note_content` not `synced`)
16
+ moves to the kept row; two such writes for one file stop the upgrade instead
17
+ of dropping one;
18
+ - references to a twin (`relation.to_id`, `note_file_vacate.entity_id`,
19
+ `accepted_project_note_change.entity_id`) move to the kept row; a relation
20
+ that would duplicate one the kept row already receives is dropped instead;
21
+ - rows that only describe the twin are deleted, and the kept row's checksums
22
+ are cleared so the next index pass re-reads the file, the source of truth.
23
+
24
+ `note_file_vacate` keys markers by path, so NFC/NFD spellings of one vacated
25
+ path are merged to the marker whose checksum guard matches the file on disk. Embeddings of
26
+ deleted vector chunks are left to the semantic index's orphan sweep, because
27
+ removing them here would need the sqlite-vec extension inside the migration.
28
+
29
+ Linux and Windows filesystems keep NFC and NFD names as two distinct files, so
30
+ there stored paths stay byte-exact and this migration does nothing -- the same
31
+ rule `nfc_path` applies.
32
+
33
+ Revision ID: 0042
34
+ Revises: 0041
35
+ Create Date: 2026-10-14 12:00:00.000000
36
+
37
+ """
38
+
39
+ import hashlib
40
+ import sys
41
+ import unicodedata
42
+ from collections import defaultdict
43
+ from pathlib import Path
44
+ from typing import Sequence, Union
45
+
46
+ import sqlalchemy as sa
47
+ from alembic import op
48
+
49
+ revision: str = "0042"
50
+ down_revision: Union[str, None] = "0041"
51
+ branch_labels: Union[str, Sequence[str], None] = None
52
+ depends_on: Union[str, Sequence[str], None] = None
53
+
54
+ # (table, columns the statement needs, statement), run in order for each twin.
55
+ # A statement runs only when its table and columns exist: search_index is derived
56
+ # state whose shape differs across older databases, and the next index pass
57
+ # rebuilds it anyway.
58
+ TWIN_STATEMENTS = (
59
+ # The twin's outgoing edges describe the same file the kept row describes.
60
+ ("relation", ("from_id",), "DELETE FROM relation WHERE from_id = :twin"),
61
+ # An edge into the twin that duplicates an edge into the kept row would break
62
+ # uix_relation_from_id_to_id once redirected; the kept row's edge stays.
63
+ (
64
+ "relation",
65
+ ("from_id", "to_id", "relation_type"),
66
+ "DELETE FROM relation WHERE to_id = :twin AND EXISTS ("
67
+ "SELECT 1 FROM relation AS kept WHERE kept.from_id = relation.from_id "
68
+ "AND kept.to_id = :keeper AND kept.relation_type = relation.relation_type)",
69
+ ),
70
+ ("relation", ("to_id",), "UPDATE relation SET to_id = :keeper WHERE to_id = :twin"),
71
+ (
72
+ "note_file_vacate",
73
+ ("entity_id",),
74
+ "UPDATE note_file_vacate SET entity_id = :keeper WHERE entity_id = :twin",
75
+ ),
76
+ (
77
+ "accepted_project_note_change",
78
+ ("entity_id",),
79
+ "UPDATE accepted_project_note_change SET entity_id = :keeper WHERE entity_id = :twin",
80
+ ),
81
+ ("observation", ("entity_id",), "DELETE FROM observation WHERE entity_id = :twin"),
82
+ ("note_section", ("entity_id",), "DELETE FROM note_section WHERE entity_id = :twin"),
83
+ (
84
+ "memory_time_index",
85
+ ("entity_id",),
86
+ "DELETE FROM memory_time_index WHERE entity_id = :twin",
87
+ ),
88
+ (
89
+ "relation_search_refresh",
90
+ ("entity_id",),
91
+ "DELETE FROM relation_search_refresh WHERE entity_id = :twin",
92
+ ),
93
+ ("note_content", ("entity_id",), "DELETE FROM note_content WHERE entity_id = :twin"),
94
+ ("search_index", ("entity_id",), "DELETE FROM search_index WHERE entity_id = :twin"),
95
+ (
96
+ "search_vector_chunks",
97
+ ("entity_id",),
98
+ "DELETE FROM search_vector_chunks WHERE entity_id = :twin",
99
+ ),
100
+ )
101
+
102
+ # (table, key column, path columns) for every stored project-relative path.
103
+ PATH_COLUMNS = (
104
+ ("entity", "id", ("file_path",)),
105
+ ("note_content", "entity_id", ("file_path",)),
106
+ ("note_file_vacate", "id", ("file_path",)),
107
+ ("accepted_project_note_change", "id", ("file_path", "previous_file_path")),
108
+ # search_index has no single-column key on every backend; the entity id plus
109
+ # the stored path identifies the rows, so the old path is matched as well.
110
+ ("search_index", "entity_id", ("file_path",)),
111
+ )
112
+
113
+
114
+ def _nfc(path: str) -> str:
115
+ return unicodedata.normalize("NFC", path)
116
+
117
+
118
+ def _nfc_or_none(path: str | None) -> str | None:
119
+ return None if path is None else _nfc(path)
120
+
121
+
122
+ def _table_columns(connection: sa.Connection) -> dict[str, set[str]]:
123
+ inspector = sa.inspect(connection)
124
+ # Reflect only tables this migration uses. Inspecting unrelated vec0 tables
125
+ # requires sqlite-vec on the migration connection even without reading rows.
126
+ needed_tables = {table for table, _, _ in TWIN_STATEMENTS} | {
127
+ table for table, _, _ in PATH_COLUMNS
128
+ }
129
+ return {
130
+ table: {column["name"] for column in inspector.get_columns(table)}
131
+ for table in inspector.get_table_names()
132
+ if table in needed_tables
133
+ }
134
+
135
+
136
+ def _has(columns: dict[str, set[str]], table: str, *names: str) -> bool:
137
+ return table in columns and set(names) <= columns[table]
138
+
139
+
140
+ def _keep_unwritten_note_content(
141
+ connection: sa.Connection, columns: dict[str, set[str]], keeper: int, twins: list[int]
142
+ ) -> None:
143
+ """Move an accepted write that has not reached disk yet onto the kept entity.
144
+
145
+ A `synced` row equals the file on disk, so dropping it loses nothing. Any other
146
+ status is accepted content that exists only in the database. `updated_at` is
147
+ no guide: reconciliation refreshes it on rows whose bytes did not change.
148
+ """
149
+ if not _has(columns, "note_content", "entity_id", "file_write_status"):
150
+ return
151
+ rows = connection.execute(
152
+ sa.text(
153
+ "SELECT entity_id, file_write_status FROM note_content WHERE entity_id IN :ids"
154
+ ).bindparams(sa.bindparam("ids", expanding=True)),
155
+ {"ids": [keeper, *twins]},
156
+ ).all()
157
+ unwritten = [entity_id for entity_id, status in rows if status != "synced"]
158
+ if len(unwritten) > 1:
159
+ raise RuntimeError(
160
+ "Entities "
161
+ + ", ".join(str(entity_id) for entity_id in sorted(unwritten))
162
+ + " are NFC/NFD spellings of one file and each holds an accepted write that "
163
+ "has not been written to disk. Let the previous Basic Memory version finish "
164
+ "writing them (or resolve the note by hand), then upgrade again."
165
+ )
166
+ owners = {entity_id for entity_id, _ in rows}
167
+ source = unwritten[0] if unwritten else (keeper if keeper in owners else None)
168
+ if source is None and owners:
169
+ source = min(owners)
170
+ if source is None or source == keeper:
171
+ return
172
+ connection.execute(
173
+ sa.text("DELETE FROM note_content WHERE entity_id = :keeper"), {"keeper": keeper}
174
+ )
175
+ # external_id mirrors the owning entity and reads look note content up by it,
176
+ # so the adopted row takes the keeper's external_id along with its entity_id.
177
+ connection.execute(
178
+ sa.text(
179
+ "UPDATE note_content SET entity_id = :keeper, "
180
+ "external_id = (SELECT external_id FROM entity WHERE id = :keeper) "
181
+ "WHERE entity_id = :owner"
182
+ ),
183
+ {"keeper": keeper, "owner": source},
184
+ )
185
+
186
+
187
+ def _merge_twins(connection: sa.Connection, columns: dict[str, set[str]]) -> None:
188
+ rows = connection.execute(
189
+ sa.text("SELECT id, project_id, file_path FROM entity ORDER BY id")
190
+ ).all()
191
+ groups: dict[tuple[int, str], list[int]] = defaultdict(list)
192
+ for entity_id, project_id, file_path in rows:
193
+ groups[(project_id, _nfc(file_path))].append(entity_id)
194
+
195
+ for ids in groups.values():
196
+ if len(ids) < 2:
197
+ continue
198
+ keeper, twins = ids[0], ids[1:]
199
+ _keep_unwritten_note_content(connection, columns, keeper, twins)
200
+ for twin in twins:
201
+ params = {"keeper": keeper, "twin": twin}
202
+ for table, needed, statement in TWIN_STATEMENTS:
203
+ if _has(columns, table, *needed):
204
+ connection.execute(sa.text(statement), params)
205
+ connection.execute(sa.text("DELETE FROM entity WHERE id = :twin"), params)
206
+ connection.execute(
207
+ sa.text("UPDATE entity SET checksum = NULL, sync_checksum = NULL WHERE id = :keeper"),
208
+ {"keeper": keeper},
209
+ )
210
+
211
+
212
+ def _file_checksum(project_root: str | None, file_path: str) -> str | None:
213
+ """SHA-256 of the file on disk, the same digest the indexer stores as a guard."""
214
+ if not project_root:
215
+ return None
216
+ try:
217
+ return hashlib.sha256((Path(project_root) / file_path).read_bytes()).hexdigest()
218
+ except OSError:
219
+ return None
220
+
221
+
222
+ def _merge_vacate_markers(connection: sa.Connection, columns: dict[str, set[str]]) -> None:
223
+ """Keep one marker per vacated path, since markers are unique by path.
224
+
225
+ The marker whose checksum guard matches the file on disk is the current one;
226
+ ids and timestamps are no guide because a refreshed marker keeps its id. When
227
+ no guard matches (or the file is gone) every marker is already stale, and the
228
+ lowest id is kept, which leaves the state the database held before.
229
+ """
230
+ if not _has(columns, "note_file_vacate", "id", "project_id", "file_path", "file_checksum"):
231
+ return
232
+ project_roots = {
233
+ project_id: path
234
+ for project_id, path in connection.execute(sa.text("SELECT id, path FROM project")).all()
235
+ }
236
+ groups: dict[tuple[int, str], list[tuple[int, str | None]]] = defaultdict(list)
237
+ for marker_id, project_id, file_path, checksum in connection.execute(
238
+ sa.text("SELECT id, project_id, file_path, file_checksum FROM note_file_vacate ORDER BY id")
239
+ ).all():
240
+ groups[(project_id, _nfc(file_path))].append((marker_id, checksum))
241
+ for (project_id, file_path), markers in groups.items():
242
+ if len(markers) < 2:
243
+ continue
244
+ on_disk = _file_checksum(project_roots.get(project_id), file_path)
245
+ current = next((marker for marker in markers if marker[1] == on_disk), markers[0])
246
+ for marker_id, _ in markers:
247
+ if marker_id != current[0]:
248
+ connection.execute(
249
+ sa.text("DELETE FROM note_file_vacate WHERE id = :id"), {"id": marker_id}
250
+ )
251
+
252
+
253
+ def _normalize_paths(connection: sa.Connection, columns: dict[str, set[str]]) -> None:
254
+ for table, key, path_columns in PATH_COLUMNS:
255
+ if not _has(columns, table, key, *path_columns):
256
+ continue
257
+ selected = ", ".join(path_columns)
258
+ rows = connection.execute(sa.text(f"SELECT DISTINCT {key}, {selected} FROM {table}")).all()
259
+ for row in rows:
260
+ row_key, values = row[0], tuple(row[1:])
261
+ normalized = tuple(_nfc_or_none(value) for value in values)
262
+ if normalized == values:
263
+ continue
264
+ assignments = ", ".join(f"{column} = :new_{column}" for column in path_columns)
265
+ matches = " AND ".join(
266
+ f"{column} IS NULL" if value is None else f"{column} = :old_{column}"
267
+ for column, value in zip(path_columns, values)
268
+ )
269
+ connection.execute(
270
+ sa.text(f"UPDATE {table} SET {assignments} WHERE {key} = :key AND {matches}"),
271
+ {
272
+ "key": row_key,
273
+ **{f"new_{column}": value for column, value in zip(path_columns, normalized)},
274
+ **{f"old_{column}": value for column, value in zip(path_columns, values)},
275
+ },
276
+ )
277
+
278
+
279
+ def upgrade() -> None:
280
+ """Merge NFC/NFD twin entities and store every path in NFC (macOS only)."""
281
+ if sys.platform != "darwin":
282
+ return
283
+ connection = op.get_bind()
284
+ columns = _table_columns(connection)
285
+ _merge_twins(connection, columns)
286
+ _merge_vacate_markers(connection, columns)
287
+ _normalize_paths(connection, columns)
288
+
289
+
290
+ def downgrade() -> None:
291
+ """Keep the NFC paths; the older schema reads them as ordinary paths."""
@@ -0,0 +1,77 @@
1
+ """relation.to_id foreign key becomes ON DELETE SET NULL
2
+
3
+ Deleting an entity used to take every inbound relation row with it. The wikilink
4
+ stayed in the source note's markdown, but the graph forgot the edge existed, so a
5
+ "find broken links" report came back clean over a vault full of them.
6
+
7
+ to_id has always been nullable, and the indexer already produces exactly the state
8
+ we want here -- to_id NULL with to_name holding the source's link text -- for a link
9
+ pointing at a note that does not exist yet. A deleted target should land in that same
10
+ unresolved state instead of vanishing, and forward-reference resolution then re-links
11
+ the row for free if the target is ever recreated.
12
+
13
+ from_id keeps CASCADE. An entity really does own the relations it declares.
14
+
15
+ Revision ID: bcdbd5a942ca
16
+ Revises: 7f6a2b8c9d10
17
+ Create Date: 2026-08-28 00:00:00.000000
18
+
19
+ """
20
+
21
+ from typing import Sequence, Union
22
+
23
+ from alembic import op
24
+
25
+
26
+ # revision identifiers, used by Alembic.
27
+ revision: str = "bcdbd5a942ca"
28
+ down_revision: Union[str, None] = "7f6a2b8c9d10"
29
+ branch_labels: Union[str, Sequence[str], None] = None
30
+ depends_on: Union[str, Sequence[str], None] = None
31
+
32
+
33
+ # The initial schema created relation's entity foreign keys without names, and SQLite
34
+ # has no way to reference an anonymous constraint. Handing batch_alter_table a naming
35
+ # convention lets it address the reflected FK by the name the convention would have
36
+ # given it. Side effect worth knowing about: the table recreate writes those names into
37
+ # the schema, so from_id's FK comes out named too. Cosmetic, and an improvement.
38
+ RELATION_NAMING_CONVENTION = {"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"}
39
+ SQLITE_TO_ID_FK = "fk_relation_to_id_entity"
40
+
41
+ # Postgres named it for us when the initial schema left it anonymous.
42
+ POSTGRES_TO_ID_FK = "relation_to_id_fkey"
43
+
44
+
45
+ def _set_to_id_ondelete(ondelete: str) -> None:
46
+ """Point relation.to_id's foreign key at a different ON DELETE action."""
47
+ dialect = op.get_bind().dialect.name
48
+
49
+ if dialect == "sqlite":
50
+ # SQLite cannot alter a foreign key in place; batch mode does the copy-and-swap.
51
+ # Same shape as a1b2c3d4e5f6.
52
+ with op.batch_alter_table(
53
+ "relation", schema=None, naming_convention=RELATION_NAMING_CONVENTION
54
+ ) as batch_op:
55
+ batch_op.drop_constraint(SQLITE_TO_ID_FK, type_="foreignkey")
56
+ batch_op.create_foreign_key(
57
+ SQLITE_TO_ID_FK, "entity", ["to_id"], ["id"], ondelete=ondelete
58
+ )
59
+ else:
60
+ op.drop_constraint(POSTGRES_TO_ID_FK, "relation", type_="foreignkey")
61
+ op.create_foreign_key(
62
+ POSTGRES_TO_ID_FK, "relation", "entity", ["to_id"], ["id"], ondelete=ondelete
63
+ )
64
+
65
+
66
+ def upgrade() -> None:
67
+ """Stop erasing an entity's inbound relations along with the entity."""
68
+ _set_to_id_ondelete("SET NULL")
69
+
70
+
71
+ def downgrade() -> None:
72
+ """Go back to erasing inbound relations on delete.
73
+
74
+ Rows already unresolved (to_id NULL) stay that way. This restores the constraint;
75
+ it cannot un-forget what CASCADE already ate.
76
+ """
77
+ _set_to_id_ondelete("CASCADE")