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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/alembic.ini +3 -1
- basic_memory/alembic/env.py +7 -0
- basic_memory/alembic/migrations.py +86 -2
- basic_memory/alembic/versions/0040_vector_chunk_location_columns.py +114 -0
- basic_memory/alembic/versions/0041_index_vector_chunk_location.py +48 -0
- basic_memory/alembic/versions/0042_nfc_file_paths.py +291 -0
- basic_memory/alembic/versions/bcdbd5a942ca_relation_to_id_set_null_on_delete.py +77 -0
- basic_memory/alembic/versions/d2e3f4a5b6c7_add_script_ngrams_to_full_text_search.py +123 -0
- basic_memory/alembic/versions/s2p3e4c5w6k7_add_project_partition_position.py +88 -0
- basic_memory/alembic/versions/t3n4o5t6e7s8_add_note_section_table.py +64 -0
- basic_memory/alembic/versions/u4t5e6m7p8o9_add_memory_time_index_table.py +103 -0
- basic_memory/alembic/versions/v5o6b7s8d9e0_add_observation_duplicate_index.py +49 -0
- basic_memory/alembic/versions/w6k7i8n9d0a1_search_index_row_kind_uniqueness.py +93 -0
- basic_memory/alembic/versions/w6r7e8a9d0y1_add_project_last_indexed_at.py +51 -0
- basic_memory/alembic/versions/x7d8e9f0a1b2_add_entity_vector_sync_deferred_at.py +51 -0
- basic_memory/alembic/versions/y8f9a0b1c2d3_merge_row_kind_and_deferral_heads.py +36 -0
- basic_memory/alembic/versions/z9a0b1c2d3e4_add_entity_sync_checksum.py +43 -0
- basic_memory/api/app.py +23 -0
- basic_memory/api/container.py +7 -2
- basic_memory/api/v2/routers/__init__.py +9 -0
- basic_memory/api/v2/routers/accepted_content_router.py +58 -0
- basic_memory/api/v2/routers/directory_router.py +47 -116
- basic_memory/api/v2/routers/importer_router.py +3 -3
- basic_memory/api/v2/routers/knowledge_router.py +447 -289
- basic_memory/api/v2/routers/memory_router.py +43 -15
- basic_memory/api/v2/routers/note_write_router.py +139 -0
- basic_memory/api/v2/routers/project_router.py +48 -16
- basic_memory/api/v2/routers/prompt_router.py +2 -2
- basic_memory/api/v2/routers/resource_router.py +167 -118
- basic_memory/api/v2/routers/schema_router.py +157 -55
- basic_memory/api/v2/routers/scoped_search_router.py +117 -0
- basic_memory/api/v2/routers/search_router.py +155 -138
- basic_memory/api/v2/utils.py +137 -2
- basic_memory/cli/app.py +47 -24
- basic_memory/cli/auth.py +11 -10
- basic_memory/cli/auto_update.py +7 -3
- basic_memory/cli/commands/__init__.py +18 -1
- basic_memory/cli/commands/cache.py +37 -0
- basic_memory/cli/commands/ci.py +11 -10
- basic_memory/cli/commands/cloud/api_client.py +1 -1
- basic_memory/cli/commands/cloud/bisync_commands.py +5 -4
- basic_memory/cli/commands/cloud/core_commands.py +25 -20
- basic_memory/cli/commands/cloud/project_sync.py +153 -79
- basic_memory/cli/commands/cloud/rclone_commands.py +5 -4
- basic_memory/cli/commands/cloud/rclone_config.py +4 -3
- basic_memory/cli/commands/cloud/rclone_installer.py +10 -6
- basic_memory/cli/commands/cloud/restore.py +23 -16
- basic_memory/cli/commands/cloud/shares.py +42 -31
- basic_memory/cli/commands/cloud/snapshot.py +50 -39
- basic_memory/cli/commands/cloud/upload_command.py +18 -12
- basic_memory/cli/commands/cloud/webdav.py +325 -101
- basic_memory/cli/commands/cloud/webdav_transfer.py +134 -39
- basic_memory/cli/commands/cloud/workspace.py +12 -9
- basic_memory/cli/commands/command_utils.py +39 -3
- basic_memory/cli/commands/config.py +35 -10
- basic_memory/cli/commands/db.py +161 -42
- basic_memory/cli/commands/doctor.py +3 -2
- basic_memory/cli/commands/format.py +5 -4
- basic_memory/cli/commands/hook.py +243 -43
- basic_memory/cli/commands/import_chatgpt.py +4 -1
- basic_memory/cli/commands/import_claude_conversations.py +4 -1
- basic_memory/cli/commands/import_claude_projects.py +4 -1
- basic_memory/cli/commands/import_document.py +137 -0
- basic_memory/cli/commands/import_memory_json.py +2 -1
- basic_memory/cli/commands/install.py +487 -0
- basic_memory/cli/commands/man.py +189 -6
- basic_memory/cli/commands/mcp.py +38 -1
- basic_memory/cli/commands/okf.py +77 -0
- basic_memory/cli/commands/orphans.py +4 -3
- basic_memory/cli/commands/posix.py +1024 -0
- basic_memory/cli/commands/project.py +560 -156
- basic_memory/cli/commands/prune.py +113 -0
- basic_memory/cli/commands/schema.py +17 -14
- basic_memory/cli/commands/status.py +68 -20
- basic_memory/cli/commands/tool.py +107 -24
- basic_memory/cli/commands/wiki.py +421 -0
- basic_memory/cli/main.py +6 -0
- basic_memory/cli/markup.py +19 -0
- basic_memory/config.py +134 -15
- basic_memory/config_models.py +47 -35
- basic_memory/data/pi/package/LICENSE +661 -0
- basic_memory/data/pi/package/README.md +143 -0
- basic_memory/data/pi/package/SKILLS.md +33 -0
- basic_memory/data/pi/package/extensions/bm-cli.ts +103 -0
- basic_memory/data/pi/package/extensions/config.ts +210 -0
- basic_memory/data/pi/package/extensions/index.ts +550 -0
- basic_memory/data/pi/package/extensions/session.ts +149 -0
- basic_memory/data/pi/package/package-lock.json +2973 -0
- basic_memory/data/pi/package/package.json +59 -0
- basic_memory/data/pi/package/skill-references/manifest.json +22 -0
- basic_memory/data/pi/package/skill-references/memory-capture/REFERENCE.md +284 -0
- basic_memory/data/pi/package/skill-references/memory-continue/REFERENCE.md +165 -0
- basic_memory/data/pi/package/skill-references/memory-notes/REFERENCE.md +401 -0
- basic_memory/data/pi/package/skill-references/memory-tasks/REFERENCE.md +199 -0
- basic_memory/data/pi/package/skills/basic-memory-pi/SKILL.md +33 -0
- basic_memory/data/pi/package/skills/basic-memory-pi-setup/SKILL.md +71 -0
- basic_memory/data/skills/memory-agents/SKILL.md +170 -0
- basic_memory/data/skills/memory-capture/SKILL.md +277 -0
- basic_memory/data/skills/memory-comark/SKILL.md +204 -0
- basic_memory/data/skills/memory-comark/references/components.md +740 -0
- basic_memory/data/skills/memory-continue/SKILL.md +158 -0
- basic_memory/data/skills/memory-curate/SKILL.md +236 -0
- basic_memory/data/skills/memory-defrag/SKILL.md +135 -0
- basic_memory/data/skills/memory-ingest/SKILL.md +320 -0
- basic_memory/data/skills/memory-lifecycle/SKILL.md +182 -0
- basic_memory/data/skills/memory-literary-analysis/SKILL.md +744 -0
- basic_memory/data/skills/memory-metadata-search/SKILL.md +209 -0
- basic_memory/data/skills/memory-notes/SKILL.md +394 -0
- basic_memory/data/skills/memory-okf/SKILL.md +135 -0
- basic_memory/data/skills/memory-onboarding/SKILL.md +142 -0
- basic_memory/data/skills/memory-onboarding/references/assistant-setup.md +72 -0
- basic_memory/data/skills/memory-onboarding/references/conventions.md +118 -0
- basic_memory/data/skills/memory-onboarding/references/domain-playbooks.md +159 -0
- basic_memory/data/skills/memory-onboarding/references/schema-guide.md +136 -0
- basic_memory/data/skills/memory-quest/SKILL.md +129 -0
- basic_memory/data/skills/memory-reflect/SKILL.md +154 -0
- basic_memory/data/skills/memory-research/SKILL.md +229 -0
- basic_memory/data/skills/memory-schema/SKILL.md +247 -0
- basic_memory/data/skills/memory-tasks/SKILL.md +192 -0
- basic_memory/data/tau/extension/Basic Memory.py +10 -0
- basic_memory/data/tau/extension/README.md +322 -0
- basic_memory/data/tau/extension/bridge.py +171 -0
- basic_memory/data/tau/extension/continuity.py +681 -0
- basic_memory/data/tau/extension/extension.py +106 -0
- basic_memory/data/tau/extension/knowledge.py +158 -0
- basic_memory/data/tau/extension/privacy.py +27 -0
- basic_memory/data/tau/extension/pyproject.toml +29 -0
- basic_memory/data/tau/extension/results.py +51 -0
- basic_memory/data/tau/extension/schemas/coding-session.md +66 -0
- basic_memory/data/tau/extension/schemas/decision.md +43 -0
- basic_memory/data/tau/extension/schemas/session.md +58 -0
- basic_memory/data/tau/extension/schemas/task.md +39 -0
- basic_memory/data/tau/extension/uv.lock +1060 -0
- basic_memory/data/tau/prompts/bm-decide.md +12 -0
- basic_memory/data/tau/prompts/bm-plan.md +12 -0
- basic_memory/data/tau/prompts/bm-resume.md +12 -0
- basic_memory/data/tau/prompts/bm-wrap-up.md +15 -0
- basic_memory/data/tau/skills/basic-memory-setup/SKILL.md +290 -0
- basic_memory/data/tau/skills/memory-continue/SKILL.md +158 -0
- basic_memory/data/tau/skills/memory-notes/SKILL.md +394 -0
- basic_memory/data/tau/skills/memory-schema/SKILL.md +247 -0
- basic_memory/data/tau/skills/memory-tasks/SKILL.md +192 -0
- basic_memory/db.py +7 -4
- basic_memory/deps/__init__.py +8 -2
- basic_memory/deps/db.py +7 -0
- basic_memory/deps/read_cache.py +9 -27
- basic_memory/deps/repositories.py +16 -0
- basic_memory/deps/services.py +42 -2
- basic_memory/document_ingestion/__init__.py +30 -0
- basic_memory/document_ingestion/bounded_process.py +166 -0
- basic_memory/document_ingestion/csv_extractor.py +184 -0
- basic_memory/document_ingestion/local_runtime.py +391 -0
- basic_memory/document_ingestion/markitdown_extractor.py +240 -0
- basic_memory/document_ingestion/markitdown_worker.py +186 -0
- basic_memory/document_ingestion/pdf_inspector.py +183 -0
- basic_memory/document_ingestion/pdf_inspector_worker.py +159 -0
- basic_memory/document_ingestion/raw_document.py +563 -0
- basic_memory/document_ingestion/worker_limits.py +35 -0
- basic_memory/file_types.py +155 -0
- basic_memory/file_utils.py +67 -17
- basic_memory/hooks/adapters/__init__.py +2 -1
- basic_memory/hooks/adapters/pi.py +35 -0
- basic_memory/ignore_utils.py +48 -10
- basic_memory/importers/base.py +1 -1
- basic_memory/importers/chatgpt_importer.py +54 -6
- basic_memory/importers/claude_conversations_importer.py +1 -1
- basic_memory/importers/claude_projects_importer.py +3 -3
- basic_memory/index/filesystem.py +7 -1
- basic_memory/index/local_dependencies.py +83 -17
- basic_memory/index/local_moves.py +21 -7
- basic_memory/index/local_notes.py +66 -16
- basic_memory/index/local_project.py +151 -18
- basic_memory/index/local_prune.py +108 -0
- basic_memory/index/local_runtime.py +41 -18
- basic_memory/index/local_schedulers.py +30 -7
- basic_memory/index/local_wiki_projection.py +395 -0
- basic_memory/index/note_content_materialization.py +232 -22
- basic_memory/index/watch_coordinator.py +2 -2
- basic_memory/index/watch_service.py +62 -6
- basic_memory/indexing/accepted_note_mutation_runner.py +467 -68
- basic_memory/indexing/accepted_note_write_runner.py +139 -25
- basic_memory/indexing/batch_indexer.py +414 -107
- basic_memory/indexing/change_detector.py +38 -13
- basic_memory/indexing/change_planning.py +12 -8
- basic_memory/indexing/directory_delete_runner.py +29 -1
- basic_memory/indexing/embedding_index_planning.py +45 -1
- basic_memory/indexing/file_index_checking.py +64 -22
- basic_memory/indexing/file_index_planning.py +45 -4
- basic_memory/indexing/file_indexer.py +32 -8
- basic_memory/indexing/index_file_runner.py +21 -7
- basic_memory/indexing/input_file_adaptation.py +12 -1
- basic_memory/indexing/models.py +189 -97
- basic_memory/indexing/note_content_reconciler.py +15 -1
- basic_memory/indexing/note_file_delete_runner.py +34 -40
- basic_memory/indexing/note_materialization_runner.py +14 -0
- basic_memory/indexing/project_index_coordinator.py +31 -3
- basic_memory/indexing/relation_persistence.py +207 -23
- basic_memory/indexing/relation_resolution.py +72 -15
- basic_memory/indexing/wiki_projector.py +946 -0
- basic_memory/man/__init__.py +718 -0
- basic_memory/man/bm.1 +27 -2
- basic_memory/man/man1/apropos(1).md +53 -0
- basic_memory/man/man1/cat(1).md +73 -0
- basic_memory/man/man1/find(1).md +174 -0
- basic_memory/man/man1/grep(1).md +77 -0
- basic_memory/man/man1/head(1).md +52 -0
- basic_memory/man/man1/ls(1).md +68 -0
- basic_memory/man/man1/okf-check(1).md +56 -0
- basic_memory/man/man1/okf-export(1).md +88 -0
- basic_memory/man/man1/tail(1).md +53 -0
- basic_memory/man/man1/tree(1).md +56 -0
- basic_memory/man/man3/basic-memory-diagnostics(3).md +68 -0
- basic_memory/man/man3/build-context(3).md +91 -0
- basic_memory/man/man3/chatgpt-fetch(3).md +58 -0
- basic_memory/man/man3/chatgpt-search(3).md +60 -0
- basic_memory/man/man3/cloud-info(3).md +46 -0
- basic_memory/man/man3/create-memory-project(3).md +80 -0
- basic_memory/man/man3/delete-note(3).md +73 -0
- basic_memory/man/man3/delete-project(3).md +79 -0
- basic_memory/man/man3/edit-note(3).md +122 -0
- basic_memory/man/man3/list-directory(3).md +73 -0
- basic_memory/man/man3/list-memory-projects(3).md +75 -0
- basic_memory/man/man3/list-workspaces(3).md +67 -0
- basic_memory/man/man3/move-note(3).md +75 -0
- basic_memory/man/man3/read-content(3).md +62 -0
- basic_memory/man/man3/read-note(3).md +131 -0
- basic_memory/man/man3/recent-activity(3).md +86 -0
- basic_memory/man/man3/schema-diff(3).md +66 -0
- basic_memory/man/man3/schema-infer(3).md +67 -0
- basic_memory/man/man3/schema-validate(3).md +84 -0
- basic_memory/man/man3/search-notes(3).md +164 -0
- basic_memory/man/man3/view-note(3).md +55 -0
- basic_memory/man/man3/write-note(3).md +131 -0
- basic_memory/man/man7/comark(7).md +169 -0
- basic_memory/markdown/__init__.py +20 -0
- basic_memory/markdown/entity_parser.py +106 -18
- basic_memory/markdown/line_scanning.py +84 -0
- basic_memory/markdown/note_lock.py +23 -0
- basic_memory/markdown/path_links.py +81 -0
- basic_memory/markdown/plugins.py +11 -0
- basic_memory/markdown/schemas.py +32 -2
- basic_memory/markdown/sections.py +527 -0
- basic_memory/markdown/temporal_qualifier.py +354 -0
- basic_memory/markdown/utils.py +12 -1
- basic_memory/mcp/async_client.py +101 -17
- basic_memory/mcp/clients/__init__.py +2 -1
- basic_memory/mcp/clients/knowledge.py +188 -17
- basic_memory/mcp/clients/memory.py +4 -0
- basic_memory/mcp/clients/project.py +2 -2
- basic_memory/mcp/clients/search.py +75 -2
- basic_memory/mcp/container.py +2 -2
- basic_memory/mcp/empty_results.py +39 -0
- basic_memory/mcp/index_readiness.py +37 -0
- basic_memory/mcp/note_reads.py +69 -4
- basic_memory/mcp/project_context.py +1076 -109
- basic_memory/mcp/project_context_identifiers.py +127 -29
- basic_memory/mcp/prompts/continue_conversation.py +6 -9
- basic_memory/mcp/prompts/recent_activity.py +19 -29
- basic_memory/mcp/resources/__init__.py +3 -1
- basic_memory/mcp/resources/man.py +83 -0
- basic_memory/mcp/resources/notes.py +148 -0
- basic_memory/mcp/resources/project_info.py +38 -5
- basic_memory/mcp/server.py +70 -37
- basic_memory/mcp/tools/__init__.py +9 -0
- basic_memory/mcp/tools/basic_memory_diagnostics.py +67 -1
- basic_memory/mcp/tools/build_context.py +113 -18
- basic_memory/mcp/tools/chatgpt_tools.py +15 -6
- basic_memory/mcp/tools/delete_note.py +158 -184
- basic_memory/mcp/tools/edit_note.py +440 -136
- basic_memory/mcp/tools/move_note.py +258 -151
- basic_memory/mcp/tools/posix_tools.py +1490 -0
- basic_memory/mcp/tools/project_management.py +52 -41
- basic_memory/mcp/tools/read_content.py +31 -10
- basic_memory/mcp/tools/read_note.py +246 -144
- basic_memory/mcp/tools/recent_activity.py +39 -38
- basic_memory/mcp/tools/schema.py +19 -9
- basic_memory/mcp/tools/search.py +784 -326
- basic_memory/mcp/tools/utils.py +257 -80
- basic_memory/mcp/tools/view_note.py +14 -9
- basic_memory/mcp/tools/write_note.py +544 -103
- basic_memory/mcp/workspace_project_index.py +112 -28
- basic_memory/models/__init__.py +6 -1
- basic_memory/models/knowledge.py +284 -18
- basic_memory/models/project.py +93 -1
- basic_memory/models/search.py +146 -7
- basic_memory/okf/__init__.py +1 -0
- basic_memory/okf/export.py +202 -0
- basic_memory/okf/render.py +415 -0
- basic_memory/okf/validation.py +225 -0
- basic_memory/picoschema/__init__.py +9 -1
- basic_memory/picoschema/resolver.py +59 -33
- basic_memory/read_cache/__init__.py +25 -18
- basic_memory/read_cache/contract.py +140 -70
- basic_memory/read_cache/invalidation.py +102 -26
- basic_memory/read_cache/keys.py +40 -58
- basic_memory/read_cache/lifecycle.py +2 -5
- basic_memory/read_cache/policy.py +12 -4
- basic_memory/read_cache/redis.py +228 -163
- basic_memory/read_cache/routes.py +354 -0
- basic_memory/repository/__init__.py +9 -0
- basic_memory/repository/accepted_note_repositories.py +13 -1
- basic_memory/repository/accepted_note_search_repository.py +89 -10
- basic_memory/repository/embedding_provider_factory.py +9 -7
- basic_memory/repository/entity_repository.py +106 -19
- basic_memory/repository/fastembed_rerank_provider.py +12 -2
- basic_memory/repository/memory_time_index_repository.py +147 -0
- basic_memory/repository/metadata_filters.py +203 -54
- basic_memory/repository/milvus_index.py +76 -58
- basic_memory/repository/note_section_repository.py +121 -0
- basic_memory/repository/note_type_filters.py +80 -0
- basic_memory/repository/observation_repository.py +47 -12
- basic_memory/repository/pgvector_index.py +328 -163
- basic_memory/repository/postgres_fts_chunks.py +19 -0
- basic_memory/repository/postgres_search_query.py +1053 -0
- basic_memory/repository/postgres_search_repository.py +168 -921
- basic_memory/repository/project_repository.py +142 -2
- basic_memory/repository/relation_repository.py +149 -10
- basic_memory/repository/script_ngrams.py +127 -0
- basic_memory/repository/search_filters.py +345 -0
- basic_memory/repository/search_index_row.py +60 -1
- basic_memory/repository/search_query.py +52 -0
- basic_memory/repository/search_reader.py +1647 -0
- basic_memory/repository/search_refresh_race.py +39 -0
- basic_memory/repository/search_repository.py +100 -27
- basic_memory/repository/search_repository_base.py +355 -1207
- basic_memory/repository/search_scope.py +61 -0
- basic_memory/repository/search_trace.py +20 -22
- basic_memory/repository/semantic_chunking.py +104 -69
- basic_memory/repository/semantic_runtime.py +40 -0
- basic_memory/repository/semantic_vector_index.py +60 -19
- basic_memory/repository/semantic_vector_index_factory.py +5 -5
- basic_memory/repository/semantic_vector_sync.py +184 -36
- basic_memory/repository/sqlite_search_query.py +715 -0
- basic_memory/repository/sqlite_search_repository.py +70 -824
- basic_memory/repository/sqlite_vec_index.py +112 -55
- basic_memory/repository/temporal_filters.py +150 -0
- basic_memory/runtime/accepted_note_changes.py +7 -4
- basic_memory/runtime/cleanup.py +16 -57
- basic_memory/runtime/job_payloads.py +7 -0
- basic_memory/runtime/note_content_deletes.py +2 -0
- basic_memory/runtime/note_file_guards.py +6 -1
- basic_memory/runtime/note_materialization.py +26 -2
- basic_memory/runtime/note_materialization_planning.py +4 -2
- basic_memory/runtime/note_move.py +2 -1
- basic_memory/runtime/note_object_metadata.py +49 -41
- basic_memory/runtime/project_partition.py +83 -0
- basic_memory/runtime/vector_sync.py +6 -1
- basic_memory/schemas/__init__.py +72 -0
- basic_memory/schemas/base.py +5 -3
- basic_memory/schemas/directory.py +3 -0
- basic_memory/schemas/document.py +1064 -0
- basic_memory/schemas/document_page_map.py +57 -0
- basic_memory/schemas/inspect.py +8 -18
- basic_memory/schemas/memory.py +16 -0
- basic_memory/schemas/project_index.py +12 -1
- basic_memory/schemas/project_info.py +26 -2
- basic_memory/schemas/project_readiness.py +162 -0
- basic_memory/schemas/request.py +1 -1
- basic_memory/schemas/search.py +186 -3
- basic_memory/schemas/v2/accepted_content.py +35 -0
- basic_memory/schemas/v2/entity.py +26 -0
- basic_memory/schemas/v2/note_placement.py +53 -0
- basic_memory/schemas/v2/note_write.py +80 -0
- basic_memory/services/bulk_link_resolver.py +57 -6
- basic_memory/services/context_service.py +17 -3
- basic_memory/services/directory_deletes.py +1 -0
- basic_memory/services/directory_moves.py +150 -0
- basic_memory/services/directory_service.py +13 -0
- basic_memory/services/entity_service.py +57 -189
- basic_memory/services/file_service.py +79 -60
- basic_memory/services/initialization.py +41 -16
- basic_memory/services/link_resolver.py +131 -34
- basic_memory/services/note_authorship.py +135 -0
- basic_memory/services/note_content_reads.py +42 -4
- basic_memory/services/note_content_writes.py +469 -115
- basic_memory/services/note_placement.py +127 -0
- basic_memory/services/note_preparation.py +237 -58
- basic_memory/services/note_write_outcomes.py +70 -0
- basic_memory/services/project_readiness.py +356 -0
- basic_memory/services/project_service.py +159 -29
- basic_memory/services/read_cache_changes.py +85 -0
- basic_memory/services/retrieval_inspect.py +4 -7
- basic_memory/services/schema_validation_hooks.py +70 -0
- basic_memory/services/scoped_search_service.py +156 -0
- basic_memory/services/search_guidance.py +36 -0
- basic_memory/services/search_service.py +442 -250
- basic_memory/templates/prompts/continue_conversation.hbs +6 -49
- basic_memory/templates/prompts/search.hbs +9 -58
- basic_memory/temporal.py +1165 -0
- basic_memory/utils.py +134 -14
- {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/METADATA +33 -29
- basic_memory-0.24.0.dist-info/RECORD +576 -0
- {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/WHEEL +1 -1
- basic_memory/mcp/prompts/utils.py +0 -161
- basic_memory/read_cache/read_through.py +0 -188
- basic_memory-0.23.3.dev8.dist-info/RECORD +0 -397
- {basic_memory-0.23.3.dev8.dist-info → basic_memory-0.24.0.dist-info}/entry_points.txt +0 -0
- {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.
|
|
4
|
+
__version__ = "0.24.0"
|
|
5
5
|
|
|
6
6
|
# API version for FastAPI - independent of package version
|
|
7
7
|
__api_version__ = "v2"
|
basic_memory/alembic/alembic.ini
CHANGED
|
@@ -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
|
-
#
|
|
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
|
basic_memory/alembic/env.py
CHANGED
|
@@ -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
|
-
|
|
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")
|