This is page 4 of 27. Use http://codebase.md/basicmachines-co/basic-memory?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── commands
│ │ ├── release
│ │ │ ├── beta.md
│ │ │ ├── changelog.md
│ │ │ ├── release-check.md
│ │ │ └── release.md
│ │ ├── spec.md
│ │ └── test-live.md
│ └── settings.json
├── .dockerignore
├── .env.example
├── .github
│ ├── dependabot.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ ├── config.yml
│ │ ├── documentation.md
│ │ └── feature_request.md
│ └── workflows
│ ├── claude-code-review.yml
│ ├── claude-issue-triage.yml
│ ├── claude.yml
│ ├── dev-release.yml
│ ├── docker.yml
│ ├── pr-title.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── CHANGELOG.md
├── CITATION.cff
├── CLA.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── docker-compose-postgres.yml
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── ai-assistant-guide-extended.md
│ ├── ARCHITECTURE.md
│ ├── character-handling.md
│ ├── cloud-cli.md
│ ├── Docker.md
│ └── testing-coverage.md
├── justfile
├── LICENSE
├── llms-install.md
├── pyproject.toml
├── README.md
├── SECURITY.md
├── smithery.yaml
├── specs
│ ├── SPEC-1 Specification-Driven Development Process.md
│ ├── SPEC-10 Unified Deployment Workflow and Event Tracking.md
│ ├── SPEC-11 Basic Memory API Performance Optimization.md
│ ├── SPEC-12 OpenTelemetry Observability.md
│ ├── SPEC-13 CLI Authentication with Subscription Validation.md
│ ├── SPEC-14 Cloud Git Versioning & GitHub Backup.md
│ ├── SPEC-14- Cloud Git Versioning & GitHub Backup.md
│ ├── SPEC-15 Configuration Persistence via Tigris for Cloud Tenants.md
│ ├── SPEC-16 MCP Cloud Service Consolidation.md
│ ├── SPEC-17 Semantic Search with ChromaDB.md
│ ├── SPEC-18 AI Memory Management Tool.md
│ ├── SPEC-19 Sync Performance and Memory Optimization.md
│ ├── SPEC-2 Slash Commands Reference.md
│ ├── SPEC-20 Simplified Project-Scoped Rclone Sync.md
│ ├── SPEC-3 Agent Definitions.md
│ ├── SPEC-4 Notes Web UI Component Architecture.md
│ ├── SPEC-5 CLI Cloud Upload via WebDAV.md
│ ├── SPEC-6 Explicit Project Parameter Architecture.md
│ ├── SPEC-7 POC to spike Tigris Turso for local access to cloud data.md
│ ├── SPEC-8 TigrisFS Integration.md
│ ├── SPEC-9 Multi-Project Bidirectional Sync Architecture.md
│ ├── SPEC-9 Signed Header Tenant Information.md
│ └── SPEC-9-1 Follow-Ups- Conflict, Sync, and Observability.md
├── src
│ └── basic_memory
│ ├── __init__.py
│ ├── alembic
│ │ ├── alembic.ini
│ │ ├── env.py
│ │ ├── migrations.py
│ │ ├── script.py.mako
│ │ └── versions
│ │ ├── 314f1ea54dc4_add_postgres_full_text_search_support_.py
│ │ ├── 3dae7c7b1564_initial_schema.py
│ │ ├── 502b60eaa905_remove_required_from_entity_permalink.py
│ │ ├── 5fe1ab1ccebe_add_projects_table.py
│ │ ├── 647e7a75e2cd_project_constraint_fix.py
│ │ ├── 6830751f5fb6_merge_multiple_heads.py
│ │ ├── 9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py
│ │ ├── a1b2c3d4e5f6_fix_project_foreign_keys.py
│ │ ├── a2b3c4d5e6f7_add_search_index_entity_cascade.py
│ │ ├── b3c3938bacdb_relation_to_name_unique_index.py
│ │ ├── cc7172b46608_update_search_index_schema.py
│ │ ├── e7e1f4367280_add_scan_watermark_tracking_to_project.py
│ │ ├── f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py
│ │ └── g9a0b3c4d5e6_add_external_id_to_project_and_entity.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── container.py
│ │ ├── routers
│ │ │ ├── __init__.py
│ │ │ ├── directory_router.py
│ │ │ ├── importer_router.py
│ │ │ ├── knowledge_router.py
│ │ │ ├── management_router.py
│ │ │ ├── memory_router.py
│ │ │ ├── project_router.py
│ │ │ ├── prompt_router.py
│ │ │ ├── resource_router.py
│ │ │ ├── search_router.py
│ │ │ └── utils.py
│ │ ├── template_loader.py
│ │ └── v2
│ │ ├── __init__.py
│ │ └── routers
│ │ ├── __init__.py
│ │ ├── directory_router.py
│ │ ├── importer_router.py
│ │ ├── knowledge_router.py
│ │ ├── memory_router.py
│ │ ├── project_router.py
│ │ ├── prompt_router.py
│ │ ├── resource_router.py
│ │ └── search_router.py
│ ├── cli
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── auth.py
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── cloud
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api_client.py
│ │ │ │ ├── bisync_commands.py
│ │ │ │ ├── cloud_utils.py
│ │ │ │ ├── core_commands.py
│ │ │ │ ├── rclone_commands.py
│ │ │ │ ├── rclone_config.py
│ │ │ │ ├── rclone_installer.py
│ │ │ │ ├── upload_command.py
│ │ │ │ └── upload.py
│ │ │ ├── command_utils.py
│ │ │ ├── db.py
│ │ │ ├── format.py
│ │ │ ├── import_chatgpt.py
│ │ │ ├── import_claude_conversations.py
│ │ │ ├── import_claude_projects.py
│ │ │ ├── import_memory_json.py
│ │ │ ├── mcp.py
│ │ │ ├── project.py
│ │ │ ├── status.py
│ │ │ ├── telemetry.py
│ │ │ └── tool.py
│ │ ├── container.py
│ │ └── main.py
│ ├── config.py
│ ├── db.py
│ ├── deps
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── db.py
│ │ ├── importers.py
│ │ ├── projects.py
│ │ ├── repositories.py
│ │ └── services.py
│ ├── deps.py
│ ├── file_utils.py
│ ├── ignore_utils.py
│ ├── importers
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chatgpt_importer.py
│ │ ├── claude_conversations_importer.py
│ │ ├── claude_projects_importer.py
│ │ ├── memory_json_importer.py
│ │ └── utils.py
│ ├── markdown
│ │ ├── __init__.py
│ │ ├── entity_parser.py
│ │ ├── markdown_processor.py
│ │ ├── plugins.py
│ │ ├── schemas.py
│ │ └── utils.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── async_client.py
│ │ ├── clients
│ │ │ ├── __init__.py
│ │ │ ├── directory.py
│ │ │ ├── knowledge.py
│ │ │ ├── memory.py
│ │ │ ├── project.py
│ │ │ ├── resource.py
│ │ │ └── search.py
│ │ ├── container.py
│ │ ├── project_context.py
│ │ ├── prompts
│ │ │ ├── __init__.py
│ │ │ ├── ai_assistant_guide.py
│ │ │ ├── continue_conversation.py
│ │ │ ├── recent_activity.py
│ │ │ ├── search.py
│ │ │ └── utils.py
│ │ ├── resources
│ │ │ ├── ai_assistant_guide.md
│ │ │ └── project_info.py
│ │ ├── server.py
│ │ └── tools
│ │ ├── __init__.py
│ │ ├── build_context.py
│ │ ├── canvas.py
│ │ ├── chatgpt_tools.py
│ │ ├── delete_note.py
│ │ ├── edit_note.py
│ │ ├── list_directory.py
│ │ ├── move_note.py
│ │ ├── project_management.py
│ │ ├── read_content.py
│ │ ├── read_note.py
│ │ ├── recent_activity.py
│ │ ├── search.py
│ │ ├── utils.py
│ │ ├── view_note.py
│ │ └── write_note.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── knowledge.py
│ │ ├── project.py
│ │ └── search.py
│ ├── project_resolver.py
│ ├── repository
│ │ ├── __init__.py
│ │ ├── entity_repository.py
│ │ ├── observation_repository.py
│ │ ├── postgres_search_repository.py
│ │ ├── project_info_repository.py
│ │ ├── project_repository.py
│ │ ├── relation_repository.py
│ │ ├── repository.py
│ │ ├── search_index_row.py
│ │ ├── search_repository_base.py
│ │ ├── search_repository.py
│ │ └── sqlite_search_repository.py
│ ├── runtime.py
│ ├── schemas
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── cloud.py
│ │ ├── delete.py
│ │ ├── directory.py
│ │ ├── importer.py
│ │ ├── memory.py
│ │ ├── project_info.py
│ │ ├── prompt.py
│ │ ├── request.py
│ │ ├── response.py
│ │ ├── search.py
│ │ ├── sync_report.py
│ │ └── v2
│ │ ├── __init__.py
│ │ ├── entity.py
│ │ └── resource.py
│ ├── services
│ │ ├── __init__.py
│ │ ├── context_service.py
│ │ ├── directory_service.py
│ │ ├── entity_service.py
│ │ ├── exceptions.py
│ │ ├── file_service.py
│ │ ├── initialization.py
│ │ ├── link_resolver.py
│ │ ├── project_service.py
│ │ ├── search_service.py
│ │ └── service.py
│ ├── sync
│ │ ├── __init__.py
│ │ ├── background_sync.py
│ │ ├── coordinator.py
│ │ ├── sync_service.py
│ │ └── watch_service.py
│ ├── telemetry.py
│ ├── templates
│ │ └── prompts
│ │ ├── continue_conversation.hbs
│ │ └── search.hbs
│ └── utils.py
├── test-int
│ ├── BENCHMARKS.md
│ ├── cli
│ │ ├── test_project_commands_integration.py
│ │ └── test_version_integration.py
│ ├── conftest.py
│ ├── mcp
│ │ ├── test_build_context_underscore.py
│ │ ├── test_build_context_validation.py
│ │ ├── test_chatgpt_tools_integration.py
│ │ ├── test_default_project_mode_integration.py
│ │ ├── test_delete_note_integration.py
│ │ ├── test_edit_note_integration.py
│ │ ├── test_lifespan_shutdown_sync_task_cancellation_integration.py
│ │ ├── test_list_directory_integration.py
│ │ ├── test_move_note_integration.py
│ │ ├── test_project_management_integration.py
│ │ ├── test_project_state_sync_integration.py
│ │ ├── test_read_content_integration.py
│ │ ├── test_read_note_integration.py
│ │ ├── test_search_integration.py
│ │ ├── test_single_project_mcp_integration.py
│ │ └── test_write_note_integration.py
│ ├── test_db_wal_mode.py
│ └── test_disable_permalinks_integration.py
├── tests
│ ├── __init__.py
│ ├── api
│ │ ├── conftest.py
│ │ ├── test_api_container.py
│ │ ├── test_async_client.py
│ │ ├── test_continue_conversation_template.py
│ │ ├── test_directory_router.py
│ │ ├── test_importer_router.py
│ │ ├── test_knowledge_router.py
│ │ ├── test_management_router.py
│ │ ├── test_memory_router.py
│ │ ├── test_project_router_operations.py
│ │ ├── test_project_router.py
│ │ ├── test_prompt_router.py
│ │ ├── test_relation_background_resolution.py
│ │ ├── test_resource_router.py
│ │ ├── test_search_router.py
│ │ ├── test_search_template.py
│ │ ├── test_template_loader_helpers.py
│ │ ├── test_template_loader.py
│ │ └── v2
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── test_directory_router.py
│ │ ├── test_importer_router.py
│ │ ├── test_knowledge_router.py
│ │ ├── test_memory_router.py
│ │ ├── test_project_router.py
│ │ ├── test_prompt_router.py
│ │ ├── test_resource_router.py
│ │ └── test_search_router.py
│ ├── cli
│ │ ├── cloud
│ │ │ ├── test_cloud_api_client_and_utils.py
│ │ │ ├── test_rclone_config_and_bmignore_filters.py
│ │ │ └── test_upload_path.py
│ │ ├── conftest.py
│ │ ├── test_auth_cli_auth.py
│ │ ├── test_cli_container.py
│ │ ├── test_cli_exit.py
│ │ ├── test_cli_tool_exit.py
│ │ ├── test_cli_tools.py
│ │ ├── test_cloud_authentication.py
│ │ ├── test_ignore_utils.py
│ │ ├── test_import_chatgpt.py
│ │ ├── test_import_claude_conversations.py
│ │ ├── test_import_claude_projects.py
│ │ ├── test_import_memory_json.py
│ │ ├── test_project_add_with_local_path.py
│ │ └── test_upload.py
│ ├── conftest.py
│ ├── db
│ │ └── test_issue_254_foreign_key_constraints.py
│ ├── importers
│ │ ├── test_conversation_indexing.py
│ │ ├── test_importer_base.py
│ │ └── test_importer_utils.py
│ ├── markdown
│ │ ├── __init__.py
│ │ ├── test_date_frontmatter_parsing.py
│ │ ├── test_entity_parser_error_handling.py
│ │ ├── test_entity_parser.py
│ │ ├── test_markdown_plugins.py
│ │ ├── test_markdown_processor.py
│ │ ├── test_observation_edge_cases.py
│ │ ├── test_parser_edge_cases.py
│ │ ├── test_relation_edge_cases.py
│ │ └── test_task_detection.py
│ ├── mcp
│ │ ├── clients
│ │ │ ├── __init__.py
│ │ │ └── test_clients.py
│ │ ├── conftest.py
│ │ ├── test_async_client_modes.py
│ │ ├── test_mcp_container.py
│ │ ├── test_obsidian_yaml_formatting.py
│ │ ├── test_permalink_collision_file_overwrite.py
│ │ ├── test_project_context.py
│ │ ├── test_prompts.py
│ │ ├── test_recent_activity_prompt_modes.py
│ │ ├── test_resources.py
│ │ ├── test_server_lifespan_branches.py
│ │ ├── test_tool_build_context.py
│ │ ├── test_tool_canvas.py
│ │ ├── test_tool_delete_note.py
│ │ ├── test_tool_edit_note.py
│ │ ├── test_tool_list_directory.py
│ │ ├── test_tool_move_note.py
│ │ ├── test_tool_project_management.py
│ │ ├── test_tool_read_content.py
│ │ ├── test_tool_read_note.py
│ │ ├── test_tool_recent_activity.py
│ │ ├── test_tool_resource.py
│ │ ├── test_tool_search.py
│ │ ├── test_tool_utils.py
│ │ ├── test_tool_view_note.py
│ │ ├── test_tool_write_note_kebab_filenames.py
│ │ ├── test_tool_write_note.py
│ │ └── tools
│ │ └── test_chatgpt_tools.py
│ ├── Non-MarkdownFileSupport.pdf
│ ├── README.md
│ ├── repository
│ │ ├── test_entity_repository_upsert.py
│ │ ├── test_entity_repository.py
│ │ ├── test_entity_upsert_issue_187.py
│ │ ├── test_observation_repository.py
│ │ ├── test_postgres_search_repository.py
│ │ ├── test_project_info_repository.py
│ │ ├── test_project_repository.py
│ │ ├── test_relation_repository.py
│ │ ├── test_repository.py
│ │ ├── test_search_repository_edit_bug_fix.py
│ │ └── test_search_repository.py
│ ├── schemas
│ │ ├── test_base_timeframe_minimum.py
│ │ ├── test_memory_serialization.py
│ │ ├── test_memory_url_validation.py
│ │ ├── test_memory_url.py
│ │ ├── test_relation_response_reference_resolution.py
│ │ ├── test_schemas.py
│ │ └── test_search.py
│ ├── Screenshot.png
│ ├── services
│ │ ├── test_context_service.py
│ │ ├── test_directory_service.py
│ │ ├── test_entity_service_disable_permalinks.py
│ │ ├── test_entity_service.py
│ │ ├── test_file_service.py
│ │ ├── test_initialization_cloud_mode_branches.py
│ │ ├── test_initialization.py
│ │ ├── test_link_resolver.py
│ │ ├── test_project_removal_bug.py
│ │ ├── test_project_service_operations.py
│ │ ├── test_project_service.py
│ │ └── test_search_service.py
│ ├── sync
│ │ ├── test_character_conflicts.py
│ │ ├── test_coordinator.py
│ │ ├── test_sync_service_incremental.py
│ │ ├── test_sync_service.py
│ │ ├── test_sync_wikilink_issue.py
│ │ ├── test_tmp_files.py
│ │ ├── test_watch_service_atomic_adds.py
│ │ ├── test_watch_service_edge_cases.py
│ │ ├── test_watch_service_reload.py
│ │ └── test_watch_service.py
│ ├── test_config.py
│ ├── test_deps.py
│ ├── test_production_cascade_delete.py
│ ├── test_project_resolver.py
│ ├── test_rclone_commands.py
│ ├── test_runtime.py
│ ├── test_telemetry.py
│ └── utils
│ ├── test_file_utils.py
│ ├── test_frontmatter_obsidian_compatible.py
│ ├── test_parse_tags.py
│ ├── test_permalink_formatting.py
│ ├── test_timezone_utils.py
│ ├── test_utf8_handling.py
│ └── test_validate_project_path.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/tests/mcp/test_tool_delete_note.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for delete_note MCP tool."""
2 |
3 | from basic_memory.mcp.tools.delete_note import _format_delete_error_response
4 |
5 |
6 | class TestDeleteNoteErrorFormatting:
7 | """Test the error formatting function for better user experience."""
8 |
9 | def test_format_delete_error_note_not_found(self, test_project):
10 | """Test formatting for note not found errors."""
11 | result = _format_delete_error_response(test_project.name, "entity not found", "test-note")
12 |
13 | assert "# Delete Failed - Note Not Found" in result
14 | assert "The note 'test-note' could not be found" in result
15 | assert 'search_notes("test-project", "test-note")' in result
16 | assert "Already deleted" in result
17 | assert "Wrong identifier" in result
18 |
19 | def test_format_delete_error_permission_denied(self, test_project):
20 | """Test formatting for permission errors."""
21 | result = _format_delete_error_response(test_project.name, "permission denied", "test-note")
22 |
23 | assert "# Delete Failed - Permission Error" in result
24 | assert "You don't have permission to delete 'test-note'" in result
25 | assert "Check permissions" in result
26 | assert "File locks" in result
27 | assert "list_memory_projects()" in result
28 |
29 | def test_format_delete_error_access_forbidden(self, test_project):
30 | """Test formatting for access forbidden errors."""
31 | result = _format_delete_error_response(test_project.name, "access forbidden", "test-note")
32 |
33 | assert "# Delete Failed - Permission Error" in result
34 | assert "You don't have permission to delete 'test-note'" in result
35 |
36 | def test_format_delete_error_server_error(self, test_project):
37 | """Test formatting for server errors."""
38 | result = _format_delete_error_response(
39 | test_project.name, "server error occurred", "test-note"
40 | )
41 |
42 | assert "# Delete Failed - System Error" in result
43 | assert "A system error occurred while deleting 'test-note'" in result
44 | assert "Try again" in result
45 | assert "Check file status" in result
46 |
47 | def test_format_delete_error_filesystem_error(self, test_project):
48 | """Test formatting for filesystem errors."""
49 | result = _format_delete_error_response(test_project.name, "filesystem error", "test-note")
50 |
51 | assert "# Delete Failed - System Error" in result
52 | assert "A system error occurred while deleting 'test-note'" in result
53 |
54 | def test_format_delete_error_disk_error(self, test_project):
55 | """Test formatting for disk errors."""
56 | result = _format_delete_error_response(test_project.name, "disk full", "test-note")
57 |
58 | assert "# Delete Failed - System Error" in result
59 | assert "A system error occurred while deleting 'test-note'" in result
60 |
61 | def test_format_delete_error_database_error(self, test_project):
62 | """Test formatting for database errors."""
63 | result = _format_delete_error_response(test_project.name, "database error", "test-note")
64 |
65 | assert "# Delete Failed - Database Error" in result
66 | assert "A database error occurred while deleting 'test-note'" in result
67 | assert "Sync conflict" in result
68 | assert "Database lock" in result
69 |
70 | def test_format_delete_error_sync_error(self, test_project):
71 | """Test formatting for sync errors."""
72 | result = _format_delete_error_response(test_project.name, "sync failed", "test-note")
73 |
74 | assert "# Delete Failed - Database Error" in result
75 | assert "A database error occurred while deleting 'test-note'" in result
76 |
77 | def test_format_delete_error_generic(self, test_project):
78 | """Test formatting for generic errors."""
79 | result = _format_delete_error_response(test_project.name, "unknown error", "test-note")
80 |
81 | assert "# Delete Failed" in result
82 | assert "Error deleting note 'test-note': unknown error" in result
83 | assert "General troubleshooting" in result
84 | assert "Verify the note exists" in result
85 |
86 | def test_format_delete_error_with_complex_identifier(self, test_project):
87 | """Test formatting with complex identifiers (permalinks)."""
88 | result = _format_delete_error_response(
89 | test_project.name, "entity not found", "folder/note-title"
90 | )
91 |
92 | assert 'search_notes("test-project", "note-title")' in result
93 | assert "Note Title" in result # Title format
94 | assert "folder/note-title" in result # Permalink format
95 |
96 |
97 | # Integration tests removed to focus on error formatting coverage
98 | # The error formatting tests above provide the necessary coverage for MCP tool error messaging
99 |
```
--------------------------------------------------------------------------------
/.claude/commands/release/changelog.md:
--------------------------------------------------------------------------------
```markdown
1 | # /changelog - Generate or Update Changelog Entry
2 |
3 | Analyze commits and generate formatted changelog entry for a version.
4 |
5 | ## Usage
6 | ```
7 | /changelog <version> [type]
8 | ```
9 |
10 | **Parameters:**
11 | - `version` (required): Version like `v0.14.0` or `v0.14.0b1`
12 | - `type` (optional): `beta`, `rc`, or `stable` (default: `stable`)
13 |
14 | ## Implementation
15 |
16 | You are an expert technical writer for the Basic Memory project. When the user runs `/changelog`, execute the following steps:
17 |
18 | ### Step 1: Version Analysis
19 | 1. **Determine Commit Range**
20 | ```bash
21 | # Find last release tag
22 | git tag -l "v*" --sort=-version:refname | grep -v "b\|rc" | head -1
23 |
24 | # Get commits since last release
25 | git log --oneline ${last_tag}..HEAD
26 | ```
27 |
28 | 2. **Parse Conventional Commits**
29 | - Extract feat: (features)
30 | - Extract fix: (bug fixes)
31 | - Extract BREAKING CHANGE: (breaking changes)
32 | - Extract chore:, docs:, test: (other improvements)
33 |
34 | ### Step 2: Categorize Changes
35 | 1. **Features (feat:)**
36 | - New MCP tools
37 | - New CLI commands
38 | - New API endpoints
39 | - Major functionality additions
40 |
41 | 2. **Bug Fixes (fix:)**
42 | - User-facing bug fixes
43 | - Critical issues resolved
44 | - Performance improvements
45 | - Security fixes
46 |
47 | 3. **Technical Improvements**
48 | - Test coverage improvements
49 | - Code quality enhancements
50 | - Dependency updates
51 | - Documentation updates
52 |
53 | 4. **Breaking Changes**
54 | - API changes
55 | - Configuration changes
56 | - Behavior changes
57 | - Migration requirements
58 |
59 | ### Step 3: Generate Changelog Entry
60 | Create formatted entry following existing CHANGELOG.md style:
61 |
62 | Example:
63 | ```markdown
64 | ## <version> (<date>)
65 |
66 | ### Features
67 |
68 | - **Multi-Project Management System** - Switch between projects instantly during conversations
69 | ([`993e88a`](https://github.com/basicmachines-co/basic-memory/commit/993e88a))
70 | - Instant project switching with session context
71 | - Project-specific operations and isolation
72 | - Project discovery and management tools
73 |
74 | - **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
75 | ([`6fc3904`](https://github.com/basicmachines-co/basic-memory/commit/6fc3904))
76 | - `edit_note` tool with multiple operation types
77 | - Smart frontmatter-aware editing
78 | - Validation and error handling
79 |
80 | ### Bug Fixes
81 |
82 | - **#118**: Fix YAML tag formatting to follow standard specification
83 | ([`2dc7e27`](https://github.com/basicmachines-co/basic-memory/commit/2dc7e27))
84 |
85 | - **#110**: Make --project flag work consistently across CLI commands
86 | ([`02dd91a`](https://github.com/basicmachines-co/basic-memory/commit/02dd91a))
87 |
88 | ### Technical Improvements
89 |
90 | - **Comprehensive Testing** - 100% test coverage with integration testing
91 | ([`468a22f`](https://github.com/basicmachines-co/basic-memory/commit/468a22f))
92 | - MCP integration test suite
93 | - End-to-end testing framework
94 | - Performance and edge case validation
95 |
96 | ### Breaking Changes
97 |
98 | - **Database Migration**: Automatic migration from per-project to unified database.
99 | Data will be re-index from the filesystem, resulting in no data loss.
100 | - **Configuration Changes**: Projects now synced between config.json and database
101 | - **Full Backward Compatibility**: All existing setups continue to work seamlessly
102 | ```
103 |
104 | ### Step 4: Integration
105 | 1. **Update CHANGELOG.md**
106 | - Insert new entry at top
107 | - Maintain consistent formatting
108 | - Include commit links and issue references
109 |
110 | 2. **Validation**
111 | - Check all major changes are captured
112 | - Verify commit links work
113 | - Ensure issue numbers are correct
114 |
115 | ## Smart Analysis Features
116 |
117 | ### Automatic Classification
118 | - Detect feature additions from file changes
119 | - Identify bug fixes from commit messages
120 | - Find breaking changes from code analysis
121 | - Extract issue numbers from commit messages
122 |
123 | ### Content Enhancement
124 | - Add context for technical changes
125 | - Include migration guidance for breaking changes
126 | - Suggest installation/upgrade instructions
127 | - Link to relevant documentation
128 |
129 | ## Output Format
130 |
131 | ### For Beta Releases
132 |
133 | Example:
134 | ```markdown
135 | ## v0.13.0b4 (2025-06-03)
136 |
137 | ### Beta Changes Since v0.13.0b3
138 |
139 | - Fix FastMCP API compatibility issues
140 | - Update dependencies to latest versions
141 | - Resolve setuptools import error
142 |
143 | ### Installation
144 | ```bash
145 | uv tool install basic-memory --prerelease=allow
146 | ```
147 |
148 | ### Known Issues
149 | - [List any known issues for beta testing]
150 | ```
151 |
152 | ### For Stable Releases
153 | Full changelog with complete feature list, organized by impact and category.
154 |
155 | ## Context
156 | - Follows existing CHANGELOG.md format and style
157 | - Uses conventional commit standards
158 | - Includes GitHub commit links for traceability
159 | - Focuses on user-facing changes and value
160 | - Maintains consistency with previous entries
```
--------------------------------------------------------------------------------
/src/basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py:
--------------------------------------------------------------------------------
```python
1 | """project constraint fix
2 |
3 | Revision ID: 647e7a75e2cd
4 | Revises: 5fe1ab1ccebe
5 | Create Date: 2025-06-03 12:48:30.162566
6 |
7 | """
8 |
9 | from typing import Sequence, Union
10 |
11 | from alembic import op
12 | import sqlalchemy as sa
13 |
14 |
15 | # revision identifiers, used by Alembic.
16 | revision: str = "647e7a75e2cd"
17 | down_revision: Union[str, None] = "5fe1ab1ccebe"
18 | branch_labels: Union[str, Sequence[str], None] = None
19 | depends_on: Union[str, Sequence[str], None] = None
20 |
21 |
22 | def upgrade() -> None:
23 | """Remove the problematic UNIQUE constraint on is_default column.
24 |
25 | The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
26 | which breaks project creation when the service sets is_default=False.
27 |
28 | SQLite: Recreate the table without the constraint (no ALTER TABLE support)
29 | Postgres: Use ALTER TABLE to drop the constraint directly
30 | """
31 | connection = op.get_bind()
32 | is_sqlite = connection.dialect.name == "sqlite"
33 |
34 | if is_sqlite:
35 | # For SQLite, we need to recreate the table without the UNIQUE constraint
36 | # Create a new table without the UNIQUE constraint on is_default
37 | op.create_table(
38 | "project_new",
39 | sa.Column("id", sa.Integer(), nullable=False),
40 | sa.Column("name", sa.String(), nullable=False),
41 | sa.Column("description", sa.Text(), nullable=True),
42 | sa.Column("permalink", sa.String(), nullable=False),
43 | sa.Column("path", sa.String(), nullable=False),
44 | sa.Column("is_active", sa.Boolean(), nullable=False),
45 | sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
46 | sa.Column("created_at", sa.DateTime(), nullable=False),
47 | sa.Column("updated_at", sa.DateTime(), nullable=False),
48 | sa.PrimaryKeyConstraint("id"),
49 | sa.UniqueConstraint("name"),
50 | sa.UniqueConstraint("permalink"),
51 | )
52 |
53 | # Copy data from old table to new table
54 | op.execute("INSERT INTO project_new SELECT * FROM project")
55 |
56 | # Drop the old table
57 | op.drop_table("project")
58 |
59 | # Rename the new table
60 | op.rename_table("project_new", "project")
61 |
62 | # Recreate the indexes
63 | with op.batch_alter_table("project", schema=None) as batch_op:
64 | batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
65 | batch_op.create_index("ix_project_name", ["name"], unique=True)
66 | batch_op.create_index("ix_project_path", ["path"], unique=False)
67 | batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
68 | batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
69 | else:
70 | # For Postgres, we can simply drop the constraint
71 | with op.batch_alter_table("project", schema=None) as batch_op:
72 | batch_op.drop_constraint("project_is_default_key", type_="unique")
73 |
74 |
75 | def downgrade() -> None:
76 | """Add back the UNIQUE constraint on is_default column.
77 |
78 | WARNING: This will break project creation again if multiple projects
79 | have is_default=FALSE.
80 | """
81 | # Recreate the table with the UNIQUE constraint
82 | op.create_table(
83 | "project_old",
84 | sa.Column("id", sa.Integer(), nullable=False),
85 | sa.Column("name", sa.String(), nullable=False),
86 | sa.Column("description", sa.Text(), nullable=True),
87 | sa.Column("permalink", sa.String(), nullable=False),
88 | sa.Column("path", sa.String(), nullable=False),
89 | sa.Column("is_active", sa.Boolean(), nullable=False),
90 | sa.Column("is_default", sa.Boolean(), nullable=True),
91 | sa.Column("created_at", sa.DateTime(), nullable=False),
92 | sa.Column("updated_at", sa.DateTime(), nullable=False),
93 | sa.PrimaryKeyConstraint("id"),
94 | sa.UniqueConstraint("is_default"), # Add back the problematic constraint
95 | sa.UniqueConstraint("name"),
96 | sa.UniqueConstraint("permalink"),
97 | )
98 |
99 | # Copy data (this may fail if multiple FALSE values exist)
100 | op.execute("INSERT INTO project_old SELECT * FROM project")
101 |
102 | # Drop the current table and rename
103 | op.drop_table("project")
104 | op.rename_table("project_old", "project")
105 |
106 | # Recreate indexes
107 | with op.batch_alter_table("project", schema=None) as batch_op:
108 | batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
109 | batch_op.create_index("ix_project_name", ["name"], unique=True)
110 | batch_op.create_index("ix_project_path", ["path"], unique=False)
111 | batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
112 | batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
113 |
```
--------------------------------------------------------------------------------
/tests/importers/test_conversation_indexing.py:
--------------------------------------------------------------------------------
```python
1 | """Test that imported conversations are properly indexed with correct permalink and title.
2 |
3 | This test verifies issue #452 - Imported conversations not indexed correctly.
4 | """
5 |
6 | import pytest
7 |
8 | from basic_memory.config import ProjectConfig
9 | from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
10 | from basic_memory.markdown import EntityParser
11 | from basic_memory.markdown.markdown_processor import MarkdownProcessor
12 | from basic_memory.repository import EntityRepository
13 | from basic_memory.services import EntityService
14 | from basic_memory.services.file_service import FileService
15 | from basic_memory.services.search_service import SearchService
16 | from basic_memory.schemas.search import SearchQuery
17 | from basic_memory.sync.sync_service import SyncService
18 |
19 |
20 | @pytest.mark.asyncio
21 | async def test_imported_conversations_have_correct_permalink_and_title(
22 | project_config: ProjectConfig,
23 | sync_service: SyncService,
24 | entity_service: EntityService,
25 | entity_repository: EntityRepository,
26 | search_service: SearchService,
27 | ):
28 | """Test that imported conversations have correct permalink and title after sync.
29 |
30 | Issue #452: Imported conversations show permalink: null in search results
31 | and title shows as filename instead of frontmatter title.
32 | """
33 | base_path = project_config.home
34 |
35 | # Create parser, processor, and file_service for importer
36 | parser = EntityParser(base_path)
37 | processor = MarkdownProcessor(parser)
38 | file_service = FileService(base_path, processor)
39 |
40 | # Create importer
41 | importer = ClaudeConversationsImporter(base_path, processor, file_service)
42 |
43 | # Sample conversation data
44 | conversations = [
45 | {
46 | "uuid": "test-123",
47 | "name": "My Test Conversation Title",
48 | "created_at": "2025-01-15T10:00:00Z",
49 | "updated_at": "2025-01-15T11:00:00Z",
50 | "chat_messages": [
51 | {
52 | "uuid": "msg-1",
53 | "sender": "human",
54 | "created_at": "2025-01-15T10:00:00Z",
55 | "text": "Hello world",
56 | "content": [{"type": "text", "text": "Hello world"}],
57 | "attachments": [],
58 | },
59 | {
60 | "uuid": "msg-2",
61 | "sender": "assistant",
62 | "created_at": "2025-01-15T10:01:00Z",
63 | "text": "Hello!",
64 | "content": [{"type": "text", "text": "Hello!"}],
65 | "attachments": [],
66 | },
67 | ],
68 | }
69 | ]
70 |
71 | # Run import
72 | result = await importer.import_data(conversations, "conversations")
73 | assert result.success, f"Import failed: {result}"
74 | assert result.conversations == 1
75 |
76 | # Verify the file was created with correct content
77 | conv_path = base_path / "conversations" / "20250115-My_Test_Conversation_Title.md"
78 | assert conv_path.exists(), f"Expected file at {conv_path}"
79 |
80 | content = conv_path.read_text()
81 | assert "---" in content, "File should have frontmatter markers"
82 | assert "title: My Test Conversation Title" in content, "File should have title in frontmatter"
83 | assert "permalink: conversations/20250115-My_Test_Conversation_Title" in content, (
84 | "File should have permalink in frontmatter"
85 | )
86 |
87 | # Run sync to index the imported file
88 | await sync_service.sync(base_path, project_config.name)
89 |
90 | # Verify entity in database
91 | entities = await entity_repository.find_all()
92 | assert len(entities) == 1, f"Expected 1 entity, got {len(entities)}"
93 |
94 | entity = entities[0]
95 |
96 | # These are the key assertions for issue #452
97 | assert entity.title == "My Test Conversation Title", (
98 | f"Title should be from frontmatter, got: {entity.title}"
99 | )
100 | assert entity.permalink == "conversations/20250115-My_Test_Conversation_Title", (
101 | f"Permalink should be from frontmatter, got: {entity.permalink}"
102 | )
103 |
104 | # Verify search index also has correct data
105 | results = await search_service.search(SearchQuery(text="Test Conversation"))
106 | assert len(results) >= 1, "Should find the conversation in search"
107 |
108 | # Find our entity in search results
109 | search_result = next((r for r in results if r.entity_id == entity.id), None)
110 | assert search_result is not None, "Entity should be in search results"
111 | assert search_result.title == "My Test Conversation Title", (
112 | f"Search title should be from frontmatter, got: {search_result.title}"
113 | )
114 | assert search_result.permalink == "conversations/20250115-My_Test_Conversation_Title", (
115 | f"Search permalink should not be null, got: {search_result.permalink}"
116 | )
117 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "basic-memory"
3 | dynamic = ["version"]
4 | description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
5 | readme = "README.md"
6 | requires-python = ">=3.12"
7 | license = { text = "AGPL-3.0-or-later" }
8 | authors = [
9 | { name = "Basic Machines", email = "[email protected]" }
10 | ]
11 | dependencies = [
12 | "sqlalchemy>=2.0.0",
13 | "pyyaml>=6.0.1",
14 | "typer>=0.9.0",
15 | "aiosqlite>=0.20.0",
16 | "greenlet>=3.1.1",
17 | "pydantic[email,timezone]>=2.12.0",
18 | "mcp>=1.23.1",
19 | "pydantic-settings>=2.6.1",
20 | "loguru>=0.7.3",
21 | "pyright>=1.1.390",
22 | "markdown-it-py>=3.0.0",
23 | "python-frontmatter>=1.1.0",
24 | "rich>=13.9.4",
25 | "unidecode>=1.3.8",
26 | "dateparser>=1.2.0",
27 | "watchfiles>=1.0.4",
28 | "fastapi[standard]>=0.115.8",
29 | "alembic>=1.14.1",
30 | "pillow>=11.1.0",
31 | "pybars3>=0.9.7",
32 | "fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
33 | "pyjwt>=2.10.1",
34 | "python-dotenv>=1.1.0",
35 | "pytest-aio>=1.9.0",
36 | "aiofiles>=24.1.0", # Optional observability (disabled by default via config)
37 | "asyncpg>=0.30.0",
38 | "nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
39 | "pytest-asyncio>=1.2.0",
40 | "psycopg==3.3.1",
41 | "mdformat>=0.7.22",
42 | "mdformat-gfm>=0.3.7",
43 | "mdformat-frontmatter>=2.0.8",
44 | "openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
45 | "sniffio>=1.3.1",
46 | "anyio>=4.10.0",
47 | "httpx>=0.28.0",
48 | ]
49 |
50 |
51 | [project.urls]
52 | Homepage = "https://github.com/basicmachines-co/basic-memory"
53 | Repository = "https://github.com/basicmachines-co/basic-memory"
54 | Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
55 |
56 | [project.scripts]
57 | basic-memory = "basic_memory.cli.main:app"
58 | bm = "basic_memory.cli.main:app"
59 |
60 | [build-system]
61 | requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
62 | build-backend = "hatchling.build"
63 |
64 | [tool.pytest.ini_options]
65 | pythonpath = ["src", "tests"]
66 | addopts = "--cov=basic_memory --cov-report term-missing"
67 | testpaths = ["tests", "test-int"]
68 | asyncio_mode = "strict"
69 | asyncio_default_fixture_loop_scope = "function"
70 | markers = [
71 | "benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
72 | "slow: Slow-running tests (deselect with '-m \"not slow\"')",
73 | "postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
74 | "windows: Windows-specific tests (deselect with '-m \"not windows\"')",
75 | ]
76 |
77 | [tool.ruff]
78 | line-length = 100
79 | target-version = "py312"
80 |
81 | [dependency-groups]
82 | dev = [
83 | "gevent>=24.11.1",
84 | "icecream>=2.1.3",
85 | "pytest>=8.3.4",
86 | "pytest-cov>=4.1.0",
87 | "pytest-mock>=3.12.0",
88 | "pytest-asyncio>=0.24.0",
89 | "pytest-xdist>=3.0.0",
90 | "ruff>=0.1.6",
91 | "freezegun>=1.5.5",
92 | "testcontainers[postgres]>=4.0.0",
93 | "psycopg>=3.2.0",
94 | "pyright>=1.1.408",
95 | ]
96 |
97 | [tool.hatch.version]
98 | source = "uv-dynamic-versioning"
99 |
100 | [tool.uv-dynamic-versioning]
101 | vcs = "git"
102 | style = "pep440"
103 | bump = true
104 | fallback-version = "0.0.0"
105 |
106 | [tool.pyright]
107 | include = ["src/"]
108 | exclude = ["**/__pycache__"]
109 | ignore = ["test/"]
110 | defineConstant = { DEBUG = true }
111 | reportMissingImports = "error"
112 | reportMissingTypeStubs = false
113 | pythonVersion = "3.12"
114 |
115 |
116 |
117 | [tool.coverage.run]
118 | concurrency = ["thread", "gevent"]
119 | parallel = true
120 | source = ["basic_memory"]
121 |
122 | [tool.coverage.report]
123 | exclude_lines = [
124 | "pragma: no cover",
125 | "def __repr__",
126 | "if self.debug:",
127 | "if settings.DEBUG",
128 | "raise AssertionError",
129 | "raise NotImplementedError",
130 | "if 0:",
131 | "if __name__ == .__main__.:",
132 | "class .*\\bProtocol\\):",
133 | "@(abc\\.)?abstractmethod",
134 | ]
135 |
136 | # Exclude specific modules that are difficult to test comprehensively
137 | omit = [
138 | "*/external_auth_provider.py", # External HTTP calls to OAuth providers
139 | "*/supabase_auth_provider.py", # External HTTP calls to Supabase APIs
140 | "*/watch_service.py", # File system watching - complex integration testing
141 | "*/background_sync.py", # Background processes
142 | "*/cli/**", # CLI is an interactive wrapper; core logic is covered via API/MCP/service tests
143 | "*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
144 | "*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
145 | "*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
146 | "*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
147 | "*/services/migration_service.py", # Complex migration scenarios
148 | ]
149 |
150 | [tool.logfire]
151 | ignore_no_config = true
152 |
```
--------------------------------------------------------------------------------
/src/basic_memory/mcp/tools/build_context.py:
--------------------------------------------------------------------------------
```python
1 | """Build context tool for Basic Memory MCP server."""
2 |
3 | from typing import Optional
4 |
5 | from loguru import logger
6 | from fastmcp import Context
7 |
8 | from basic_memory.mcp.async_client import get_client
9 | from basic_memory.mcp.project_context import get_active_project
10 | from basic_memory.mcp.server import mcp
11 | from basic_memory.telemetry import track_mcp_tool
12 | from basic_memory.schemas.base import TimeFrame
13 | from basic_memory.schemas.memory import (
14 | GraphContext,
15 | MemoryUrl,
16 | memory_url_path,
17 | )
18 |
19 |
20 | @mcp.tool(
21 | description="""Build context from a memory:// URI to continue conversations naturally.
22 |
23 | Use this to follow up on previous discussions or explore related topics.
24 |
25 | Memory URL Format:
26 | - Use paths like "folder/note" or "memory://folder/note"
27 | - Pattern matching: "folder/*" matches all notes in folder
28 | - Valid characters: letters, numbers, hyphens, underscores, forward slashes
29 | - Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|)
30 | - Examples: "specs/search", "projects/basic-memory", "notes/*"
31 |
32 | Timeframes support natural language like:
33 | - "2 days ago", "last week", "today", "3 months ago"
34 | - Or standard formats like "7d", "24h"
35 | """,
36 | )
37 | async def build_context(
38 | url: MemoryUrl,
39 | project: Optional[str] = None,
40 | depth: str | int | None = 1,
41 | timeframe: Optional[TimeFrame] = "7d",
42 | page: int = 1,
43 | page_size: int = 10,
44 | max_related: int = 10,
45 | context: Context | None = None,
46 | ) -> GraphContext:
47 | """Get context needed to continue a discussion within a specific project.
48 |
49 | This tool enables natural continuation of discussions by loading relevant context
50 | from memory:// URIs. It uses pattern matching to find relevant content and builds
51 | a rich context graph of related information.
52 |
53 | Project Resolution:
54 | Server resolves projects in this order: Single Project Mode → project parameter → default project.
55 | If project unknown, use list_memory_projects() or recent_activity() first.
56 |
57 | Args:
58 | project: Project name to build context from. Optional - server will resolve using hierarchy.
59 | If unknown, use list_memory_projects() to discover available projects.
60 | url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
61 | depth: How many relation hops to traverse (1-3 recommended for performance)
62 | timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
63 | page: Page number of results to return (default: 1)
64 | page_size: Number of results to return per page (default: 10)
65 | max_related: Maximum number of related results to return (default: 10)
66 | context: Optional FastMCP context for performance caching.
67 |
68 | Returns:
69 | GraphContext containing:
70 | - primary_results: Content matching the memory:// URI
71 | - related_results: Connected content via relations
72 | - metadata: Context building details
73 |
74 | Examples:
75 | # Continue a specific discussion
76 | build_context("my-project", "memory://specs/search")
77 |
78 | # Get deeper context about a component
79 | build_context("work-docs", "memory://components/memory-service", depth=2)
80 |
81 | # Look at recent changes to a specification
82 | build_context("research", "memory://specs/document-format", timeframe="today")
83 |
84 | # Research the history of a feature
85 | build_context("dev-notes", "memory://features/knowledge-graph", timeframe="3 months ago")
86 |
87 | Raises:
88 | ToolError: If project doesn't exist or depth parameter is invalid
89 | """
90 | track_mcp_tool("build_context")
91 | logger.info(f"Building context from {url} in project {project}")
92 |
93 | # Convert string depth to integer if needed
94 | if isinstance(depth, str):
95 | try:
96 | depth = int(depth)
97 | except ValueError:
98 | from mcp.server.fastmcp.exceptions import ToolError
99 |
100 | raise ToolError(f"Invalid depth parameter: '{depth}' is not a valid integer")
101 |
102 | # URL is already validated and normalized by MemoryUrl type annotation
103 |
104 | async with get_client() as client:
105 | # Get the active project using the new stateless approach
106 | active_project = await get_active_project(client, project, context)
107 |
108 | # Import here to avoid circular import
109 | from basic_memory.mcp.clients import MemoryClient
110 |
111 | # Use typed MemoryClient for API calls
112 | memory_client = MemoryClient(client, active_project.external_id)
113 | return await memory_client.build_context(
114 | memory_url_path(url),
115 | depth=depth or 1,
116 | timeframe=timeframe,
117 | page=page,
118 | page_size=page_size,
119 | max_related=max_related,
120 | )
121 |
```
--------------------------------------------------------------------------------
/tests/cli/test_auth_cli_auth.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | import os
3 | import stat
4 | import time
5 | from contextlib import asynccontextmanager
6 |
7 | import httpx
8 | import pytest
9 |
10 | from basic_memory.cli.auth import CLIAuth
11 |
12 |
13 | def _make_mock_transport(handler):
14 | return httpx.MockTransport(handler)
15 |
16 |
17 | @pytest.mark.asyncio
18 | async def test_cli_auth_request_device_authorization_uses_injected_http_client(
19 | tmp_path, monkeypatch
20 | ):
21 | """Integration-style test: exercise the request flow with real httpx plumbing (MockTransport)."""
22 | monkeypatch.setenv("HOME", str(tmp_path))
23 | monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
24 |
25 | async def handler(request: httpx.Request) -> httpx.Response:
26 | assert request.url.path.endswith("/oauth2/device_authorization")
27 | body = (await request.aread()).decode()
28 | # sanity: client_id should be in form data
29 | assert "client_id=test-client-id" in body
30 | return httpx.Response(
31 | 200,
32 | json={
33 | "device_code": "devcode",
34 | "user_code": "usercode",
35 | "verification_uri": "https://example.test/verify",
36 | "interval": 1,
37 | },
38 | )
39 |
40 | transport = _make_mock_transport(handler)
41 |
42 | @asynccontextmanager
43 | async def client_factory():
44 | async with httpx.AsyncClient(transport=transport) as client:
45 | yield client
46 |
47 | auth = CLIAuth(
48 | client_id="test-client-id",
49 | authkit_domain="https://example.test",
50 | http_client_factory=client_factory,
51 | )
52 |
53 | result = await auth.request_device_authorization()
54 | assert result is not None
55 | assert result["device_code"] == "devcode"
56 |
57 |
58 | def test_cli_auth_generate_pkce_pair_format(tmp_path, monkeypatch):
59 | monkeypatch.setenv("HOME", str(tmp_path))
60 | monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
61 |
62 | auth = CLIAuth(client_id="cid", authkit_domain="https://example.test")
63 | verifier, challenge = auth.generate_pkce_pair()
64 |
65 | # PKCE verifier/challenge should be URL-safe base64 without padding.
66 | assert verifier
67 | assert challenge
68 | assert "=" not in verifier
69 | assert "=" not in challenge
70 | # code verifier length should be in recommended bounds (rough sanity).
71 | assert 43 <= len(verifier) <= 128
72 |
73 |
74 | @pytest.mark.asyncio
75 | async def test_cli_auth_save_load_and_get_valid_token_roundtrip(tmp_path, monkeypatch):
76 | monkeypatch.setenv("HOME", str(tmp_path))
77 | monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
78 |
79 | auth = CLIAuth(client_id="cid", authkit_domain="https://example.test")
80 |
81 | tokens = {
82 | "access_token": "at",
83 | "refresh_token": "rt",
84 | "expires_in": 3600,
85 | "token_type": "Bearer",
86 | }
87 | auth.save_tokens(tokens)
88 |
89 | loaded = auth.load_tokens()
90 | assert loaded is not None
91 | assert loaded["access_token"] == "at"
92 | assert loaded["refresh_token"] == "rt"
93 | assert auth.is_token_valid(loaded) is True
94 |
95 | valid = await auth.get_valid_token()
96 | assert valid == "at"
97 |
98 | # Permission should be 600 on POSIX systems
99 | if os.name != "nt":
100 | mode = auth.token_file.stat().st_mode
101 | assert stat.S_IMODE(mode) == 0o600
102 |
103 |
104 | @pytest.mark.asyncio
105 | async def test_cli_auth_refresh_flow_uses_injected_http_client(tmp_path, monkeypatch):
106 | monkeypatch.setenv("HOME", str(tmp_path))
107 | monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
108 |
109 | async def handler(request: httpx.Request) -> httpx.Response:
110 | if request.url.path.endswith("/oauth2/token"):
111 | body = (await request.aread()).decode()
112 | assert "grant_type=refresh_token" in body
113 | return httpx.Response(
114 | 200,
115 | json={
116 | "access_token": "new-at",
117 | "refresh_token": "new-rt",
118 | "expires_in": 3600,
119 | "token_type": "Bearer",
120 | },
121 | )
122 | raise AssertionError(f"Unexpected request: {request.method} {request.url}")
123 |
124 | transport = _make_mock_transport(handler)
125 |
126 | @asynccontextmanager
127 | async def client_factory():
128 | async with httpx.AsyncClient(transport=transport) as client:
129 | yield client
130 |
131 | auth = CLIAuth(
132 | client_id="cid",
133 | authkit_domain="https://example.test",
134 | http_client_factory=client_factory,
135 | )
136 |
137 | # Write an expired token file manually (so we control expires_at precisely).
138 | auth.token_file.parent.mkdir(parents=True, exist_ok=True)
139 | auth.token_file.write_text(
140 | json.dumps(
141 | {
142 | "access_token": "old-at",
143 | "refresh_token": "old-rt",
144 | "expires_at": int(time.time()) - 10,
145 | "token_type": "Bearer",
146 | }
147 | ),
148 | encoding="utf-8",
149 | )
150 |
151 | token = await auth.get_valid_token()
152 | assert token == "new-at"
153 |
```
--------------------------------------------------------------------------------
/src/basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py:
--------------------------------------------------------------------------------
```python
1 | """add projects table
2 |
3 | Revision ID: 5fe1ab1ccebe
4 | Revises: cc7172b46608
5 | Create Date: 2025-05-14 09:05:18.214357
6 |
7 | """
8 |
9 | from typing import Sequence, Union
10 |
11 | from alembic import op
12 | import sqlalchemy as sa
13 |
14 |
15 | # revision identifiers, used by Alembic.
16 | revision: str = "5fe1ab1ccebe"
17 | down_revision: Union[str, None] = "cc7172b46608"
18 | branch_labels: Union[str, Sequence[str], None] = None
19 | depends_on: Union[str, Sequence[str], None] = None
20 |
21 |
22 | def upgrade() -> None:
23 | # ### commands auto generated by Alembic - please adjust! ###
24 |
25 | # SQLite FTS5 virtual table handling is SQLite-specific
26 | # For Postgres, search_index is a regular table managed by ORM
27 | connection = op.get_bind()
28 | is_sqlite = connection.dialect.name == "sqlite"
29 |
30 | op.create_table(
31 | "project",
32 | sa.Column("id", sa.Integer(), nullable=False),
33 | sa.Column("name", sa.String(), nullable=False),
34 | sa.Column("description", sa.Text(), nullable=True),
35 | sa.Column("permalink", sa.String(), nullable=False),
36 | sa.Column("path", sa.String(), nullable=False),
37 | sa.Column("is_active", sa.Boolean(), nullable=False),
38 | sa.Column("is_default", sa.Boolean(), nullable=True),
39 | sa.Column("created_at", sa.DateTime(), nullable=False),
40 | sa.Column("updated_at", sa.DateTime(), nullable=False),
41 | sa.PrimaryKeyConstraint("id"),
42 | sa.UniqueConstraint("is_default"),
43 | sa.UniqueConstraint("name"),
44 | sa.UniqueConstraint("permalink"),
45 | if_not_exists=True,
46 | )
47 | with op.batch_alter_table("project", schema=None) as batch_op:
48 | batch_op.create_index(
49 | "ix_project_created_at", ["created_at"], unique=False, if_not_exists=True
50 | )
51 | batch_op.create_index("ix_project_name", ["name"], unique=True, if_not_exists=True)
52 | batch_op.create_index("ix_project_path", ["path"], unique=False, if_not_exists=True)
53 | batch_op.create_index(
54 | "ix_project_permalink", ["permalink"], unique=True, if_not_exists=True
55 | )
56 | batch_op.create_index(
57 | "ix_project_updated_at", ["updated_at"], unique=False, if_not_exists=True
58 | )
59 |
60 | with op.batch_alter_table("entity", schema=None) as batch_op:
61 | batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
62 | batch_op.drop_index(
63 | "uix_entity_permalink",
64 | sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
65 | if is_sqlite
66 | else None,
67 | )
68 | batch_op.drop_index("ix_entity_file_path")
69 | batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
70 | batch_op.create_index("ix_entity_project_id", ["project_id"], unique=False)
71 | batch_op.create_index(
72 | "uix_entity_file_path_project", ["file_path", "project_id"], unique=True
73 | )
74 | batch_op.create_index(
75 | "uix_entity_permalink_project",
76 | ["permalink", "project_id"],
77 | unique=True,
78 | sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
79 | if is_sqlite
80 | else None,
81 | )
82 | batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
83 |
84 | # drop the search index table. it will be recreated
85 | # Only drop for SQLite - Postgres uses regular table managed by ORM
86 | if is_sqlite:
87 | op.drop_table("search_index")
88 |
89 | # ### end Alembic commands ###
90 |
91 |
92 | def downgrade() -> None:
93 | # ### commands auto generated by Alembic - please adjust! ###
94 | with op.batch_alter_table("entity", schema=None) as batch_op:
95 | batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
96 | batch_op.drop_index(
97 | "uix_entity_permalink_project",
98 | sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
99 | )
100 | batch_op.drop_index("uix_entity_file_path_project")
101 | batch_op.drop_index("ix_entity_project_id")
102 | batch_op.drop_index(batch_op.f("ix_entity_file_path"))
103 | batch_op.create_index("ix_entity_file_path", ["file_path"], unique=1)
104 | batch_op.create_index(
105 | "uix_entity_permalink",
106 | ["permalink"],
107 | unique=1,
108 | sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
109 | )
110 | batch_op.drop_column("project_id")
111 |
112 | with op.batch_alter_table("project", schema=None) as batch_op:
113 | batch_op.drop_index("ix_project_updated_at")
114 | batch_op.drop_index("ix_project_permalink")
115 | batch_op.drop_index("ix_project_path")
116 | batch_op.drop_index("ix_project_name")
117 | batch_op.drop_index("ix_project_created_at")
118 |
119 | op.drop_table("project")
120 | # ### end Alembic commands ###
121 |
```
--------------------------------------------------------------------------------
/src/basic_memory/schemas/v2/entity.py:
--------------------------------------------------------------------------------
```python
1 | """V2 entity and project schemas with ID-first design."""
2 |
3 | from datetime import datetime
4 | from typing import Dict, List, Literal, Optional
5 |
6 | from pydantic import BaseModel, Field, ConfigDict
7 |
8 | from basic_memory.schemas.response import ObservationResponse, RelationResponse
9 |
10 |
11 | class EntityResolveRequest(BaseModel):
12 | """Request to resolve a string identifier to an entity ID.
13 |
14 | Supports resolution of:
15 | - Permalinks (e.g., "specs/search")
16 | - Titles (e.g., "Search Specification")
17 | - File paths (e.g., "specs/search.md")
18 | """
19 |
20 | identifier: str = Field(
21 | ...,
22 | description="Entity identifier to resolve (permalink, title, or file path)",
23 | min_length=1,
24 | max_length=500,
25 | )
26 |
27 |
28 | class EntityResolveResponse(BaseModel):
29 | """Response from identifier resolution.
30 |
31 | Returns the entity ID and associated metadata for the resolved entity.
32 | """
33 |
34 | external_id: str = Field(..., description="External UUID (primary API identifier)")
35 | entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
36 | permalink: Optional[str] = Field(None, description="Entity permalink")
37 | file_path: str = Field(..., description="Relative file path")
38 | title: str = Field(..., description="Entity title")
39 | resolution_method: Literal["external_id", "permalink", "title", "path", "search"] = Field(
40 | ..., description="How the identifier was resolved"
41 | )
42 |
43 |
44 | class MoveEntityRequestV2(BaseModel):
45 | """V2 request schema for moving an entity to a new file location.
46 |
47 | In V2 API, the entity ID is provided in the URL path, so this request
48 | only needs the destination path.
49 | """
50 |
51 | destination_path: str = Field(
52 | ...,
53 | description="New file path for the entity (relative to project root)",
54 | min_length=1,
55 | max_length=500,
56 | )
57 |
58 |
59 | class EntityResponseV2(BaseModel):
60 | """V2 entity response with external_id as the primary API identifier.
61 |
62 | This response format emphasizes the external_id (UUID) as the primary API identifier,
63 | with the numeric id maintained for internal reference.
64 | """
65 |
66 | # External UUID first - this is the primary API identifier in v2
67 | external_id: str = Field(..., description="External UUID (primary API identifier)")
68 | # Internal numeric ID
69 | id: int = Field(..., description="Numeric entity ID (internal identifier)")
70 |
71 | # Core entity fields
72 | title: str = Field(..., description="Entity title")
73 | entity_type: str = Field(..., description="Entity type")
74 | content_type: str = Field(default="text/markdown", description="Content MIME type")
75 |
76 | # Secondary identifiers (for compatibility and convenience)
77 | permalink: Optional[str] = Field(None, description="Entity permalink (may change)")
78 | file_path: str = Field(..., description="Relative file path (may change)")
79 |
80 | # Content and metadata
81 | content: Optional[str] = Field(None, description="Entity content")
82 | entity_metadata: Optional[Dict] = Field(None, description="Entity metadata")
83 |
84 | # Relationships
85 | observations: List[ObservationResponse] = Field(
86 | default_factory=list, description="Entity observations"
87 | )
88 | relations: List[RelationResponse] = Field(default_factory=list, description="Entity relations")
89 |
90 | # Timestamps
91 | created_at: datetime = Field(..., description="Creation timestamp")
92 | updated_at: datetime = Field(..., description="Last update timestamp")
93 |
94 | # V2-specific metadata
95 | api_version: Literal["v2"] = Field(
96 | default="v2", description="API version (always 'v2' for this response)"
97 | )
98 |
99 | model_config = ConfigDict(from_attributes=True)
100 |
101 |
102 | class ProjectResolveRequest(BaseModel):
103 | """Request to resolve a project identifier to a project ID.
104 |
105 | Supports resolution of:
106 | - Project names (e.g., "my-project")
107 | - Permalinks (e.g., "my-project")
108 | """
109 |
110 | identifier: str = Field(
111 | ...,
112 | description="Project identifier to resolve (name or permalink)",
113 | min_length=1,
114 | max_length=255,
115 | )
116 |
117 |
118 | class ProjectResolveResponse(BaseModel):
119 | """Response from project identifier resolution.
120 |
121 | Returns the project ID and associated metadata for the resolved project.
122 | """
123 |
124 | external_id: str = Field(..., description="External UUID (primary API identifier)")
125 | project_id: int = Field(..., description="Numeric project ID (internal identifier)")
126 | name: str = Field(..., description="Project name")
127 | permalink: str = Field(..., description="Project permalink")
128 | path: str = Field(..., description="Project file path")
129 | is_active: bool = Field(..., description="Whether the project is active")
130 | is_default: bool = Field(..., description="Whether the project is the default")
131 | resolution_method: Literal["external_id", "name", "permalink"] = Field(
132 | ..., description="How the identifier was resolved"
133 | )
134 |
```
--------------------------------------------------------------------------------
/src/basic_memory/api/v2/routers/memory_router.py:
--------------------------------------------------------------------------------
```python
1 | """V2 routes for memory:// URI operations.
2 |
3 | This router uses external_id UUIDs for stable, API-friendly routing.
4 | V1 uses string-based project names which are less efficient and less stable.
5 | """
6 |
7 | from typing import Annotated, Optional
8 |
9 | from fastapi import APIRouter, Query, Path
10 | from loguru import logger
11 |
12 | from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
13 | from basic_memory.schemas.base import TimeFrame, parse_timeframe
14 | from basic_memory.schemas.memory import (
15 | GraphContext,
16 | normalize_memory_url,
17 | )
18 | from basic_memory.schemas.search import SearchItemType
19 | from basic_memory.api.routers.utils import to_graph_context
20 |
21 | # Note: No prefix here - it's added during registration as /v2/{project_id}/memory
22 | router = APIRouter(tags=["memory"])
23 |
24 |
25 | @router.get("/memory/recent", response_model=GraphContext)
26 | async def recent(
27 | context_service: ContextServiceV2ExternalDep,
28 | entity_repository: EntityRepositoryV2ExternalDep,
29 | project_id: str = Path(..., description="Project external UUID"),
30 | type: Annotated[list[SearchItemType] | None, Query()] = None,
31 | depth: int = 1,
32 | timeframe: TimeFrame = "7d",
33 | page: int = 1,
34 | page_size: int = 10,
35 | max_related: int = 10,
36 | ) -> GraphContext:
37 | """Get recent activity context for a project.
38 |
39 | Args:
40 | project_id: Project external UUID from URL path
41 | context_service: Context service scoped to project
42 | entity_repository: Entity repository scoped to project
43 | type: Types of items to include (entities, relations, observations)
44 | depth: How many levels of related entities to include
45 | timeframe: Time window for recent activity (e.g., "7d", "1 week")
46 | page: Page number for pagination
47 | page_size: Number of items per page
48 | max_related: Maximum related entities to include per item
49 |
50 | Returns:
51 | GraphContext with recent activity and related entities
52 | """
53 | # return all types by default
54 | types = (
55 | [SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
56 | if not type
57 | else type
58 | )
59 |
60 | logger.debug(
61 | f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
62 | )
63 | # Parse timeframe
64 | since = parse_timeframe(timeframe)
65 | limit = page_size
66 | offset = (page - 1) * page_size
67 |
68 | # Build context
69 | context = await context_service.build_context(
70 | types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
71 | )
72 | recent_context = await to_graph_context(
73 | context, entity_repository=entity_repository, page=page, page_size=page_size
74 | )
75 | logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
76 | return recent_context
77 |
78 |
79 | # get_memory_context needs to be declared last so other paths can match
80 |
81 |
82 | @router.get("/memory/{uri:path}", response_model=GraphContext)
83 | async def get_memory_context(
84 | context_service: ContextServiceV2ExternalDep,
85 | entity_repository: EntityRepositoryV2ExternalDep,
86 | uri: str,
87 | project_id: str = Path(..., description="Project external UUID"),
88 | depth: int = 1,
89 | timeframe: Optional[TimeFrame] = None,
90 | page: int = 1,
91 | page_size: int = 10,
92 | max_related: int = 10,
93 | ) -> GraphContext:
94 | """Get rich context from memory:// URI.
95 |
96 | V2 supports both legacy path-based URIs and new ID-based URIs:
97 | - Legacy: memory://path/to/note
98 | - ID-based: memory://id/123 or memory://123
99 |
100 | Args:
101 | project_id: Project external UUID from URL path
102 | context_service: Context service scoped to project
103 | entity_repository: Entity repository scoped to project
104 | uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
105 | depth: How many levels of related entities to include
106 | timeframe: Optional time window for filtering related content
107 | page: Page number for pagination
108 | page_size: Number of items per page
109 | max_related: Maximum related entities to include
110 |
111 | Returns:
112 | GraphContext with the entity and its related context
113 | """
114 | logger.debug(
115 | f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
116 | )
117 | memory_url = normalize_memory_url(uri)
118 |
119 | # Parse timeframe
120 | since = parse_timeframe(timeframe) if timeframe else None
121 | limit = page_size
122 | offset = (page - 1) * page_size
123 |
124 | # Build context
125 | context = await context_service.build_context(
126 | memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
127 | )
128 | return await to_graph_context(
129 | context, entity_repository=entity_repository, page=page, page_size=page_size
130 | )
131 |
```
--------------------------------------------------------------------------------
/src/basic_memory/cli/commands/cloud/api_client.py:
--------------------------------------------------------------------------------
```python
1 | """Cloud API client utilities."""
2 |
3 | from collections.abc import AsyncIterator
4 | from typing import Optional
5 | from contextlib import asynccontextmanager
6 | from typing import AsyncContextManager, Callable
7 |
8 | import httpx
9 | import typer
10 | from rich.console import Console
11 |
12 | from basic_memory.cli.auth import CLIAuth
13 | from basic_memory.config import ConfigManager
14 |
15 | console = Console()
16 |
17 | HttpClientFactory = Callable[[], AsyncContextManager[httpx.AsyncClient]]
18 |
19 |
20 | class CloudAPIError(Exception):
21 | """Exception raised for cloud API errors."""
22 |
23 | def __init__(
24 | self, message: str, status_code: Optional[int] = None, detail: Optional[dict] = None
25 | ):
26 | super().__init__(message)
27 | self.status_code = status_code
28 | self.detail = detail or {}
29 |
30 |
31 | class SubscriptionRequiredError(CloudAPIError):
32 | """Exception raised when user needs an active subscription."""
33 |
34 | def __init__(self, message: str, subscribe_url: str):
35 | super().__init__(message, status_code=403, detail={"error": "subscription_required"})
36 | self.subscribe_url = subscribe_url
37 |
38 |
39 | def get_cloud_config() -> tuple[str, str, str]:
40 | """Get cloud OAuth configuration from config."""
41 | config_manager = ConfigManager()
42 | config = config_manager.config
43 | return config.cloud_client_id, config.cloud_domain, config.cloud_host
44 |
45 |
46 | async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
47 | """
48 | Get authentication headers with JWT token.
49 | handles jwt refresh if needed.
50 | """
51 | client_id, domain, _ = get_cloud_config()
52 | auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
53 | token = await auth_obj.get_valid_token()
54 | if not token:
55 | console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
56 | raise typer.Exit(1)
57 |
58 | return {"Authorization": f"Bearer {token}"}
59 |
60 |
61 | @asynccontextmanager
62 | async def _default_http_client(timeout: float) -> AsyncIterator[httpx.AsyncClient]:
63 | async with httpx.AsyncClient(timeout=timeout) as client:
64 | yield client
65 |
66 |
67 | async def make_api_request(
68 | method: str,
69 | url: str,
70 | headers: Optional[dict] = None,
71 | json_data: Optional[dict] = None,
72 | timeout: float = 30.0,
73 | *,
74 | auth: CLIAuth | None = None,
75 | http_client_factory: HttpClientFactory | None = None,
76 | ) -> httpx.Response:
77 | """Make an API request to the cloud service."""
78 | headers = headers or {}
79 | auth_headers = await get_authenticated_headers(auth=auth)
80 | headers.update(auth_headers)
81 | # Add debug headers to help with compression issues
82 | headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
83 |
84 | client_factory = http_client_factory or (lambda: _default_http_client(timeout))
85 | async with client_factory() as client:
86 | try:
87 | response = await client.request(method=method, url=url, headers=headers, json=json_data)
88 | response.raise_for_status()
89 | return response
90 | except httpx.HTTPError as e:
91 | # Check if this is a response error with response details
92 | if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
93 | response = e.response # type: ignore
94 |
95 | # Try to parse error detail from response
96 | error_detail = None
97 | try:
98 | error_detail = response.json()
99 | except Exception:
100 | # If JSON parsing fails, we'll handle it as a generic error
101 | pass
102 |
103 | # Check for subscription_required error (403)
104 | if response.status_code == 403 and isinstance(error_detail, dict):
105 | # Handle both FastAPI HTTPException format (nested under "detail")
106 | # and direct format
107 | detail_obj = error_detail.get("detail", error_detail)
108 | if (
109 | isinstance(detail_obj, dict)
110 | and detail_obj.get("error") == "subscription_required"
111 | ):
112 | message = detail_obj.get("message", "Active subscription required")
113 | subscribe_url = detail_obj.get(
114 | "subscribe_url", "https://basicmemory.com/subscribe"
115 | )
116 | raise SubscriptionRequiredError(
117 | message=message, subscribe_url=subscribe_url
118 | ) from e
119 |
120 | # Raise generic CloudAPIError with status code and detail
121 | raise CloudAPIError(
122 | f"API request failed: {e}",
123 | status_code=response.status_code,
124 | detail=error_detail if isinstance(error_detail, dict) else {},
125 | ) from e
126 |
127 | raise CloudAPIError(f"API request failed: {e}") from e
128 |
```
--------------------------------------------------------------------------------
/src/basic_memory/repository/project_repository.py:
--------------------------------------------------------------------------------
```python
1 | """Repository for managing projects in Basic Memory."""
2 |
3 | from pathlib import Path
4 | from typing import Optional, Sequence, Union
5 |
6 |
7 | from sqlalchemy import text
8 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9 |
10 | from basic_memory import db
11 | from basic_memory.models.project import Project
12 | from basic_memory.repository.repository import Repository
13 |
14 |
15 | class ProjectRepository(Repository[Project]):
16 | """Repository for Project model.
17 |
18 | Projects represent collections of knowledge entities grouped together.
19 | Each entity, observation, and relation belongs to a specific project.
20 | """
21 |
22 | def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
23 | """Initialize with session maker."""
24 | super().__init__(session_maker, Project)
25 |
26 | async def get_by_name(self, name: str) -> Optional[Project]:
27 | """Get project by name (exact match).
28 |
29 | Args:
30 | name: Unique name of the project
31 | """
32 | query = self.select().where(Project.name == name)
33 | return await self.find_one(query)
34 |
35 | async def get_by_name_case_insensitive(self, name: str) -> Optional[Project]:
36 | """Get project by name (case-insensitive match).
37 |
38 | Args:
39 | name: Project name (case-insensitive)
40 |
41 | Returns:
42 | Project if found, None otherwise
43 | """
44 | query = self.select().where(Project.name.ilike(name))
45 | return await self.find_one(query)
46 |
47 | async def get_by_permalink(self, permalink: str) -> Optional[Project]:
48 | """Get project by permalink.
49 |
50 | Args:
51 | permalink: URL-friendly identifier for the project
52 | """
53 | query = self.select().where(Project.permalink == permalink)
54 | return await self.find_one(query)
55 |
56 | async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]:
57 | """Get project by filesystem path.
58 |
59 | Args:
60 | path: Path to the project directory (will be converted to string internally)
61 | """
62 | query = self.select().where(Project.path == Path(path).as_posix())
63 | return await self.find_one(query)
64 |
65 | async def get_by_id(self, project_id: int) -> Optional[Project]:
66 | """Get project by numeric ID.
67 |
68 | Args:
69 | project_id: Numeric project ID
70 |
71 | Returns:
72 | Project if found, None otherwise
73 | """
74 | async with db.scoped_session(self.session_maker) as session:
75 | return await self.select_by_id(session, project_id)
76 |
77 | async def get_by_external_id(self, external_id: str) -> Optional[Project]:
78 | """Get project by external UUID.
79 |
80 | Args:
81 | external_id: External UUID identifier
82 |
83 | Returns:
84 | Project if found, None otherwise
85 | """
86 | query = self.select().where(Project.external_id == external_id)
87 | return await self.find_one(query)
88 |
89 | async def get_default_project(self) -> Optional[Project]:
90 | """Get the default project (the one marked as is_default=True)."""
91 | query = self.select().where(Project.is_default.is_not(None))
92 | return await self.find_one(query)
93 |
94 | async def get_active_projects(self) -> Sequence[Project]:
95 | """Get all active projects."""
96 | query = self.select().where(Project.is_active == True) # noqa: E712
97 | result = await self.execute_query(query)
98 | return list(result.scalars().all())
99 |
100 | async def set_as_default(self, project_id: int) -> Optional[Project]:
101 | """Set a project as the default and unset previous default.
102 |
103 | Args:
104 | project_id: ID of the project to set as default
105 |
106 | Returns:
107 | The updated project if found, None otherwise
108 | """
109 | async with db.scoped_session(self.session_maker) as session:
110 | # First, clear the default flag for all projects using direct SQL
111 | await session.execute(
112 | text("UPDATE project SET is_default = NULL WHERE is_default IS NOT NULL")
113 | )
114 | await session.flush()
115 |
116 | # Set the new default project
117 | target_project = await self.select_by_id(session, project_id)
118 | if target_project:
119 | target_project.is_default = True
120 | await session.flush()
121 | return target_project
122 | return None # pragma: no cover
123 |
124 | async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
125 | """Update project path.
126 |
127 | Args:
128 | project_id: ID of the project to update
129 | new_path: New filesystem path for the project
130 |
131 | Returns:
132 | The updated project if found, None otherwise
133 | """
134 | async with db.scoped_session(self.session_maker) as session:
135 | project = await self.select_by_id(session, project_id)
136 | if project:
137 | project.path = new_path
138 | await session.flush()
139 | return project
140 | return None
141 |
```
--------------------------------------------------------------------------------
/src/basic_memory/sync/coordinator.py:
--------------------------------------------------------------------------------
```python
1 | """SyncCoordinator - centralized sync/watch lifecycle management.
2 |
3 | This module provides a single coordinator that manages the lifecycle of
4 | file synchronization and watch services across all entry points (API, MCP, CLI).
5 |
6 | The coordinator handles:
7 | - Starting/stopping watch service
8 | - Scheduling background sync
9 | - Reporting status
10 | - Clean shutdown behavior
11 | """
12 |
13 | import asyncio
14 | from dataclasses import dataclass, field
15 | from enum import Enum, auto
16 | from typing import Optional
17 |
18 | from loguru import logger
19 |
20 | from basic_memory.config import BasicMemoryConfig
21 |
22 |
23 | class SyncStatus(Enum):
24 | """Status of the sync coordinator."""
25 |
26 | NOT_STARTED = auto()
27 | STARTING = auto()
28 | RUNNING = auto()
29 | STOPPING = auto()
30 | STOPPED = auto()
31 | ERROR = auto()
32 |
33 |
34 | @dataclass
35 | class SyncCoordinator:
36 | """Centralized coordinator for sync/watch lifecycle.
37 |
38 | Manages the lifecycle of file synchronization services, providing:
39 | - Unified start/stop interface
40 | - Status tracking
41 | - Clean shutdown with proper task cancellation
42 |
43 | Args:
44 | config: BasicMemoryConfig with sync settings
45 | should_sync: Whether sync should be enabled (from container decision)
46 | skip_reason: Human-readable reason if sync is skipped
47 |
48 | Usage:
49 | coordinator = SyncCoordinator(config=config, should_sync=True)
50 | await coordinator.start()
51 | # ... application runs ...
52 | await coordinator.stop()
53 | """
54 |
55 | config: BasicMemoryConfig
56 | should_sync: bool = True
57 | skip_reason: Optional[str] = None
58 |
59 | # Internal state (not constructor args)
60 | _status: SyncStatus = field(default=SyncStatus.NOT_STARTED, init=False)
61 | _sync_task: Optional[asyncio.Task] = field(default=None, init=False)
62 |
63 | @property
64 | def status(self) -> SyncStatus:
65 | """Current status of the coordinator."""
66 | return self._status
67 |
68 | @property
69 | def is_running(self) -> bool:
70 | """Whether sync is currently running."""
71 | return self._status == SyncStatus.RUNNING
72 |
73 | async def start(self) -> None:
74 | """Start the sync/watch service if enabled.
75 |
76 | This is a non-blocking call that starts the sync task in the background.
77 | Use stop() to cleanly shut down.
78 | """
79 | if not self.should_sync:
80 | if self.skip_reason:
81 | logger.info(f"{self.skip_reason} - skipping local file sync")
82 | self._status = SyncStatus.STOPPED
83 | return
84 |
85 | if self._status in (SyncStatus.RUNNING, SyncStatus.STARTING):
86 | logger.warning("Sync coordinator already running or starting")
87 | return
88 |
89 | self._status = SyncStatus.STARTING
90 | logger.info("Starting file sync in background")
91 |
92 | try:
93 | # Deferred import to avoid circular dependency
94 | from basic_memory.services.initialization import initialize_file_sync
95 |
96 | async def _file_sync_runner() -> None: # pragma: no cover
97 | """Run the file sync service."""
98 | try:
99 | await initialize_file_sync(self.config)
100 | except asyncio.CancelledError:
101 | logger.debug("File sync cancelled")
102 | raise
103 | except Exception as e:
104 | logger.error(f"Error in file sync: {e}")
105 | self._status = SyncStatus.ERROR
106 | raise
107 |
108 | self._sync_task = asyncio.create_task(_file_sync_runner())
109 | self._status = SyncStatus.RUNNING
110 | logger.info("Sync coordinator started successfully")
111 |
112 | except Exception as e: # pragma: no cover
113 | logger.error(f"Failed to start sync coordinator: {e}")
114 | self._status = SyncStatus.ERROR
115 | raise
116 |
117 | async def stop(self) -> None:
118 | """Stop the sync/watch service cleanly.
119 |
120 | Cancels the background task and waits for it to complete.
121 | Safe to call even if not running.
122 | """
123 | if self._status in (SyncStatus.NOT_STARTED, SyncStatus.STOPPED):
124 | return
125 |
126 | if self._sync_task is None: # pragma: no cover
127 | self._status = SyncStatus.STOPPED
128 | return
129 |
130 | self._status = SyncStatus.STOPPING
131 | logger.info("Stopping sync coordinator...")
132 |
133 | self._sync_task.cancel()
134 | try:
135 | await self._sync_task
136 | except asyncio.CancelledError:
137 | logger.info("File sync task cancelled successfully")
138 |
139 | self._sync_task = None
140 | self._status = SyncStatus.STOPPED
141 | logger.info("Sync coordinator stopped")
142 |
143 | def get_status_info(self) -> dict:
144 | """Get status information for reporting.
145 |
146 | Returns:
147 | Dictionary with status details for diagnostics
148 | """
149 | return {
150 | "status": self._status.name,
151 | "should_sync": self.should_sync,
152 | "skip_reason": self.skip_reason,
153 | "has_task": self._sync_task is not None,
154 | }
155 |
156 |
157 | __all__ = [
158 | "SyncCoordinator",
159 | "SyncStatus",
160 | ]
161 |
```
--------------------------------------------------------------------------------
/tests/api/test_prompt_router.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the prompt router endpoints."""
2 |
3 | import pytest
4 | import pytest_asyncio
5 | from httpx import AsyncClient
6 |
7 | from basic_memory.services.context_service import ContextService
8 |
9 |
10 | @pytest_asyncio.fixture
11 | async def context_service(entity_repository, search_service, observation_repository):
12 | """Create a real context service for testing."""
13 | return ContextService(entity_repository, search_service, observation_repository)
14 |
15 |
16 | @pytest.mark.asyncio
17 | async def test_continue_conversation_endpoint(
18 | client: AsyncClient,
19 | entity_service,
20 | search_service,
21 | context_service,
22 | entity_repository,
23 | test_graph,
24 | project_url,
25 | ):
26 | """Test the continue_conversation endpoint with real services."""
27 | # Create request data
28 | request_data = {
29 | "topic": "Root", # This should match our test entity in test_graph
30 | "timeframe": "7d",
31 | "depth": 1,
32 | "related_items_limit": 2,
33 | }
34 |
35 | # Call the endpoint
36 | response = await client.post(f"{project_url}/prompt/continue-conversation", json=request_data)
37 |
38 | # Verify response
39 | assert response.status_code == 200
40 | result = response.json()
41 | assert "prompt" in result
42 | assert "context" in result
43 |
44 | # Check content of context
45 | context = result["context"]
46 | assert context["topic"] == "Root"
47 | assert context["timeframe"] == "7d"
48 | assert context["has_results"] is True
49 | assert len(context["hierarchical_results"]) > 0
50 |
51 | # Check content of prompt
52 | prompt = result["prompt"]
53 | assert "Continuing conversation on: Root" in prompt
54 | assert "memory retrieval session" in prompt
55 |
56 | # Test without topic - should use recent activity
57 | request_data = {"timeframe": "1d", "depth": 1, "related_items_limit": 2}
58 |
59 | response = await client.post(f"{project_url}/prompt/continue-conversation", json=request_data)
60 |
61 | assert response.status_code == 200
62 | result = response.json()
63 | assert "Recent Activity" in result["context"]["topic"]
64 |
65 |
66 | @pytest.mark.asyncio
67 | async def test_search_prompt_endpoint(
68 | client: AsyncClient, entity_service, search_service, test_graph, project_url
69 | ):
70 | """Test the search_prompt endpoint with real services."""
71 | # Create request data
72 | request_data = {
73 | "query": "Root", # This should match our test entity
74 | "timeframe": "7d",
75 | }
76 |
77 | # Call the endpoint
78 | response = await client.post(f"{project_url}/prompt/search", json=request_data)
79 |
80 | # Verify response
81 | assert response.status_code == 200
82 | result = response.json()
83 | assert "prompt" in result
84 | assert "context" in result
85 |
86 | # Check content of context
87 | context = result["context"]
88 | assert context["query"] == "Root"
89 | assert context["timeframe"] == "7d"
90 | assert context["has_results"] is True
91 | assert len(context["results"]) > 0
92 |
93 | # Check content of prompt
94 | prompt = result["prompt"]
95 | assert 'Search Results for: "Root"' in prompt
96 | assert "This is a memory search session" in prompt
97 |
98 |
99 | @pytest.mark.asyncio
100 | async def test_search_prompt_no_results(
101 | client: AsyncClient, entity_service, search_service, project_url
102 | ):
103 | """Test the search_prompt endpoint with a query that returns no results."""
104 | # Create request data with a query that shouldn't match anything
105 | request_data = {"query": "NonExistentQuery12345", "timeframe": "7d"}
106 |
107 | # Call the endpoint
108 | response = await client.post(f"{project_url}/prompt/search", json=request_data)
109 |
110 | # Verify response
111 | assert response.status_code == 200
112 | result = response.json()
113 |
114 | # Check content of context
115 | context = result["context"]
116 | assert context["query"] == "NonExistentQuery12345"
117 | assert context["has_results"] is False
118 | assert len(context["results"]) == 0
119 |
120 | # Check content of prompt
121 | prompt = result["prompt"]
122 | assert 'Search Results for: "NonExistentQuery12345"' in prompt
123 | assert "I couldn't find any results for this query" in prompt
124 | assert "Opportunity to Capture Knowledge" in prompt
125 |
126 |
127 | @pytest.mark.asyncio
128 | async def test_error_handling(client: AsyncClient, monkeypatch, project_url):
129 | """Test error handling in the endpoints by breaking the template loader."""
130 |
131 | # Patch the template loader to raise an exception
132 | def mock_render(*args, **kwargs):
133 | raise Exception("Template error")
134 |
135 | # Apply the patch
136 | monkeypatch.setattr("basic_memory.api.template_loader.TemplateLoader.render", mock_render)
137 |
138 | # Test continue_conversation error handling
139 | response = await client.post(
140 | f"{project_url}/prompt/continue-conversation",
141 | json={"topic": "test error", "timeframe": "7d"},
142 | )
143 |
144 | assert response.status_code == 500
145 | assert "detail" in response.json()
146 | assert "Template error" in response.json()["detail"]
147 |
148 | # Test search_prompt error handling
149 | response = await client.post(
150 | f"{project_url}/prompt/search", json={"query": "test error", "timeframe": "7d"}
151 | )
152 |
153 | assert response.status_code == 500
154 | assert "detail" in response.json()
155 | assert "Template error" in response.json()["detail"]
156 |
```
--------------------------------------------------------------------------------
/tests/cli/test_import_memory_json.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for import_memory_json command."""
2 |
3 | import json
4 |
5 | import pytest
6 | from typer.testing import CliRunner
7 |
8 | from basic_memory.cli.app import import_app
9 | from basic_memory.cli.commands import import_memory_json # noqa
10 | from basic_memory.markdown import MarkdownProcessor
11 | from basic_memory.services.file_service import FileService
12 |
13 | # Set up CLI runner
14 | runner = CliRunner()
15 |
16 |
17 | @pytest.fixture
18 | def sample_entities():
19 | """Sample entities for testing."""
20 | return [
21 | {
22 | "type": "entity",
23 | "name": "test_entity",
24 | "entityType": "test",
25 | "observations": ["Test observation 1", "Test observation 2"],
26 | },
27 | {
28 | "type": "relation",
29 | "from": "test_entity",
30 | "to": "related_entity",
31 | "relationType": "test_relation",
32 | },
33 | ]
34 |
35 |
36 | @pytest.fixture
37 | def sample_json_file(tmp_path, sample_entities):
38 | """Create a sample memory.json file."""
39 | json_file = tmp_path / "memory.json"
40 | with open(json_file, "w", encoding="utf-8") as f:
41 | for entity in sample_entities:
42 | f.write(json.dumps(entity) + "\n")
43 | return json_file
44 |
45 |
46 | @pytest.mark.asyncio
47 | async def test_get_importer_dependencies(tmp_path, monkeypatch):
48 | """Test getting importer dependencies (MarkdownProcessor and FileService)."""
49 | monkeypatch.setenv("HOME", str(tmp_path))
50 | processor, file_service = await import_memory_json.get_importer_dependencies()
51 | assert isinstance(processor, MarkdownProcessor)
52 | assert isinstance(file_service, FileService)
53 |
54 |
55 | def test_import_json_command_file_not_found(tmp_path):
56 | """Test error handling for nonexistent file."""
57 | nonexistent = tmp_path / "nonexistent.json"
58 | result = runner.invoke(import_app, ["memory-json", str(nonexistent)])
59 | assert result.exit_code == 1
60 | assert "File not found" in result.output
61 |
62 |
63 | def test_import_json_command_success(tmp_path, sample_json_file, monkeypatch):
64 | """Test successful JSON import via command."""
65 | # Set up test environment
66 | monkeypatch.setenv("HOME", str(tmp_path))
67 |
68 | # Run import
69 | result = runner.invoke(import_app, ["memory-json", str(sample_json_file)])
70 | assert result.exit_code == 0
71 | assert "Import complete" in result.output
72 | assert "Created 1 entities" in result.output
73 | assert "Added 1 relations" in result.output
74 |
75 |
76 | def test_import_json_command_invalid_json(tmp_path):
77 | """Test error handling for invalid JSON."""
78 | # Create invalid JSON file
79 | invalid_file = tmp_path / "invalid.json"
80 | invalid_file.write_text("not json")
81 |
82 | result = runner.invoke(import_app, ["memory-json", str(invalid_file)])
83 | assert result.exit_code == 1
84 | assert "Error during import" in result.output
85 |
86 |
87 | def test_import_json_command_handle_old_format(tmp_path):
88 | """Test handling old format JSON with from_id/to_id."""
89 | # Create JSON with old format
90 | old_format = [
91 | {
92 | "type": "entity",
93 | "name": "test_entity",
94 | "entityType": "test",
95 | "observations": ["Test observation"],
96 | },
97 | {
98 | "type": "relation",
99 | "from_id": "test_entity",
100 | "to_id": "other_entity",
101 | "relation_type": "test_relation",
102 | },
103 | ]
104 |
105 | json_file = tmp_path / "old_format.json"
106 | with open(json_file, "w", encoding="utf-8") as f:
107 | for item in old_format:
108 | f.write(json.dumps(item) + "\n")
109 |
110 | # Set up test environment
111 | monkeypatch = pytest.MonkeyPatch()
112 | monkeypatch.setenv("HOME", str(tmp_path))
113 |
114 | # Run import
115 | result = runner.invoke(import_app, ["memory-json", str(json_file)])
116 | assert result.exit_code == 0
117 | assert "Import complete" in result.output
118 |
119 |
120 | def test_import_json_command_missing_name_key(tmp_path):
121 | """Test handling JSON with missing 'name' key using 'id' instead."""
122 | # Create JSON with id instead of name (common in Knowledge Graph Memory Server)
123 | data_with_id = [
124 | {
125 | "type": "entity",
126 | "id": "test_entity_id",
127 | "entityType": "test",
128 | "observations": ["Test observation with id"],
129 | },
130 | {
131 | "type": "entity",
132 | "entityName": "test_entity_2",
133 | "entityType": "test",
134 | "observations": ["Test observation with entityName"],
135 | },
136 | {
137 | "type": "entity",
138 | "name": "test_entity_title",
139 | "entityType": "test",
140 | "observations": ["Test observation with name"],
141 | },
142 | ]
143 |
144 | json_file = tmp_path / "missing_name.json"
145 | with open(json_file, "w", encoding="utf-8") as f:
146 | for item in data_with_id:
147 | f.write(json.dumps(item) + "\n")
148 |
149 | # Set up test environment
150 | monkeypatch = pytest.MonkeyPatch()
151 | monkeypatch.setenv("HOME", str(tmp_path))
152 |
153 | # Run import - should not fail even without 'name' key
154 | result = runner.invoke(import_app, ["memory-json", str(json_file)])
155 | assert result.exit_code == 0
156 | assert "Import complete" in result.output
157 | assert "Created 3 entities" in result.output
158 |
```
--------------------------------------------------------------------------------
/tests/markdown/test_parser_edge_cases.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for markdown parser edge cases."""
2 |
3 | from pathlib import Path
4 | from textwrap import dedent
5 |
6 | import pytest
7 |
8 | from basic_memory.markdown.entity_parser import EntityParser
9 |
10 |
11 | @pytest.mark.asyncio
12 | async def test_unicode_content(tmp_path):
13 | """Test handling of Unicode content including emoji and non-Latin scripts."""
14 | content = dedent("""
15 | ---
16 | type: test
17 | id: test/unicode
18 | created: 2024-12-21T14:00:00Z
19 | modified: 2024-12-21T14:00:00Z
20 | tags: [unicode, 测试]
21 | ---
22 |
23 | # Unicode Test 🧪
24 |
25 | ## Observations
26 | - [test] Emoji test 👍 #emoji #test (Testing emoji)
27 | - [中文] Chinese text 测试 #language (Script test)
28 | - [русский] Russian привет #language (More scripts)
29 | - [note] Emoji in text 😀 #meta (Category test)
30 |
31 | ## Relations
32 | - tested_by [[测试组件]] (Unicode test)
33 | - depends_on [[компонент]] (Another test)
34 | """)
35 |
36 | test_file = tmp_path / "unicode.md"
37 | test_file.write_text(content, encoding="utf-8")
38 |
39 | parser = EntityParser(tmp_path)
40 | entity = await parser.parse_file(test_file)
41 |
42 | assert "测试" in entity.frontmatter.metadata["tags"]
43 | assert "chinese" not in entity.frontmatter.metadata["tags"]
44 | assert "🧪" in entity.content
45 |
46 | # Verify Unicode in observations
47 | assert any(o.content == "Emoji test 👍 #emoji #test" for o in entity.observations)
48 | assert any(o.category == "中文" for o in entity.observations)
49 | assert any(o.category == "русский" for o in entity.observations)
50 |
51 | # Verify Unicode in relations
52 | assert any(r.target == "测试组件" for r in entity.relations)
53 | assert any(r.target == "компонент" for r in entity.relations)
54 |
55 |
56 | @pytest.mark.asyncio
57 | async def test_empty_file(tmp_path):
58 | """Test handling of empty files."""
59 | empty_file = tmp_path / "empty.md"
60 | empty_file.write_text("")
61 |
62 | parser = EntityParser(tmp_path)
63 | entity = await parser.parse_file(empty_file)
64 | assert entity.observations == []
65 | assert entity.relations == []
66 |
67 |
68 | @pytest.mark.asyncio
69 | async def test_missing_sections(tmp_path):
70 | """Test handling of files with missing sections."""
71 | content = dedent("""
72 | ---
73 | type: test
74 | id: test/missing
75 | created: 2024-01-09
76 | modified: 2024-01-09
77 | tags: []
78 | ---
79 |
80 | Just some content
81 | with [[links]] but no sections
82 | """)
83 |
84 | test_file = tmp_path / "missing.md"
85 | test_file.write_text(content)
86 |
87 | parser = EntityParser(tmp_path)
88 | entity = await parser.parse_file(test_file)
89 | assert len(entity.relations) == 1
90 | assert entity.relations[0].target == "links"
91 | assert entity.relations[0].type == "links_to"
92 |
93 |
94 | @pytest.mark.asyncio
95 | async def test_tasks_are_not_observations(tmp_path):
96 | """Test handling of plain observations without categories."""
97 | content = dedent("""
98 | ---
99 | type: test
100 | id: test/missing
101 | created: 2024-01-09
102 | modified: 2024-01-09
103 | tags: []
104 | ---
105 |
106 | - [ ] one
107 | -[ ] two
108 | - [x] done
109 | - [-] not done
110 | """)
111 |
112 | test_file = tmp_path / "missing.md"
113 | test_file.write_text(content)
114 |
115 | parser = EntityParser(tmp_path)
116 | entity = await parser.parse_file(test_file)
117 | assert len(entity.observations) == 0
118 |
119 |
120 | @pytest.mark.asyncio
121 | async def test_nested_content(tmp_path):
122 | """Test handling of deeply nested content."""
123 | content = dedent("""
124 | ---
125 | type: test
126 | id: test/nested
127 | created: 2024-01-09
128 | modified: 2024-01-09
129 | tags: []
130 | ---
131 |
132 | # Test
133 |
134 | ## Level 1
135 | - [test] Level 1 #test (First level)
136 | - implements [[One]]
137 |
138 | ### Level 2
139 | - [test] Level 2 #test (Second level)
140 | - uses [[Two]]
141 |
142 | #### Level 3
143 | - [test] Level 3 #test (Third level)
144 | - needs [[Three]]
145 | """)
146 |
147 | test_file = tmp_path / "nested.md"
148 | test_file.write_text(content)
149 |
150 | parser = EntityParser(tmp_path)
151 | entity = await parser.parse_file(test_file)
152 |
153 | # Should find all observations and relations regardless of nesting
154 | assert len(entity.observations) == 3
155 | assert len(entity.relations) == 3
156 | assert {r.target for r in entity.relations} == {"One", "Two", "Three"}
157 |
158 |
159 | @pytest.mark.asyncio
160 | async def test_malformed_frontmatter(tmp_path):
161 | """Test handling of malformed frontmatter."""
162 | # Missing fields
163 | content = dedent("""
164 | ---
165 | type: test
166 | ---
167 |
168 | # Test
169 | """)
170 |
171 | test_file = tmp_path / "malformed.md"
172 | test_file.write_text(content)
173 |
174 | parser = EntityParser(tmp_path)
175 | entity = await parser.parse_file(test_file)
176 | assert entity.frontmatter.permalink is None
177 |
178 |
179 | @pytest.mark.asyncio
180 | async def test_file_not_found():
181 | """Test handling of non-existent files."""
182 | parser = EntityParser(Path("/tmp"))
183 | with pytest.raises(FileNotFoundError):
184 | await parser.parse_file(Path("nonexistent.md"))
185 |
```
--------------------------------------------------------------------------------
/tests/mcp/test_tool_build_context.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for discussion context MCP tool."""
2 |
3 | import pytest
4 | from datetime import datetime
5 |
6 | from mcp.server.fastmcp.exceptions import ToolError
7 |
8 | from basic_memory.mcp.tools import build_context
9 | from basic_memory.schemas.memory import (
10 | GraphContext,
11 | )
12 |
13 |
14 | @pytest.mark.asyncio
15 | async def test_get_basic_discussion_context(client, test_graph, test_project):
16 | """Test getting basic discussion context."""
17 | context = await build_context.fn(project=test_project.name, url="memory://test/root")
18 |
19 | assert isinstance(context, GraphContext)
20 | assert len(context.results) == 1
21 | assert context.results[0].primary_result.permalink == "test/root"
22 | assert len(context.results[0].related_results) > 0
23 |
24 | # Verify metadata
25 | assert context.metadata.uri == "test/root"
26 | assert context.metadata.depth == 1 # default depth
27 | assert context.metadata.timeframe is not None
28 | assert isinstance(context.metadata.generated_at, datetime)
29 | assert context.metadata.primary_count == 1
30 | if context.metadata.related_count:
31 | assert context.metadata.related_count > 0
32 |
33 |
34 | @pytest.mark.asyncio
35 | async def test_get_discussion_context_pattern(client, test_graph, test_project):
36 | """Test getting context with pattern matching."""
37 | context = await build_context.fn(project=test_project.name, url="memory://test/*", depth=1)
38 |
39 | assert isinstance(context, GraphContext)
40 | assert len(context.results) > 1 # Should match multiple test/* paths
41 | assert all("test/" in item.primary_result.permalink for item in context.results) # pyright: ignore [reportOperatorIssue]
42 | assert context.metadata.depth == 1
43 |
44 |
45 | @pytest.mark.asyncio
46 | async def test_get_discussion_context_timeframe(client, test_graph, test_project):
47 | """Test timeframe parameter filtering."""
48 | # Get recent context
49 | recent_context = await build_context.fn(
50 | project=test_project.name,
51 | url="memory://test/root",
52 | timeframe="1d", # Last 24 hours
53 | )
54 |
55 | # Get older context
56 | older_context = await build_context.fn(
57 | project=test_project.name,
58 | url="memory://test/root",
59 | timeframe="30d", # Last 30 days
60 | )
61 |
62 | # Calculate total related items
63 | total_recent_related = (
64 | sum(len(item.related_results) for item in recent_context.results)
65 | if recent_context.results
66 | else 0
67 | )
68 | total_older_related = (
69 | sum(len(item.related_results) for item in older_context.results)
70 | if older_context.results
71 | else 0
72 | )
73 |
74 | assert total_older_related >= total_recent_related
75 |
76 |
77 | @pytest.mark.asyncio
78 | async def test_get_discussion_context_not_found(client, test_project):
79 | """Test handling of non-existent URIs."""
80 | context = await build_context.fn(project=test_project.name, url="memory://test/does-not-exist")
81 |
82 | assert isinstance(context, GraphContext)
83 | assert len(context.results) == 0
84 | assert context.metadata.primary_count == 0
85 | assert context.metadata.related_count == 0
86 |
87 |
88 | # Test data for different timeframe formats
89 | valid_timeframes = [
90 | "7d", # Standard format
91 | "yesterday", # Natural language
92 | "0d", # Zero duration
93 | ]
94 |
95 | invalid_timeframes = [
96 | "invalid", # Nonsense string
97 | # NOTE: "tomorrow" now returns 1 day ago due to timezone safety - no longer invalid
98 | ]
99 |
100 |
101 | @pytest.mark.asyncio
102 | async def test_build_context_timeframe_formats(client, test_graph, test_project):
103 | """Test that build_context accepts various timeframe formats."""
104 | test_url = "memory://specs/test"
105 |
106 | # Test each valid timeframe
107 | for timeframe in valid_timeframes:
108 | try:
109 | result = await build_context.fn(
110 | project=test_project.name,
111 | url=test_url,
112 | timeframe=timeframe,
113 | page=1,
114 | page_size=10,
115 | max_related=10,
116 | )
117 | assert result is not None
118 | except Exception as e:
119 | pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
120 |
121 | # Test invalid timeframes should raise ValidationError
122 | for timeframe in invalid_timeframes:
123 | with pytest.raises(ToolError):
124 | await build_context.fn(project=test_project.name, url=test_url, timeframe=timeframe)
125 |
126 |
127 | @pytest.mark.asyncio
128 | async def test_build_context_string_depth_parameter(client, test_graph, test_project):
129 | """Test that build_context handles string depth parameter correctly."""
130 | test_url = "memory://test/root"
131 |
132 | # Test valid string depth parameter - should either raise ToolError or convert to int
133 | try:
134 | result = await build_context.fn(url=test_url, depth="2", project=test_project.name)
135 | # If it succeeds, verify the depth was converted to an integer
136 | assert isinstance(result.metadata.depth, int)
137 | assert result.metadata.depth == 2
138 | except ToolError:
139 | # This is also acceptable behavior - type validation should catch it
140 | pass
141 |
142 | # Test invalid string depth parameter - should raise ToolError
143 | with pytest.raises(ToolError):
144 | await build_context.fn(test_url, depth="invalid", project=test_project.name)
145 |
```
--------------------------------------------------------------------------------
/tests/api/test_continue_conversation_template.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the continue_conversation template rendering."""
2 |
3 | import datetime
4 | import pytest
5 |
6 | from basic_memory.api.template_loader import TemplateLoader
7 | from basic_memory.schemas.memory import EntitySummary
8 | from basic_memory.schemas.search import SearchItemType
9 |
10 |
11 | @pytest.fixture
12 | def template_loader():
13 | """Return a TemplateLoader instance for testing."""
14 | return TemplateLoader()
15 |
16 |
17 | @pytest.fixture
18 | def entity_summary():
19 | """Create a sample EntitySummary for testing."""
20 | return EntitySummary(
21 | entity_id=1,
22 | title="Test Entity",
23 | permalink="test/entity",
24 | type=SearchItemType.ENTITY,
25 | content="This is a test entity with some content.",
26 | file_path="/path/to/test/entity.md",
27 | created_at=datetime.datetime(2023, 1, 1, 12, 0),
28 | )
29 |
30 |
31 | @pytest.fixture
32 | def context_with_results(entity_summary):
33 | """Create a sample context with results for testing."""
34 | from basic_memory.schemas.memory import ObservationSummary, ContextResult
35 |
36 | # Create an observation for the entity
37 | observation = ObservationSummary(
38 | observation_id=1,
39 | entity_id=1,
40 | title="Test Observation",
41 | permalink="test/entity/observations/1",
42 | category="test",
43 | content="This is a test observation.",
44 | file_path="/path/to/test/entity.md",
45 | created_at=datetime.datetime(2023, 1, 1, 12, 0),
46 | )
47 |
48 | # Create a context result with primary_result, observations, and related_results
49 | context_item = ContextResult(
50 | primary_result=entity_summary,
51 | observations=[observation],
52 | related_results=[entity_summary],
53 | )
54 |
55 | return {
56 | "topic": "Test Topic",
57 | "timeframe": "7d",
58 | "has_results": True,
59 | "hierarchical_results": [context_item],
60 | }
61 |
62 |
63 | @pytest.fixture
64 | def context_without_results():
65 | """Create a sample context without results for testing."""
66 | return {
67 | "topic": "Empty Topic",
68 | "timeframe": "1d",
69 | "has_results": False,
70 | "hierarchical_results": [],
71 | }
72 |
73 |
74 | @pytest.mark.asyncio
75 | async def test_continue_conversation_with_results(template_loader, context_with_results):
76 | """Test rendering the continue_conversation template with results."""
77 | result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
78 |
79 | # Check that key elements are present
80 | assert "Continuing conversation on: Test Topic" in result
81 | assert "memory://test/entity" in result
82 | assert "Test Entity" in result
83 | assert "This is a test entity with some content." in result
84 | assert "Related Context" in result
85 | assert "read_note" in result
86 | assert "Next Steps" in result
87 | assert "Knowledge Capture Recommendation" in result
88 |
89 |
90 | @pytest.mark.asyncio
91 | async def test_continue_conversation_without_results(template_loader, context_without_results):
92 | """Test rendering the continue_conversation template without results."""
93 | result = await template_loader.render(
94 | "prompts/continue_conversation.hbs", context_without_results
95 | )
96 |
97 | # Check that key elements are present
98 | assert "Continuing conversation on: Empty Topic" in result
99 | assert "The supplied query did not return any information" in result
100 | assert "Opportunity to Capture New Knowledge!" in result
101 | assert 'title="Empty Topic"' in result
102 | assert "Next Steps" in result
103 | assert "Knowledge Capture Recommendation" in result
104 |
105 |
106 | @pytest.mark.asyncio
107 | async def test_next_steps_section(template_loader, context_with_results):
108 | """Test that the next steps section is rendered correctly."""
109 | result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
110 |
111 | assert "Next Steps" in result
112 | assert 'Explore more with: `search_notes("Test Topic")`' in result
113 | assert (
114 | f'See what\'s changed: `recent_activity(timeframe="{context_with_results["timeframe"]}")`'
115 | in result
116 | )
117 | assert "Record new learnings or decisions from this conversation" in result
118 |
119 |
120 | @pytest.mark.asyncio
121 | async def test_knowledge_capture_recommendation(template_loader, context_with_results):
122 | """Test that the knowledge capture recommendation is rendered."""
123 | result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
124 |
125 | assert "Knowledge Capture Recommendation" in result
126 | assert "actively look for opportunities to:" in result
127 | assert "Record key information, decisions, or insights" in result
128 | assert "Link new knowledge to existing topics" in result
129 | assert "Suggest capturing important context" in result
130 | assert "one of the most valuable aspects of Basic Memory" in result
131 |
132 |
133 | @pytest.mark.asyncio
134 | async def test_timeframe_default_value(template_loader, context_with_results):
135 | """Test that the timeframe uses the default value when not provided."""
136 | # Remove the timeframe from the context
137 | context_without_timeframe = context_with_results.copy()
138 | context_without_timeframe["timeframe"] = None
139 |
140 | result = await template_loader.render(
141 | "prompts/continue_conversation.hbs", context_without_timeframe
142 | )
143 |
144 | # Check that the default value is used
145 | assert 'recent_activity(timeframe="7d")' in result
146 |
```
--------------------------------------------------------------------------------
/tests/repository/test_entity_upsert_issue_187.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for issue #187 - UNIQUE constraint violation on file_path during sync."""
2 |
3 | import pytest
4 | from datetime import datetime, timezone
5 |
6 | from basic_memory.models.knowledge import Entity, Observation
7 | from basic_memory.repository.entity_repository import EntityRepository
8 |
9 |
10 | @pytest.mark.asyncio
11 | async def test_upsert_entity_with_observations_conflict(entity_repository: EntityRepository):
12 | """Test upserting an entity that already exists with observations.
13 |
14 | This reproduces issue #187 where sync fails with UNIQUE constraint violations
15 | when trying to update entities that already exist with observations.
16 | """
17 | # Create initial entity with observations
18 | entity1 = Entity(
19 | project_id=entity_repository.project_id,
20 | title="Original Title",
21 | entity_type="note",
22 | permalink="debugging/backup-system/coderabbit-feedback-resolution",
23 | file_path="debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md",
24 | content_type="text/markdown",
25 | created_at=datetime.now(timezone.utc),
26 | updated_at=datetime.now(timezone.utc),
27 | )
28 |
29 | # Add observations to the entity
30 | obs1 = Observation(
31 | project_id=entity_repository.project_id,
32 | content="This is a test observation",
33 | category="testing",
34 | tags=["test"],
35 | )
36 | entity1.observations.append(obs1)
37 |
38 | result1 = await entity_repository.upsert_entity(entity1)
39 | original_id = result1.id
40 |
41 | # Verify entity was created with observations
42 | assert result1.id is not None
43 | assert len(result1.observations) == 1
44 |
45 | # Now try to upsert the same file_path with different content/observations
46 | # This simulates a file being modified and re-synced
47 | entity2 = Entity(
48 | project_id=entity_repository.project_id,
49 | title="Updated Title",
50 | entity_type="note",
51 | permalink="debugging/backup-system/coderabbit-feedback-resolution", # Same permalink
52 | file_path="debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md", # Same file_path
53 | content_type="text/markdown",
54 | created_at=datetime.now(timezone.utc),
55 | updated_at=datetime.now(timezone.utc),
56 | )
57 |
58 | # Add different observations
59 | obs2 = Observation(
60 | project_id=entity_repository.project_id,
61 | content="This is an updated observation",
62 | category="updated",
63 | tags=["updated"],
64 | )
65 | obs3 = Observation(
66 | project_id=entity_repository.project_id,
67 | content="This is a second observation",
68 | category="second",
69 | tags=["second"],
70 | )
71 | entity2.observations.extend([obs2, obs3])
72 |
73 | # This should UPDATE the existing entity, not fail with IntegrityError
74 | result2 = await entity_repository.upsert_entity(entity2)
75 |
76 | # Should update existing entity (same ID)
77 | assert result2.id == original_id
78 | assert result2.title == "Updated Title"
79 | assert result2.file_path == entity1.file_path
80 | assert result2.permalink == entity1.permalink
81 |
82 | # Observations should be updated
83 | assert len(result2.observations) == 2
84 | assert result2.observations[0].content == "This is an updated observation"
85 | assert result2.observations[1].content == "This is a second observation"
86 |
87 |
88 | @pytest.mark.asyncio
89 | async def test_upsert_entity_repeated_sync_same_file(entity_repository: EntityRepository):
90 | """Test that syncing the same file multiple times doesn't cause IntegrityError.
91 |
92 | This tests the specific scenario from issue #187 where files are being
93 | synced repeatedly and hitting UNIQUE constraint violations.
94 | """
95 | file_path = "processes/Complete Process for Uploading New Training Videos.md"
96 | permalink = "processes/complete-process-for-uploading-new-training-videos"
97 |
98 | # Create initial entity
99 | entity1 = Entity(
100 | project_id=entity_repository.project_id,
101 | title="Complete Process for Uploading New Training Videos",
102 | entity_type="note",
103 | permalink=permalink,
104 | file_path=file_path,
105 | content_type="text/markdown",
106 | checksum="abc123",
107 | created_at=datetime.now(timezone.utc),
108 | updated_at=datetime.now(timezone.utc),
109 | )
110 |
111 | result1 = await entity_repository.upsert_entity(entity1)
112 | first_id = result1.id
113 |
114 | # Simulate multiple sync attempts (like the infinite retry loop in the issue)
115 | for i in range(5):
116 | entity_new = Entity(
117 | project_id=entity_repository.project_id,
118 | title="Complete Process for Uploading New Training Videos",
119 | entity_type="note",
120 | permalink=permalink,
121 | file_path=file_path,
122 | content_type="text/markdown",
123 | checksum=f"def{456 + i}", # Different checksum each time
124 | created_at=datetime.now(timezone.utc),
125 | updated_at=datetime.now(timezone.utc),
126 | )
127 |
128 | # Each upsert should succeed and update the existing entity
129 | result = await entity_repository.upsert_entity(entity_new)
130 |
131 | # Should always return the same entity (updated)
132 | assert result.id == first_id
133 | assert result.checksum == entity_new.checksum
134 | assert result.file_path == file_path
135 | assert result.permalink == permalink
136 |
```
--------------------------------------------------------------------------------
/tests/api/test_search_template.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the search template rendering."""
2 |
3 | import datetime
4 | import pytest
5 |
6 | from basic_memory.api.template_loader import TemplateLoader
7 | from basic_memory.schemas.search import SearchItemType, SearchResult
8 |
9 |
10 | @pytest.fixture
11 | def template_loader():
12 | """Return a TemplateLoader instance for testing."""
13 | return TemplateLoader()
14 |
15 |
16 | @pytest.fixture
17 | def search_result():
18 | """Create a sample SearchResult for testing."""
19 | return SearchResult(
20 | title="Test Search Result",
21 | type=SearchItemType.ENTITY,
22 | permalink="test/search-result",
23 | score=0.95,
24 | content="This is a test search result with some content.",
25 | file_path="/path/to/test/search-result.md",
26 | metadata={"created_at": datetime.datetime(2023, 2, 1, 12, 0)},
27 | )
28 |
29 |
30 | @pytest.fixture
31 | def context_with_results(search_result):
32 | """Create a sample context with search results."""
33 | return {
34 | "query": "test query",
35 | "timeframe": "30d",
36 | "has_results": True,
37 | "result_count": 1,
38 | "results": [search_result],
39 | }
40 |
41 |
42 | @pytest.fixture
43 | def context_without_results():
44 | """Create a sample context without search results."""
45 | return {
46 | "query": "empty query",
47 | "timeframe": None,
48 | "has_results": False,
49 | "result_count": 0,
50 | "results": [],
51 | }
52 |
53 |
54 | @pytest.mark.asyncio
55 | async def test_search_with_results(template_loader, context_with_results):
56 | """Test rendering the search template with results."""
57 | result = await template_loader.render("prompts/search.hbs", context_with_results)
58 |
59 | # Check that key elements are present
60 | assert 'Search Results for: "test query" (after 30d)' in result
61 | assert "1.0. Test Search Result" in result
62 | assert "Type**: entity" in result
63 | assert "Relevance Score**: 0.95" in result
64 | assert "This is a test search result with some content." in result
65 | assert 'read_note("test/search-result")' in result
66 | assert "Next Steps" in result
67 | assert "Synthesize and Capture Knowledge" in result
68 |
69 |
70 | @pytest.mark.asyncio
71 | async def test_search_without_results(template_loader, context_without_results):
72 | """Test rendering the search template without results."""
73 | result = await template_loader.render("prompts/search.hbs", context_without_results)
74 |
75 | # Check that key elements are present
76 | assert 'Search Results for: "empty query"' in result
77 | assert "I couldn't find any results for this query." in result
78 | assert "Opportunity to Capture Knowledge!" in result
79 | assert "write_note(" in result
80 | assert 'title="Empty query"' in result
81 | assert "Other Suggestions" in result
82 |
83 |
84 | @pytest.mark.asyncio
85 | async def test_multiple_search_results(template_loader):
86 | """Test rendering the search template with multiple results."""
87 | # Create multiple search results
88 | results = []
89 | for i in range(1, 6): # Create 5 results
90 | results.append(
91 | SearchResult(
92 | title=f"Search Result {i}",
93 | type=SearchItemType.ENTITY,
94 | permalink=f"test/result-{i}",
95 | score=1.0 - (i * 0.1), # Decreasing scores
96 | content=f"Content for result {i}",
97 | file_path=f"/path/to/result-{i}.md",
98 | metadata={},
99 | )
100 | )
101 |
102 | context = {
103 | "query": "multiple results",
104 | "timeframe": None,
105 | "has_results": True,
106 | "result_count": len(results),
107 | "results": results,
108 | }
109 |
110 | result = await template_loader.render("prompts/search.hbs", context)
111 |
112 | # Check that all results are rendered
113 | for i in range(1, 6):
114 | assert f"{i}.0. Search Result {i}" in result
115 | assert f"Content for result {i}" in result
116 | assert f'read_note("test/result-{i}")' in result
117 |
118 |
119 | @pytest.mark.asyncio
120 | async def test_capitalization_in_write_note_template(template_loader, context_with_results):
121 | """Test that the query is capitalized in the write_note template."""
122 | result = await template_loader.render("prompts/search.hbs", context_with_results)
123 |
124 | # The query should be capitalized in the suggested write_note call
125 | assert "Synthesis of Test query Information" in result
126 |
127 |
128 | @pytest.mark.asyncio
129 | async def test_timeframe_display(template_loader):
130 | """Test that the timeframe is displayed correctly when present, and not when absent."""
131 | # Context with timeframe
132 | context_with_timeframe = {
133 | "query": "with timeframe",
134 | "timeframe": "7d",
135 | "has_results": True,
136 | "result_count": 0,
137 | "results": [],
138 | }
139 |
140 | result_with_timeframe = await template_loader.render(
141 | "prompts/search.hbs", context_with_timeframe
142 | )
143 | assert 'Search Results for: "with timeframe" (after 7d)' in result_with_timeframe
144 |
145 | # Context without timeframe
146 | context_without_timeframe = {
147 | "query": "without timeframe",
148 | "timeframe": None,
149 | "has_results": True,
150 | "result_count": 0,
151 | "results": [],
152 | }
153 |
154 | result_without_timeframe = await template_loader.render(
155 | "prompts/search.hbs", context_without_timeframe
156 | )
157 | assert 'Search Results for: "without timeframe"' in result_without_timeframe
158 | assert 'Search Results for: "without timeframe" (after' not in result_without_timeframe
159 |
```
--------------------------------------------------------------------------------
/src/basic_memory/mcp/async_client.py:
--------------------------------------------------------------------------------
```python
1 | from contextlib import asynccontextmanager, AbstractAsyncContextManager
2 | from typing import AsyncIterator, Callable, Optional
3 |
4 | from httpx import ASGITransport, AsyncClient, Timeout
5 | from loguru import logger
6 |
7 | from basic_memory.api.app import app as fastapi_app
8 | from basic_memory.config import ConfigManager
9 |
10 |
11 | # Optional factory override for dependency injection
12 | _client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
13 |
14 |
15 | def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
16 | """Override the default client factory (for cloud app, testing, etc).
17 |
18 | Args:
19 | factory: An async context manager that yields an AsyncClient
20 |
21 | Example:
22 | @asynccontextmanager
23 | async def custom_client_factory():
24 | async with AsyncClient(...) as client:
25 | yield client
26 |
27 | set_client_factory(custom_client_factory)
28 | """
29 | global _client_factory
30 | _client_factory = factory
31 |
32 |
33 | @asynccontextmanager
34 | async def get_client() -> AsyncIterator[AsyncClient]:
35 | """Get an AsyncClient as a context manager.
36 |
37 | This function provides proper resource management for HTTP clients,
38 | ensuring connections are closed after use. It supports three modes:
39 |
40 | 1. **Factory injection** (cloud app, tests):
41 | If a custom factory is set via set_client_factory(), use that.
42 |
43 | 2. **CLI cloud mode**:
44 | When cloud_mode_enabled is True, create HTTP client with auth
45 | token from CLIAuth for requests to cloud proxy endpoint.
46 |
47 | 3. **Local mode** (default):
48 | Use ASGI transport for in-process requests to local FastAPI app.
49 |
50 | Usage:
51 | async with get_client() as client:
52 | response = await client.get("/path")
53 |
54 | Yields:
55 | AsyncClient: Configured HTTP client for the current mode
56 |
57 | Raises:
58 | RuntimeError: If cloud mode is enabled but user is not authenticated
59 | """
60 | if _client_factory:
61 | # Use injected factory (cloud app, tests)
62 | async with _client_factory() as client:
63 | yield client
64 | else:
65 | # Default: create based on config
66 | config = ConfigManager().config
67 | timeout = Timeout(
68 | connect=10.0, # 10 seconds for connection
69 | read=30.0, # 30 seconds for reading response
70 | write=30.0, # 30 seconds for writing request
71 | pool=30.0, # 30 seconds for connection pool
72 | )
73 |
74 | if config.cloud_mode_enabled:
75 | # CLI cloud mode: inject auth when creating client
76 | from basic_memory.cli.auth import CLIAuth
77 |
78 | auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
79 | token = await auth.get_valid_token()
80 |
81 | if not token:
82 | raise RuntimeError(
83 | "Cloud mode enabled but not authenticated. "
84 | "Run 'basic-memory cloud login' first."
85 | )
86 |
87 | # Auth header set ONCE at client creation
88 | proxy_base_url = f"{config.cloud_host}/proxy"
89 | logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
90 | async with AsyncClient(
91 | base_url=proxy_base_url,
92 | headers={"Authorization": f"Bearer {token}"},
93 | timeout=timeout,
94 | ) as client:
95 | yield client
96 | else:
97 | # Local mode: ASGI transport for in-process calls
98 | # Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed
99 | logger.info("Creating ASGI client for local Basic Memory API")
100 | async with AsyncClient(
101 | transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
102 | ) as client:
103 | yield client
104 |
105 |
106 | def create_client() -> AsyncClient:
107 | """Create an HTTP client based on configuration.
108 |
109 | DEPRECATED: Use get_client() context manager instead for proper resource management.
110 |
111 | This function is kept for backward compatibility but will be removed in a future version.
112 | The returned client should be closed manually by calling await client.aclose().
113 |
114 | Returns:
115 | AsyncClient configured for either local ASGI or remote proxy
116 | """
117 | config_manager = ConfigManager()
118 | config = config_manager.config
119 |
120 | # Configure timeout for longer operations like write_note
121 | # Default httpx timeout is 5 seconds which is too short for file operations
122 | timeout = Timeout(
123 | connect=10.0, # 10 seconds for connection
124 | read=30.0, # 30 seconds for reading response
125 | write=30.0, # 30 seconds for writing request
126 | pool=30.0, # 30 seconds for connection pool
127 | )
128 |
129 | if config.cloud_mode_enabled:
130 | # Use HTTP transport to proxy endpoint
131 | proxy_base_url = f"{config.cloud_host}/proxy"
132 | logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
133 | return AsyncClient(base_url=proxy_base_url, timeout=timeout)
134 | else:
135 | # Default: use ASGI transport for local API (development mode)
136 | logger.info("Creating ASGI client for local Basic Memory API")
137 | return AsyncClient(
138 | transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
139 | )
140 |
```
--------------------------------------------------------------------------------
/src/basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py:
--------------------------------------------------------------------------------
```python
1 | """Add Postgres full-text search support with tsvector and GIN indexes
2 |
3 | Revision ID: 314f1ea54dc4
4 | Revises: e7e1f4367280
5 | Create Date: 2025-11-15 18:05:01.025405
6 |
7 | """
8 |
9 | from typing import Sequence, Union
10 |
11 | from alembic import op
12 | import sqlalchemy as sa
13 |
14 |
15 | # revision identifiers, used by Alembic.
16 | revision: str = "314f1ea54dc4"
17 | down_revision: Union[str, None] = "e7e1f4367280"
18 | branch_labels: Union[str, Sequence[str], None] = None
19 | depends_on: Union[str, Sequence[str], None] = None
20 |
21 |
22 | def upgrade() -> None:
23 | """Add PostgreSQL full-text search support.
24 |
25 | This migration:
26 | 1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
27 | 2. Adds generated tsvector column for full-text search
28 | 3. Creates GIN index on the tsvector column for fast text queries
29 | 4. Creates GIN index on metadata JSONB column for fast containment queries
30 |
31 | Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
32 | """
33 | # Check if we're using Postgres
34 | connection = op.get_bind()
35 | if connection.dialect.name == "postgresql":
36 | # Create search_index table for Postgres
37 | # For SQLite, this is a FTS5 virtual table created elsewhere
38 | from sqlalchemy.dialects.postgresql import JSONB
39 |
40 | op.create_table(
41 | "search_index",
42 | sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
43 | sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
44 | sa.Column("title", sa.Text(), nullable=True),
45 | sa.Column("content_stems", sa.Text(), nullable=True),
46 | sa.Column("content_snippet", sa.Text(), nullable=True),
47 | sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
48 | sa.Column("file_path", sa.String(), nullable=True),
49 | sa.Column("type", sa.String(), nullable=True),
50 | sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
51 | sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
52 | sa.Column("relation_type", sa.String(), nullable=True),
53 | sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
54 | sa.Column("category", sa.String(), nullable=True),
55 | sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
56 | sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
57 | sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
58 | sa.PrimaryKeyConstraint(
59 | "id", "type", "project_id"
60 | ), # Composite key: id can repeat across types
61 | sa.ForeignKeyConstraint(
62 | ["project_id"],
63 | ["project.id"],
64 | name="fk_search_index_project_id",
65 | ondelete="CASCADE",
66 | ),
67 | if_not_exists=True,
68 | )
69 |
70 | # Create index on project_id for efficient multi-tenant queries
71 | op.create_index(
72 | "ix_search_index_project_id",
73 | "search_index",
74 | ["project_id"],
75 | unique=False,
76 | )
77 |
78 | # Create unique partial index on permalink for markdown files
79 | # Non-markdown files don't have permalinks, so we use a partial index
80 | op.execute("""
81 | CREATE UNIQUE INDEX uix_search_index_permalink_project
82 | ON search_index (permalink, project_id)
83 | WHERE permalink IS NOT NULL
84 | """)
85 |
86 | # Add tsvector column as a GENERATED ALWAYS column
87 | # This automatically updates when title or content_stems change
88 | op.execute("""
89 | ALTER TABLE search_index
90 | ADD COLUMN textsearchable_index_col tsvector
91 | GENERATED ALWAYS AS (
92 | to_tsvector('english',
93 | coalesce(title, '') || ' ' ||
94 | coalesce(content_stems, '')
95 | )
96 | ) STORED
97 | """)
98 |
99 | # Create GIN index on tsvector column for fast full-text search
100 | op.create_index(
101 | "idx_search_index_fts",
102 | "search_index",
103 | ["textsearchable_index_col"],
104 | unique=False,
105 | postgresql_using="gin",
106 | )
107 |
108 | # Create GIN index on metadata JSONB for fast containment queries
109 | # Using jsonb_path_ops for smaller index size and better performance
110 | op.execute("""
111 | CREATE INDEX idx_search_index_metadata_gin
112 | ON search_index
113 | USING GIN (metadata jsonb_path_ops)
114 | """)
115 |
116 |
117 | def downgrade() -> None:
118 | """Remove PostgreSQL full-text search support."""
119 | connection = op.get_bind()
120 | if connection.dialect.name == "postgresql":
121 | # Drop indexes first
122 | op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
123 | op.drop_index("idx_search_index_fts", table_name="search_index")
124 | op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
125 | op.drop_index("ix_search_index_project_id", table_name="search_index")
126 |
127 | # Drop the generated column
128 | op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
129 |
130 | # Drop the search_index table
131 | op.drop_table("search_index")
132 |
```
--------------------------------------------------------------------------------
/tests/cli/test_project_add_with_local_path.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for bm project add with --local-path flag."""
2 |
3 | import json
4 | from pathlib import Path
5 | from contextlib import asynccontextmanager
6 |
7 | import pytest
8 | from typer.testing import CliRunner
9 |
10 | from basic_memory.cli.app import app
11 |
12 |
13 | @pytest.fixture
14 | def runner():
15 | return CliRunner()
16 |
17 |
18 | @pytest.fixture
19 | def mock_config(tmp_path, monkeypatch):
20 | """Create a mock config in cloud mode using environment variables."""
21 | # Invalidate config cache to ensure clean state for each test
22 | from basic_memory import config as config_module
23 |
24 | config_module._CONFIG_CACHE = None
25 |
26 | config_dir = tmp_path / ".basic-memory"
27 | config_dir.mkdir(parents=True, exist_ok=True)
28 | config_file = config_dir / "config.json"
29 |
30 | config_data = {
31 | "env": "dev",
32 | "projects": {},
33 | "default_project": "main",
34 | "cloud_mode": True,
35 | "cloud_projects": {},
36 | }
37 |
38 | config_file.write_text(json.dumps(config_data, indent=2))
39 |
40 | # Set HOME to tmp_path so ConfigManager uses our test config
41 | monkeypatch.setenv("HOME", str(tmp_path))
42 |
43 | yield config_file
44 |
45 |
46 | @pytest.fixture
47 | def mock_api_client(monkeypatch):
48 | """Stub the API client for project add without stdlib mocks."""
49 | import basic_memory.cli.commands.project as project_cmd
50 |
51 | @asynccontextmanager
52 | async def fake_get_client():
53 | yield object()
54 |
55 | class _Resp:
56 | def json(self):
57 | return {
58 | "message": "Project 'test-project' added successfully",
59 | "status": "success",
60 | "default": False,
61 | "old_project": None,
62 | "new_project": {
63 | "id": 1,
64 | "external_id": "12345678-1234-1234-1234-123456789012",
65 | "name": "test-project",
66 | "path": "/test-project",
67 | "is_default": False,
68 | },
69 | }
70 |
71 | calls: list[tuple[str, dict]] = []
72 |
73 | async def fake_call_post(client, path: str, json: dict, **kwargs):
74 | calls.append((path, json))
75 | return _Resp()
76 |
77 | monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
78 | monkeypatch.setattr(project_cmd, "call_post", fake_call_post)
79 |
80 | return calls
81 |
82 |
83 | def test_project_add_with_local_path_saves_to_config(
84 | runner, mock_config, mock_api_client, tmp_path
85 | ):
86 | """Test that bm project add --local-path saves sync path to config."""
87 | local_sync_dir = tmp_path / "sync" / "test-project"
88 |
89 | result = runner.invoke(
90 | app,
91 | [
92 | "project",
93 | "add",
94 | "test-project",
95 | "--local-path",
96 | str(local_sync_dir),
97 | ],
98 | )
99 |
100 | assert result.exit_code == 0, f"Exit code: {result.exit_code}, Stdout: {result.stdout}"
101 | assert "Project 'test-project' added successfully" in result.stdout
102 | assert "Local sync path configured" in result.stdout
103 | # Check path is present (may be line-wrapped in output)
104 | assert "test-project" in result.stdout
105 | assert "sync" in result.stdout
106 |
107 | # Verify config was updated
108 | config_data = json.loads(mock_config.read_text())
109 | assert "test-project" in config_data["cloud_projects"]
110 | # Use as_posix() for cross-platform compatibility (Windows uses backslashes)
111 | assert config_data["cloud_projects"]["test-project"]["local_path"] == local_sync_dir.as_posix()
112 | assert config_data["cloud_projects"]["test-project"]["last_sync"] is None
113 | assert config_data["cloud_projects"]["test-project"]["bisync_initialized"] is False
114 |
115 | # Verify local directory was created
116 | assert local_sync_dir.exists()
117 | assert local_sync_dir.is_dir()
118 |
119 |
120 | def test_project_add_without_local_path_no_config_entry(runner, mock_config, mock_api_client):
121 | """Test that bm project add without --local-path doesn't save to config."""
122 | result = runner.invoke(
123 | app,
124 | ["project", "add", "test-project"],
125 | )
126 |
127 | assert result.exit_code == 0
128 | assert "Project 'test-project' added successfully" in result.stdout
129 | assert "Local sync path configured" not in result.stdout
130 |
131 | # Verify config was NOT updated with cloud_projects entry
132 | config_data = json.loads(mock_config.read_text())
133 | assert "test-project" not in config_data.get("cloud_projects", {})
134 |
135 |
136 | def test_project_add_local_path_expands_tilde(runner, mock_config, mock_api_client):
137 | """Test that --local-path ~/path expands to absolute path."""
138 | result = runner.invoke(
139 | app,
140 | ["project", "add", "test-project", "--local-path", "~/test-sync"],
141 | )
142 |
143 | assert result.exit_code == 0
144 |
145 | # Verify config has expanded path
146 | config_data = json.loads(mock_config.read_text())
147 | local_path = config_data["cloud_projects"]["test-project"]["local_path"]
148 | # Path should be absolute (starts with / on Unix or drive letter on Windows)
149 | assert Path(local_path).is_absolute()
150 | assert "~" not in local_path
151 | assert local_path.endswith("/test-sync")
152 |
153 |
154 | def test_project_add_local_path_creates_nested_directories(
155 | runner, mock_config, mock_api_client, tmp_path
156 | ):
157 | """Test that --local-path creates nested directories."""
158 | nested_path = tmp_path / "a" / "b" / "c" / "test-project"
159 |
160 | result = runner.invoke(
161 | app,
162 | ["project", "add", "test-project", "--local-path", str(nested_path)],
163 | )
164 |
165 | assert result.exit_code == 0
166 | assert nested_path.exists()
167 | assert nested_path.is_dir()
168 |
```
--------------------------------------------------------------------------------
/src/basic_memory/mcp/clients/knowledge.py:
--------------------------------------------------------------------------------
```python
1 | """Typed client for knowledge/entity API operations.
2 |
3 | Encapsulates all /v2/projects/{project_id}/knowledge/* endpoints.
4 | """
5 |
6 | from typing import Any
7 |
8 | from httpx import AsyncClient
9 |
10 | from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
11 | from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
12 |
13 |
14 | class KnowledgeClient:
15 | """Typed client for knowledge graph entity operations.
16 |
17 | Centralizes:
18 | - API path construction for /v2/projects/{project_id}/knowledge/*
19 | - Response validation via Pydantic models
20 | - Consistent error handling through call_* utilities
21 |
22 | Usage:
23 | async with get_client() as http_client:
24 | client = KnowledgeClient(http_client, project_id)
25 | entity = await client.create_entity(entity_data)
26 | """
27 |
28 | def __init__(self, http_client: AsyncClient, project_id: str):
29 | """Initialize the knowledge client.
30 |
31 | Args:
32 | http_client: HTTPX AsyncClient for making requests
33 | project_id: Project external_id (UUID) for API calls
34 | """
35 | self.http_client = http_client
36 | self.project_id = project_id
37 | self._base_path = f"/v2/projects/{project_id}/knowledge"
38 |
39 | # --- Entity CRUD Operations ---
40 |
41 | async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
42 | """Create a new entity.
43 |
44 | Args:
45 | entity_data: Entity data including title, content, folder, etc.
46 |
47 | Returns:
48 | EntityResponse with created entity details
49 |
50 | Raises:
51 | ToolError: If the request fails
52 | """
53 | response = await call_post(
54 | self.http_client,
55 | f"{self._base_path}/entities",
56 | json=entity_data,
57 | )
58 | return EntityResponse.model_validate(response.json())
59 |
60 | async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
61 | """Update an existing entity (full replacement).
62 |
63 | Args:
64 | entity_id: Entity external_id (UUID)
65 | entity_data: Complete entity data for replacement
66 |
67 | Returns:
68 | EntityResponse with updated entity details
69 |
70 | Raises:
71 | ToolError: If the request fails
72 | """
73 | response = await call_put(
74 | self.http_client,
75 | f"{self._base_path}/entities/{entity_id}",
76 | json=entity_data,
77 | )
78 | return EntityResponse.model_validate(response.json())
79 |
80 | async def get_entity(self, entity_id: str) -> EntityResponse:
81 | """Get an entity by ID.
82 |
83 | Args:
84 | entity_id: Entity external_id (UUID)
85 |
86 | Returns:
87 | EntityResponse with entity details
88 |
89 | Raises:
90 | ToolError: If the entity is not found or request fails
91 | """
92 | response = await call_get(
93 | self.http_client,
94 | f"{self._base_path}/entities/{entity_id}",
95 | )
96 | return EntityResponse.model_validate(response.json())
97 |
98 | async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
99 | """Partially update an entity.
100 |
101 | Args:
102 | entity_id: Entity external_id (UUID)
103 | patch_data: Partial entity data to update
104 |
105 | Returns:
106 | EntityResponse with updated entity details
107 |
108 | Raises:
109 | ToolError: If the request fails
110 | """
111 | response = await call_patch(
112 | self.http_client,
113 | f"{self._base_path}/entities/{entity_id}",
114 | json=patch_data,
115 | )
116 | return EntityResponse.model_validate(response.json())
117 |
118 | async def delete_entity(self, entity_id: str) -> DeleteEntitiesResponse:
119 | """Delete an entity.
120 |
121 | Args:
122 | entity_id: Entity external_id (UUID)
123 |
124 | Returns:
125 | DeleteEntitiesResponse confirming deletion
126 |
127 | Raises:
128 | ToolError: If the entity is not found or request fails
129 | """
130 | response = await call_delete(
131 | self.http_client,
132 | f"{self._base_path}/entities/{entity_id}",
133 | )
134 | return DeleteEntitiesResponse.model_validate(response.json())
135 |
136 | async def move_entity(self, entity_id: str, destination_path: str) -> EntityResponse:
137 | """Move an entity to a new location.
138 |
139 | Args:
140 | entity_id: Entity external_id (UUID)
141 | destination_path: New file path for the entity
142 |
143 | Returns:
144 | EntityResponse with updated entity details
145 |
146 | Raises:
147 | ToolError: If the request fails
148 | """
149 | response = await call_put(
150 | self.http_client,
151 | f"{self._base_path}/entities/{entity_id}/move",
152 | json={"destination_path": destination_path},
153 | )
154 | return EntityResponse.model_validate(response.json())
155 |
156 | # --- Resolution ---
157 |
158 | async def resolve_entity(self, identifier: str) -> str:
159 | """Resolve a string identifier to an entity external_id.
160 |
161 | Args:
162 | identifier: The identifier to resolve (permalink, title, or path)
163 |
164 | Returns:
165 | The resolved entity external_id (UUID)
166 |
167 | Raises:
168 | ToolError: If the identifier cannot be resolved
169 | """
170 | response = await call_post(
171 | self.http_client,
172 | f"{self._base_path}/resolve",
173 | json={"identifier": identifier},
174 | )
175 | data = response.json()
176 | return data["external_id"]
177 |
```
--------------------------------------------------------------------------------
/src/basic_memory/importers/memory_json_importer.py:
--------------------------------------------------------------------------------
```python
1 | """Memory JSON import service for Basic Memory."""
2 |
3 | import logging
4 | from typing import Any, Dict, List, Optional
5 |
6 | from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
7 | from basic_memory.importers.base import Importer
8 | from basic_memory.schemas.importer import EntityImportResult
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class MemoryJsonImporter(Importer[EntityImportResult]):
14 | """Service for importing memory.json format data."""
15 |
16 | def handle_error( # pragma: no cover
17 | self, message: str, error: Optional[Exception] = None
18 | ) -> EntityImportResult:
19 | """Return a failed EntityImportResult with an error message."""
20 | error_msg = f"{message}: {error}" if error else message
21 | return EntityImportResult(
22 | import_count={},
23 | success=False,
24 | error_message=error_msg,
25 | entities=0,
26 | relations=0,
27 | skipped_entities=0,
28 | )
29 |
30 | async def import_data(
31 | self, source_data, destination_folder: str = "", **kwargs: Any
32 | ) -> EntityImportResult:
33 | """Import entities and relations from a memory.json file.
34 |
35 | Args:
36 | source_data: Path to the memory.json file.
37 | destination_folder: Optional destination folder within the project.
38 | **kwargs: Additional keyword arguments.
39 |
40 | Returns:
41 | EntityImportResult containing statistics and status of the import.
42 | """
43 | try:
44 | # First pass - collect all relations by source entity
45 | entity_relations: Dict[str, List[Relation]] = {}
46 | entities: Dict[str, Dict[str, Any]] = {}
47 | skipped_entities: int = 0
48 |
49 | # Ensure the destination folder exists if provided
50 | if destination_folder: # pragma: no cover
51 | await self.ensure_folder_exists(destination_folder)
52 |
53 | # First pass - collect entities and relations
54 | for line in source_data:
55 | data = line
56 | if data["type"] == "entity":
57 | # Handle different possible name keys
58 | entity_name = data.get("name") or data.get("entityName") or data.get("id")
59 | if not entity_name:
60 | logger.warning(f"Entity missing name field: {data}") # pragma: no cover
61 | skipped_entities += 1 # pragma: no cover
62 | continue # pragma: no cover
63 | entities[entity_name] = data
64 | elif data["type"] == "relation":
65 | # Store relation with its source entity
66 | source = data.get("from") or data.get("from_id")
67 | if source not in entity_relations:
68 | entity_relations[source] = []
69 | entity_relations[source].append(
70 | Relation(
71 | type=data.get("relationType") or data.get("relation_type"),
72 | target=data.get("to") or data.get("to_id"),
73 | )
74 | )
75 |
76 | # Second pass - create and write entities
77 | entities_created = 0
78 | for name, entity_data in entities.items():
79 | # Get entity type with fallback
80 | entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
81 |
82 | # Build permalink with optional destination folder prefix
83 | permalink = (
84 | f"{destination_folder}/{entity_type}/{name}"
85 | if destination_folder
86 | else f"{entity_type}/{name}"
87 | )
88 |
89 | # Ensure entity type directory exists using FileService with relative path
90 | entity_type_dir = (
91 | f"{destination_folder}/{entity_type}" if destination_folder else entity_type
92 | )
93 | await self.file_service.ensure_directory(entity_type_dir)
94 |
95 | # Get observations with fallback to empty list
96 | observations = entity_data.get("observations", [])
97 |
98 | entity = EntityMarkdown(
99 | frontmatter=EntityFrontmatter(
100 | metadata={
101 | "type": entity_type,
102 | "title": name,
103 | "permalink": permalink,
104 | }
105 | ),
106 | content=f"# {name}\n",
107 | observations=[Observation(content=obs) for obs in observations],
108 | relations=entity_relations.get(name, []),
109 | )
110 |
111 | # Write file using relative path - FileService handles base_path
112 | file_path = f"{entity.frontmatter.metadata['permalink']}.md"
113 | await self.write_entity(entity, file_path)
114 | entities_created += 1
115 |
116 | relations_count = sum(len(rels) for rels in entity_relations.values())
117 |
118 | return EntityImportResult(
119 | import_count={"entities": entities_created, "relations": relations_count},
120 | success=True,
121 | entities=entities_created,
122 | relations=relations_count,
123 | skipped_entities=skipped_entities,
124 | )
125 |
126 | except Exception as e: # pragma: no cover
127 | logger.exception("Failed to import memory.json")
128 | return self.handle_error("Failed to import memory.json", e)
129 |
```
--------------------------------------------------------------------------------
/tests/api/test_memory_router.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for memory router endpoints."""
2 |
3 | from datetime import datetime
4 |
5 | import pytest
6 |
7 | from basic_memory.schemas.memory import GraphContext
8 |
9 |
10 | @pytest.mark.asyncio
11 | async def test_get_memory_context(client, test_graph, project_url):
12 | """Test getting context from memory URL."""
13 | response = await client.get(f"{project_url}/memory/test/root")
14 | assert response.status_code == 200
15 |
16 | context = GraphContext(**response.json())
17 | assert len(context.results) == 1
18 | assert context.results[0].primary_result.permalink == "test/root"
19 | assert len(context.results[0].related_results) > 0
20 |
21 | # Verify metadata
22 | assert context.metadata.uri == "test/root"
23 | assert context.metadata.depth == 1 # default depth
24 | assert isinstance(context.metadata.generated_at, datetime)
25 | assert context.metadata.primary_count + context.metadata.related_count > 0
26 | assert context.metadata.total_results is not None # Backwards compatibility field
27 |
28 |
29 | @pytest.mark.asyncio
30 | async def test_get_memory_context_pagination(client, test_graph, project_url):
31 | """Test getting context from memory URL."""
32 | response = await client.get(f"{project_url}/memory/test/root?page=1&page_size=1")
33 | assert response.status_code == 200
34 |
35 | context = GraphContext(**response.json())
36 | assert len(context.results) == 1
37 | assert context.results[0].primary_result.permalink == "test/root"
38 | assert len(context.results[0].related_results) > 0
39 |
40 | # Verify metadata
41 | assert context.metadata.uri == "test/root"
42 | assert context.metadata.depth == 1 # default depth
43 | assert isinstance(context.metadata.generated_at, datetime)
44 | assert context.metadata.primary_count > 0
45 |
46 |
47 | @pytest.mark.asyncio
48 | async def test_get_memory_context_pattern(client, test_graph, project_url):
49 | """Test getting context with pattern matching."""
50 | response = await client.get(f"{project_url}/memory/test/*")
51 | assert response.status_code == 200
52 |
53 | context = GraphContext(**response.json())
54 | assert len(context.results) > 1 # Should match multiple test/* paths
55 | assert all("test/" in item.primary_result.permalink for item in context.results)
56 |
57 |
58 | @pytest.mark.asyncio
59 | async def test_get_memory_context_depth(client, test_graph, project_url):
60 | """Test depth parameter affects relation traversal."""
61 | # With depth=1, should only get immediate connections
62 | response = await client.get(f"{project_url}/memory/test/root?depth=1&max_results=20")
63 | assert response.status_code == 200
64 | context1 = GraphContext(**response.json())
65 |
66 | # With depth=2, should get deeper connections
67 | response = await client.get(f"{project_url}/memory/test/root?depth=3&max_results=20")
68 | assert response.status_code == 200
69 | context2 = GraphContext(**response.json())
70 |
71 | # Calculate total related items in all result items
72 | total_related1 = sum(len(item.related_results) for item in context1.results)
73 | total_related2 = sum(len(item.related_results) for item in context2.results)
74 |
75 | assert total_related2 > total_related1
76 |
77 |
78 | @pytest.mark.asyncio
79 | async def test_get_memory_context_timeframe(client, test_graph, project_url):
80 | """Test timeframe parameter filters by date."""
81 | # Recent timeframe
82 | response = await client.get(f"{project_url}/memory/test/root?timeframe=1d")
83 | assert response.status_code == 200
84 | recent = GraphContext(**response.json())
85 |
86 | # Longer timeframe
87 | response = await client.get(f"{project_url}/memory/test/root?timeframe=30d")
88 | assert response.status_code == 200
89 | older = GraphContext(**response.json())
90 |
91 | # Calculate total related items
92 | total_recent_related = (
93 | sum(len(item.related_results) for item in recent.results) if recent.results else 0
94 | )
95 | total_older_related = (
96 | sum(len(item.related_results) for item in older.results) if older.results else 0
97 | )
98 |
99 | assert total_older_related >= total_recent_related
100 |
101 |
102 | @pytest.mark.asyncio
103 | async def test_not_found(client, project_url):
104 | """Test handling of non-existent paths."""
105 | response = await client.get(f"{project_url}/memory/test/does-not-exist")
106 | assert response.status_code == 200
107 |
108 | context = GraphContext(**response.json())
109 | assert len(context.results) == 0
110 |
111 |
112 | @pytest.mark.asyncio
113 | async def test_recent_activity(client, test_graph, project_url):
114 | """Test handling of recent activity."""
115 | response = await client.get(f"{project_url}/memory/recent")
116 | assert response.status_code == 200
117 |
118 | context = GraphContext(**response.json())
119 | assert len(context.results) > 0
120 | assert context.metadata.primary_count > 0
121 |
122 |
123 | @pytest.mark.asyncio
124 | async def test_recent_activity_pagination(client, test_graph, project_url):
125 | """Test pagination for recent activity."""
126 | response = await client.get(f"{project_url}/memory/recent?page=1&page_size=1")
127 | assert response.status_code == 200
128 |
129 | context = GraphContext(**response.json())
130 | assert len(context.results) == 1
131 | assert context.page == 1
132 | assert context.page_size == 1
133 |
134 |
135 | @pytest.mark.asyncio
136 | async def test_recent_activity_by_type(client, test_graph, project_url):
137 | """Test filtering recent activity by type."""
138 | response = await client.get(f"{project_url}/memory/recent?type=relation&type=observation")
139 | assert response.status_code == 200
140 |
141 | context = GraphContext(**response.json())
142 | assert len(context.results) > 0
143 |
144 | # Check for relation and observation types in primary results
145 | primary_types = [item.primary_result.type for item in context.results]
146 | assert "relation" in primary_types or "observation" in primary_types
147 |
```
--------------------------------------------------------------------------------
/tests/markdown/test_markdown_processor.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for MarkdownProcessor.
2 |
3 | Tests focus on the Read -> Modify -> Write pattern and content preservation.
4 | """
5 |
6 | from datetime import datetime
7 | from pathlib import Path
8 |
9 | import pytest
10 |
11 | from basic_memory.markdown.markdown_processor import DirtyFileError, MarkdownProcessor
12 | from basic_memory.markdown.schemas import (
13 | EntityFrontmatter,
14 | EntityMarkdown,
15 | Observation,
16 | Relation,
17 | )
18 |
19 |
20 | @pytest.mark.asyncio
21 | async def test_write_new_minimal_file(markdown_processor: MarkdownProcessor, tmp_path: Path):
22 | """Test creating new file with just title."""
23 | path = tmp_path / "test.md"
24 |
25 | # Create minimal markdown schema
26 | metadata = {}
27 | metadata["title"] = "Test Note"
28 | metadata["type"] = "note"
29 | metadata["permalink"] = "test"
30 | metadata["created"] = datetime(2024, 1, 1)
31 | metadata["modified"] = datetime(2024, 1, 1)
32 | metadata["tags"] = ["test"]
33 | markdown = EntityMarkdown(
34 | frontmatter=EntityFrontmatter(
35 | metadata=metadata,
36 | ),
37 | content="",
38 | )
39 |
40 | # Write file
41 | await markdown_processor.write_file(path, markdown)
42 |
43 | # Read back and verify
44 | content = path.read_text(encoding="utf-8")
45 | assert "---" in content # Has frontmatter
46 | assert "type: note" in content
47 | assert "permalink: test" in content
48 | assert "# Test Note" in content # Added title
49 | assert "tags:" in content
50 | assert "- test" in content
51 |
52 | # Should not have empty sections
53 | assert "## Observations" not in content
54 | assert "## Relations" not in content
55 |
56 |
57 | @pytest.mark.asyncio
58 | async def test_write_new_file_with_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
59 | """Test creating new file with content and sections."""
60 | path = tmp_path / "test.md"
61 |
62 | # Create markdown with content and sections
63 | markdown = EntityMarkdown(
64 | frontmatter=EntityFrontmatter(
65 | type="note",
66 | permalink="test",
67 | title="Test Note",
68 | created=datetime(2024, 1, 1),
69 | modified=datetime(2024, 1, 1),
70 | ),
71 | content="# Custom Title\n\nMy content here.\nMultiple lines.",
72 | observations=[
73 | Observation(
74 | content="Test observation #test",
75 | category="tech",
76 | tags=["test"],
77 | context="test context",
78 | ),
79 | ],
80 | relations=[
81 | Relation(
82 | type="relates_to",
83 | target="other-note",
84 | context="test relation",
85 | ),
86 | ],
87 | )
88 |
89 | # Write file
90 | await markdown_processor.write_file(path, markdown)
91 |
92 | # Read back and verify
93 | content = path.read_text(encoding="utf-8")
94 |
95 | # Check content preserved exactly
96 | assert "# Custom Title" in content
97 | assert "My content here." in content
98 | assert "Multiple lines." in content
99 |
100 | # Check sections formatted correctly
101 | assert "- [tech] Test observation #test (test context)" in content
102 | assert "- relates_to [[other-note]] (test relation)" in content
103 |
104 |
105 | @pytest.mark.asyncio
106 | async def test_update_preserves_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
107 | """Test that updating file preserves existing content."""
108 | path = tmp_path / "test.md"
109 |
110 | # Create initial file
111 | initial = EntityMarkdown(
112 | frontmatter=EntityFrontmatter(
113 | type="note",
114 | permalink="test",
115 | title="Test Note",
116 | created=datetime(2024, 1, 1),
117 | modified=datetime(2024, 1, 1),
118 | ),
119 | content="# My Note\n\nOriginal content here.",
120 | observations=[
121 | Observation(content="First observation", category="note"),
122 | ],
123 | )
124 |
125 | checksum = await markdown_processor.write_file(path, initial)
126 |
127 | # Update with new observation
128 | updated = EntityMarkdown(
129 | frontmatter=initial.frontmatter,
130 | content=initial.content, # Preserve original content
131 | observations=[
132 | initial.observations[0], # Keep original observation
133 | Observation(content="Second observation", category="tech"), # Add new one
134 | ],
135 | )
136 |
137 | # Update file
138 | await markdown_processor.write_file(path, updated, expected_checksum=checksum)
139 |
140 | # Read back and verify
141 | result = await markdown_processor.read_file(path)
142 |
143 | # Original content preserved
144 | assert "Original content here." in result.content
145 |
146 | # Both observations present
147 | assert len(result.observations) == 2
148 | assert any(o.content == "First observation" for o in result.observations)
149 | assert any(o.content == "Second observation" for o in result.observations)
150 |
151 |
152 | @pytest.mark.asyncio
153 | async def test_dirty_file_detection(markdown_processor: MarkdownProcessor, tmp_path: Path):
154 | """Test detection of file modifications."""
155 | path = tmp_path / "test.md"
156 |
157 | # Create initial file
158 | initial = EntityMarkdown(
159 | frontmatter=EntityFrontmatter(
160 | type="note",
161 | permalink="test",
162 | title="Test Note",
163 | created=datetime(2024, 1, 1),
164 | modified=datetime(2024, 1, 1),
165 | ),
166 | content="Initial content",
167 | )
168 |
169 | checksum = await markdown_processor.write_file(path, initial)
170 |
171 | # Modify file directly
172 | path.write_text(path.read_text(encoding="utf-8") + "\nModified!")
173 |
174 | # Try to update with old checksum
175 | update = EntityMarkdown(
176 | frontmatter=initial.frontmatter,
177 | content="New content",
178 | )
179 |
180 | # Should raise DirtyFileError
181 | with pytest.raises(DirtyFileError):
182 | await markdown_processor.write_file(path, update, expected_checksum=checksum)
183 |
184 | # Should succeed without checksum
185 | new_checksum = await markdown_processor.write_file(path, update)
186 | assert new_checksum != checksum
187 |
```
--------------------------------------------------------------------------------
/src/basic_memory/deps/repositories.py:
--------------------------------------------------------------------------------
```python
1 | """Repository dependency injection for basic-memory.
2 |
3 | This module provides repository dependencies:
4 | - EntityRepository
5 | - ObservationRepository
6 | - RelationRepository
7 | - SearchRepository
8 |
9 | Each repository is scoped to a project ID from the request.
10 | """
11 |
12 | from typing import Annotated
13 |
14 | from fastapi import Depends
15 |
16 | from basic_memory.deps.db import SessionMakerDep
17 | from basic_memory.deps.projects import (
18 | ProjectIdDep,
19 | ProjectIdPathDep,
20 | ProjectExternalIdPathDep,
21 | )
22 | from basic_memory.repository.entity_repository import EntityRepository
23 | from basic_memory.repository.observation_repository import ObservationRepository
24 | from basic_memory.repository.relation_repository import RelationRepository
25 | from basic_memory.repository.search_repository import SearchRepository, create_search_repository
26 |
27 |
28 | # --- Entity Repository ---
29 |
30 |
31 | async def get_entity_repository(
32 | session_maker: SessionMakerDep,
33 | project_id: ProjectIdDep,
34 | ) -> EntityRepository:
35 | """Create an EntityRepository instance for the current project."""
36 | return EntityRepository(session_maker, project_id=project_id)
37 |
38 |
39 | EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
40 |
41 |
42 | async def get_entity_repository_v2( # pragma: no cover
43 | session_maker: SessionMakerDep,
44 | project_id: ProjectIdPathDep,
45 | ) -> EntityRepository:
46 | """Create an EntityRepository instance for v2 API (uses integer project_id from path)."""
47 | return EntityRepository(session_maker, project_id=project_id)
48 |
49 |
50 | EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
51 |
52 |
53 | async def get_entity_repository_v2_external(
54 | session_maker: SessionMakerDep,
55 | project_id: ProjectExternalIdPathDep,
56 | ) -> EntityRepository:
57 | """Create an EntityRepository instance for v2 API (uses external_id from path)."""
58 | return EntityRepository(session_maker, project_id=project_id)
59 |
60 |
61 | EntityRepositoryV2ExternalDep = Annotated[
62 | EntityRepository, Depends(get_entity_repository_v2_external)
63 | ]
64 |
65 |
66 | # --- Observation Repository ---
67 |
68 |
69 | async def get_observation_repository(
70 | session_maker: SessionMakerDep,
71 | project_id: ProjectIdDep,
72 | ) -> ObservationRepository:
73 | """Create an ObservationRepository instance for the current project."""
74 | return ObservationRepository(session_maker, project_id=project_id)
75 |
76 |
77 | ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
78 |
79 |
80 | async def get_observation_repository_v2( # pragma: no cover
81 | session_maker: SessionMakerDep,
82 | project_id: ProjectIdPathDep,
83 | ) -> ObservationRepository:
84 | """Create an ObservationRepository instance for v2 API."""
85 | return ObservationRepository(session_maker, project_id=project_id)
86 |
87 |
88 | ObservationRepositoryV2Dep = Annotated[
89 | ObservationRepository, Depends(get_observation_repository_v2)
90 | ]
91 |
92 |
93 | async def get_observation_repository_v2_external(
94 | session_maker: SessionMakerDep,
95 | project_id: ProjectExternalIdPathDep,
96 | ) -> ObservationRepository:
97 | """Create an ObservationRepository instance for v2 API (uses external_id)."""
98 | return ObservationRepository(session_maker, project_id=project_id)
99 |
100 |
101 | ObservationRepositoryV2ExternalDep = Annotated[
102 | ObservationRepository, Depends(get_observation_repository_v2_external)
103 | ]
104 |
105 |
106 | # --- Relation Repository ---
107 |
108 |
109 | async def get_relation_repository(
110 | session_maker: SessionMakerDep,
111 | project_id: ProjectIdDep,
112 | ) -> RelationRepository:
113 | """Create a RelationRepository instance for the current project."""
114 | return RelationRepository(session_maker, project_id=project_id)
115 |
116 |
117 | RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
118 |
119 |
120 | async def get_relation_repository_v2( # pragma: no cover
121 | session_maker: SessionMakerDep,
122 | project_id: ProjectIdPathDep,
123 | ) -> RelationRepository:
124 | """Create a RelationRepository instance for v2 API."""
125 | return RelationRepository(session_maker, project_id=project_id)
126 |
127 |
128 | RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
129 |
130 |
131 | async def get_relation_repository_v2_external(
132 | session_maker: SessionMakerDep,
133 | project_id: ProjectExternalIdPathDep,
134 | ) -> RelationRepository:
135 | """Create a RelationRepository instance for v2 API (uses external_id)."""
136 | return RelationRepository(session_maker, project_id=project_id)
137 |
138 |
139 | RelationRepositoryV2ExternalDep = Annotated[
140 | RelationRepository, Depends(get_relation_repository_v2_external)
141 | ]
142 |
143 |
144 | # --- Search Repository ---
145 |
146 |
147 | async def get_search_repository(
148 | session_maker: SessionMakerDep,
149 | project_id: ProjectIdDep,
150 | ) -> SearchRepository:
151 | """Create a backend-specific SearchRepository instance for the current project.
152 |
153 | Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
154 | based on database backend configuration.
155 | """
156 | return create_search_repository(session_maker, project_id=project_id)
157 |
158 |
159 | SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
160 |
161 |
162 | async def get_search_repository_v2( # pragma: no cover
163 | session_maker: SessionMakerDep,
164 | project_id: ProjectIdPathDep,
165 | ) -> SearchRepository:
166 | """Create a SearchRepository instance for v2 API."""
167 | return create_search_repository(session_maker, project_id=project_id)
168 |
169 |
170 | SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
171 |
172 |
173 | async def get_search_repository_v2_external(
174 | session_maker: SessionMakerDep,
175 | project_id: ProjectExternalIdPathDep,
176 | ) -> SearchRepository:
177 | """Create a SearchRepository instance for v2 API (uses external_id)."""
178 | return create_search_repository(session_maker, project_id=project_id)
179 |
180 |
181 | SearchRepositoryV2ExternalDep = Annotated[
182 | SearchRepository, Depends(get_search_repository_v2_external)
183 | ]
184 |
```
--------------------------------------------------------------------------------
/tests/services/test_project_removal_bug.py:
--------------------------------------------------------------------------------
```python
1 | """Test for project removal bug #254."""
2 |
3 | import os
4 | import tempfile
5 | from datetime import timezone, datetime
6 | from pathlib import Path
7 |
8 | import pytest
9 |
10 | from basic_memory.services.project_service import ProjectService
11 |
12 |
13 | @pytest.mark.asyncio
14 | async def test_remove_project_with_related_entities(project_service: ProjectService):
15 | """Test removing a project that has related entities (reproduces issue #254).
16 |
17 | This test verifies that projects with related entities (entities, observations, relations)
18 | can be properly deleted without foreign key constraint violations.
19 |
20 | The bug was caused by missing foreign key constraints with CASCADE DELETE after
21 | the project table was recreated in migration 647e7a75e2cd.
22 | """
23 | test_project_name = f"test-remove-with-entities-{os.urandom(4).hex()}"
24 | with tempfile.TemporaryDirectory() as temp_dir:
25 | test_root = Path(temp_dir)
26 | test_project_path = str(test_root / "test-remove-with-entities")
27 |
28 | # Make sure the test directory exists
29 | os.makedirs(test_project_path, exist_ok=True)
30 |
31 | try:
32 | # Step 1: Add the test project
33 | await project_service.add_project(test_project_name, test_project_path)
34 |
35 | # Verify project exists
36 | project = await project_service.get_project(test_project_name)
37 | assert project is not None
38 |
39 | # Step 2: Create related entities for this project
40 | from basic_memory.repository.entity_repository import EntityRepository
41 |
42 | entity_repo = EntityRepository(
43 | project_service.repository.session_maker, project_id=project.id
44 | )
45 |
46 | entity_data = {
47 | "title": "Test Entity for Deletion",
48 | "entity_type": "note",
49 | "content_type": "text/markdown",
50 | "project_id": project.id,
51 | "permalink": "test-deletion-entity",
52 | "file_path": "test-deletion-entity.md",
53 | "checksum": "test123",
54 | "created_at": datetime.now(timezone.utc),
55 | "updated_at": datetime.now(timezone.utc),
56 | }
57 | entity = await entity_repo.create(entity_data)
58 | assert entity is not None
59 |
60 | # Step 3: Create observations for the entity
61 | from basic_memory.repository.observation_repository import ObservationRepository
62 |
63 | obs_repo = ObservationRepository(
64 | project_service.repository.session_maker, project_id=project.id
65 | )
66 |
67 | observation_data = {
68 | "entity_id": entity.id,
69 | "content": "This is a test observation",
70 | "category": "note",
71 | }
72 | observation = await obs_repo.create(observation_data)
73 | assert observation is not None
74 |
75 | # Step 4: Create relations involving the entity
76 | from basic_memory.repository.relation_repository import RelationRepository
77 |
78 | rel_repo = RelationRepository(
79 | project_service.repository.session_maker, project_id=project.id
80 | )
81 |
82 | relation_data = {
83 | "from_id": entity.id,
84 | "to_name": "some-target-entity",
85 | "relation_type": "relates-to",
86 | }
87 | relation = await rel_repo.create(relation_data)
88 | assert relation is not None
89 |
90 | # Step 5: Attempt to remove the project
91 | # This should work with proper cascade delete, or fail with foreign key constraint
92 | await project_service.remove_project(test_project_name)
93 |
94 | # Step 6: Verify everything was properly deleted
95 |
96 | # Project should be gone
97 | removed_project = await project_service.get_project(test_project_name)
98 | assert removed_project is None, "Project should have been removed"
99 |
100 | # Related entities should be cascade deleted
101 | remaining_entity = await entity_repo.find_by_id(entity.id)
102 | assert remaining_entity is None, "Entity should have been cascade deleted"
103 |
104 | # Observations should be cascade deleted
105 | remaining_obs = await obs_repo.find_by_id(observation.id)
106 | assert remaining_obs is None, "Observation should have been cascade deleted"
107 |
108 | # Relations should be cascade deleted
109 | remaining_rel = await rel_repo.find_by_id(relation.id)
110 | assert remaining_rel is None, "Relation should have been cascade deleted"
111 |
112 | except Exception as e:
113 | # Check if this is the specific foreign key constraint error from the bug report
114 | if "FOREIGN KEY constraint failed" in str(e):
115 | pytest.fail(
116 | f"Bug #254 reproduced: {e}. "
117 | "This indicates missing foreign key constraints with CASCADE DELETE. "
118 | "Run migration a1b2c3d4e5f6_fix_project_foreign_keys.py to fix this."
119 | )
120 | else:
121 | # Re-raise other unexpected errors
122 | raise e
123 |
124 | finally:
125 | # Clean up - remove project if it still exists
126 | if test_project_name in project_service.projects:
127 | try:
128 | await project_service.remove_project(test_project_name)
129 | except Exception:
130 | # Manual cleanup if remove_project fails
131 | try:
132 | project_service.config_manager.remove_project(test_project_name)
133 | except Exception:
134 | pass
135 |
136 | project = await project_service.get_project(test_project_name)
137 | if project:
138 | await project_service.repository.delete(project.id)
139 |
```