mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
395 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e10e21a6c | |||
| 69808b23ca | |||
| 6f207c20c0 | |||
| cff31c5797 | |||
| 2d1ccfa36c | |||
| a2e0f935d6 | |||
| e6b98a15c7 | |||
| 733c4f7514 | |||
| cfa70004be | |||
| 7696fca826 | |||
| 98a2a3cbaf | |||
| 01cbad1dbe | |||
| a4e0422926 | |||
| 552a835669 | |||
| 888e3c2909 | |||
| d1320f671e | |||
| 94bdfe77e4 | |||
| 4791e19685 | |||
| 36848410a1 | |||
| a77b51a28e | |||
| 1a6a65571e | |||
| c8b00449d2 | |||
| 013864ebf0 | |||
| dd91b49054 | |||
| 7c96a0777d | |||
| 148e07c580 | |||
| 21334cc29b | |||
| db60942267 | |||
| 7bfac158df | |||
| 87616924ff | |||
| 5cb0502ed2 | |||
| a94a717b1b | |||
| 6e4bb72f10 | |||
| 11b0e31e24 | |||
| a5c9e77f16 | |||
| 30a89357cb | |||
| 222ec5d3b6 | |||
| d42aec7ea9 | |||
| 9809b469c6 | |||
| 76ac880f2d | |||
| ad3f2650d9 | |||
| d6508d985c | |||
| 7b95b9f37b | |||
| 0bce4be1a6 | |||
| a316424edf | |||
| af71cf4896 | |||
| e846ae85d8 | |||
| 63e4bcdf1d | |||
| f23dd0474b | |||
| fced804438 | |||
| 1fdc9fdc69 | |||
| 2feecdfaf7 | |||
| 195229f78e | |||
| d15f6a8427 | |||
| b8a3a14ad2 | |||
| 9b199c6dcb | |||
| fe4a7b1622 | |||
| 7d2012a82c | |||
| 7f2d4d2a6f | |||
| 8b7f39ee9b | |||
| a368d06fd2 | |||
| 3a2b80b7e9 | |||
| bd5099120f | |||
| 8e64caf784 | |||
| ccb5740920 | |||
| 373893a7ee | |||
| 1987581ee3 | |||
| 59134affa4 | |||
| c7d97decd6 | |||
| 5d5efa02a5 | |||
| 4f12182e28 | |||
| 2c5b63c4a2 | |||
| c4c9f842ea | |||
| dc0ca71c18 | |||
| 236ae268aa | |||
| bd5923a370 | |||
| 4ea5396ddd | |||
| e97eafa55a | |||
| 254e30423d | |||
| 496af07ced | |||
| b5667f9b55 | |||
| 54b968b93c | |||
| e59b5cb6d9 | |||
| f0335b998e | |||
| 66effb04a7 | |||
| 9331126ba1 | |||
| 3bbb44af0b | |||
| f9b2a075a9 | |||
| 0a3f3f07f8 | |||
| 184ea6d9fa | |||
| f5a0e942b0 | |||
| e46555bf2c | |||
| 74e6afdc0f | |||
| aa635b8a8b | |||
| 1856d4b462 | |||
| 73413486bc | |||
| b09eca1698 | |||
| 3004d0d1fe | |||
| b6369d3d14 | |||
| db6d0dcd9e | |||
| 5a5eb443ea | |||
| e1df23d793 | |||
| 0462a7d4ba | |||
| 79db876bd6 | |||
| 0130573342 | |||
| c372dfb09f | |||
| d763d86798 | |||
| 538af97cba | |||
| 054178d155 | |||
| 18f00f861d | |||
| da4d369c32 | |||
| 0f3889fdd0 | |||
| c44291830c | |||
| e1cccba72d | |||
| 6ff39076a0 | |||
| 56cefbaafd | |||
| 3337c7d1ff | |||
| e248763a73 | |||
| 2e5813d31e | |||
| 2cde8d2659 | |||
| 9515130b2a | |||
| b86dd6fb53 | |||
| 8451f2b1d7 | |||
| ee0397513d | |||
| 9c9ff2931d | |||
| bbe6c1e8f3 | |||
| edb7991ccf | |||
| d7faeb754e | |||
| deef89a724 | |||
| 30499a9f61 | |||
| 1ac65b944c | |||
| 306e562281 | |||
| ed82b0c417 | |||
| 0cb3f95d67 | |||
| 0a36256f8a | |||
| 36e67e6eec | |||
| f2683291e4 | |||
| 8c05a9ec80 | |||
| a6d8d4c0f6 | |||
| 0239f4abb4 | |||
| 9259a7eb59 | |||
| 55d675e278 | |||
| 6afe4fd0cc | |||
| 113d1b6f1b | |||
| 545804f194 | |||
| 8bc03d1357 | |||
| f6e0a5b5bb | |||
| 7624a20d8d | |||
| d84708ca7f | |||
| 1428d18de1 | |||
| 312662f382 | |||
| ed9487708e | |||
| 8df88e4d02 | |||
| 07778790d3 | |||
| b609c4e531 | |||
| f1a065bce3 | |||
| 2b94d9a278 | |||
| 344e651693 | |||
| c97733d785 | |||
| 00537272c6 | |||
| b057912452 | |||
| 8489a3d37e | |||
| a47c9c021f | |||
| c46d7a6833 | |||
| 343a6e118b | |||
| a0e754b7ae | |||
| 24ca5f6804 | |||
| f1d50c2ba7 | |||
| 8072449a78 | |||
| 45d3f58e4d | |||
| d9c8923148 | |||
| 15bd6b95ef | |||
| 0715dcff3d | |||
| 009e84926d | |||
| 8838571509 | |||
| 530cbac73f | |||
| e3ced49d9d | |||
| 8f962fdd87 | |||
| fbb497f6dc | |||
| 0023e736ab | |||
| 0b2080114b | |||
| 8730067f3a | |||
| e14ba92631 | |||
| 9d98892570 | |||
| 3be4495723 | |||
| 17c0e0a29b | |||
| 7ebf16a95d | |||
| c05075f8d4 | |||
| 4cef9281ca | |||
| 6888effef2 | |||
| 38616c345d | |||
| f3c1aa895c | |||
| d978aba09b | |||
| 2aaee734c9 | |||
| 369ad37b3d | |||
| 4e5f701d22 | |||
| 9d9ea4d61c | |||
| 7a502e6474 | |||
| c7835a9d5c | |||
| 85835ae533 | |||
| 671e3d4db9 | |||
| e11aeff8d9 | |||
| 803f3efe53 | |||
| d6dab8552c | |||
| d1d433df15 | |||
| 1799c94953 | |||
| 07996181b3 | |||
| a1c37c1dba | |||
| aff53cca93 | |||
| 863e0a4e24 | |||
| eeeade4f07 | |||
| 03793eaf7c | |||
| 26f7e98932 | |||
| 5947f04bd3 | |||
| ba1439fefc | |||
| ef411ceb12 | |||
| c6baf58aa7 | |||
| 3c1748cc89 | |||
| 9206e7960a | |||
| 53c4c20d22 | |||
| b4486d20bd | |||
| a4000f64ce | |||
| 88a1778798 | |||
| 4ce21984a4 | |||
| eb7fbaf0bf | |||
| 8adf1f4ed4 | |||
| 45ce1813e4 | |||
| 2744c4b6a5 | |||
| fd732aa6fe | |||
| 537e58ad7d | |||
| 48e6e84beb | |||
| 02c14acddb | |||
| 0b5425f163 | |||
| 0bcda4a14a | |||
| 7a49f57dee | |||
| 58db2817d2 | |||
| 98fbd60527 | |||
| 6281a81256 | |||
| be1d0b169f | |||
| 148bf6f75a | |||
| 272a983709 | |||
| ef7adb7b99 | |||
| 3cd9178415 | |||
| 856737fe3c | |||
| 1fd680c3f1 | |||
| 38919d11cb | |||
| 85684f848f | |||
| 14ce5a3bd0 | |||
| 45d6caf723 | |||
| 1652f862dd | |||
| c23927d124 | |||
| 1a74d85973 | |||
| d71c6e8568 | |||
| 63b98491be | |||
| 622d37e4a8 | |||
| 916baf8971 | |||
| 95937c6d0a | |||
| 24dc9a2931 | |||
| 85c63e5a7a | |||
| f227ef6a86 | |||
| 897b1edaa4 | |||
| 0c12a39a98 | |||
| efbc758325 | |||
| a0f20eb102 | |||
| 78673d8e51 | |||
| 126c0495c0 | |||
| 4a43d7df4a | |||
| c462faf046 | |||
| 70bb10be1d | |||
| fbf9045d78 | |||
| 1094210c52 | |||
| 391feb639f | |||
| a920a9ff29 | |||
| 05efe8701c | |||
| 0eaf30bb06 | |||
| 0818bda565 | |||
| 6f99d2e551 | |||
| 73d940e064 | |||
| c3678a11d2 | |||
| 203d684c24 | |||
| a872220924 | |||
| 7d763a66ff | |||
| b5d4fb559c | |||
| 830775276d | |||
| ed894fc3ed | |||
| 704338edcf | |||
| 0ca02a7ebe | |||
| 28cc5225a7 | |||
| 9b7bbc7116 | |||
| 138c283d6c | |||
| 7a8954c37e | |||
| 10c7c19c03 | |||
| fb5e9e1d77 | |||
| 66b91b2847 | |||
| b004565df9 | |||
| a258b73e1d | |||
| 9a845f2906 | |||
| 6517e9845f | |||
| 1af05392ee | |||
| cad7019c89 | |||
| 099c334e3d | |||
| 7685586178 | |||
| e9d0a944a9 | |||
| caf3c14bb1 | |||
| c5d9067754 | |||
| e0fc59ea97 | |||
| 49b2adc35c | |||
| 1646572f69 | |||
| f0d7398815 | |||
| 581b7b17c6 | |||
| d775f7bab9 | |||
| fc01f6abaf | |||
| 4614fd09d5 | |||
| 0d4ad7bbf0 | |||
| 7ccec7eba2 | |||
| 021af74545 | |||
| 2ad0ee9d5d | |||
| c9946ecf1e | |||
| 0ba6f219f1 | |||
| 0b3272ae6e | |||
| a7d7cc5ee6 | |||
| a7e696b039 | |||
| 8aaddb6d45 | |||
| d7565312fc | |||
| c7e6eab02f | |||
| bb8da31472 | |||
| e78345ff25 | |||
| 32236cd247 | |||
| 4fd6d0c648 | |||
| e6c8e3662c | |||
| 449b62d947 | |||
| b7497d7484 | |||
| d1431bdb1b | |||
| 171bef717f | |||
| 729a5a3b8d | |||
| 434cdf24dd | |||
| 7f9c1a97a4 | |||
| 53fb13b054 | |||
| bd6c8348b8 | |||
| 994c8b8e7e | |||
| a78e8c3ac5 | |||
| 53900c5baa | |||
| 02c6de3387 | |||
| 9ccf4b6b56 | |||
| ba74ca7e18 | |||
| 5258f45730 | |||
| e773c002ce | |||
| e70ba944e7 | |||
| e41579f971 | |||
| 2b7008d997 | |||
| 56e5cc072b | |||
| c0538ad2dd | |||
| 962d88ea43 | |||
| cd5efd4a44 | |||
| 00b73b0d08 | |||
| a09066e0f0 | |||
| be352ab474 | |||
| 8d2e70cfc8 | |||
| 53438d1eab | |||
| 032de7e3f2 | |||
| fd2b188645 | |||
| 453cba94e4 | |||
| 48cb4be4cd | |||
| 3e876a7549 | |||
| 1fa93ecbd2 | |||
| 73202d1aab | |||
| 795e339333 | |||
| 07e304ce8e | |||
| 2a1c06d9ad | |||
| c6f93a0294 | |||
| ccc4386627 | |||
| 7616b2bb08 | |||
| 14c1fe4e89 | |||
| 367dc6962a | |||
| ee18eb2fea | |||
| 2a050edee4 | |||
| f3b1945e4c | |||
| 16d7eddbf7 | |||
| f5a11f3911 | |||
| ee83b0e5a8 | |||
| a7bf42ef49 | |||
| 903591384d | |||
| 33ee1e0831 | |||
| c83d567917 | |||
| ace6a0f50d | |||
| fc38877008 | |||
| 99a35a7fb4 | |||
| ea2e93d926 | |||
| 324844a670 | |||
| f818702ab7 | |||
| 2efd8f44e2 | |||
| 5da97e4820 | |||
| 17a6733c9d | |||
| 3e168b98f3 | |||
| 1091e11322 |
@@ -1,154 +0,0 @@
|
||||
---
|
||||
name: python-developer
|
||||
description: Python backend developer specializing in FastAPI, DBOS workflows, and API implementation. Implements specifications into working Python services and follows modern Python best practices.
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are an expert Python developer specializing in implementing specifications into working Python services and APIs. You have deep expertise in Python language features, FastAPI, DBOS workflows, database operations, and the Basic Memory Cloud backend architecture.
|
||||
|
||||
**Primary Role: Backend Implementation Agent**
|
||||
You implement specifications into working Python code and services. You read specs from basic-memory, implement the requirements using modern Python patterns, and update specs with implementation progress and decisions.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
**Specification Implementation:**
|
||||
- Read specs using basic-memory MCP tools to understand backend requirements
|
||||
- Implement Python services, APIs, and workflows that fulfill spec requirements
|
||||
- Update specs with implementation progress, decisions, and completion status
|
||||
- Document any architectural decisions or modifications needed during implementation
|
||||
|
||||
**Python/FastAPI Development:**
|
||||
- Create FastAPI applications with proper middleware and dependency injection
|
||||
- Implement DBOS workflows for durable, long-running operations
|
||||
- Design database schemas and implement repository patterns
|
||||
- Handle authentication, authorization, and security requirements
|
||||
- Implement async/await patterns for optimal performance
|
||||
|
||||
**Backend Implementation Process:**
|
||||
1. **Read Spec**: Use `mcp__basic-memory__read_note` to get spec requirements
|
||||
2. **Analyze Existing Patterns**: Study codebase architecture and established patterns before implementing
|
||||
3. **Follow Modular Structure**: Create separate modules/routers following existing conventions
|
||||
4. **Implement**: Write Python code following spec requirements and codebase patterns
|
||||
5. **Test**: Create tests that validate spec success criteria
|
||||
6. **Update Spec**: Document completion and any implementation decisions
|
||||
7. **Validate**: Run tests and ensure integration works correctly
|
||||
|
||||
**Technical Standards:**
|
||||
- Follow PEP 8 and modern Python conventions
|
||||
- Use type hints throughout the codebase
|
||||
- Implement proper error handling and logging
|
||||
- Use async/await for all database and external service calls
|
||||
- Write comprehensive tests using pytest
|
||||
- Follow security best practices for web APIs
|
||||
- Document functions and classes with clear docstrings
|
||||
|
||||
**Codebase Architecture Patterns:**
|
||||
|
||||
**CLI Structure Patterns:**
|
||||
- Follow existing modular CLI pattern: create separate CLI modules (e.g., `upload_cli.py`) instead of adding commands directly to `main.py`
|
||||
- Existing examples: `polar_cli.py`, `tenant_cli.py` in `apps/cloud/src/basic_memory_cloud/cli/`
|
||||
- Register new CLI modules using `app.add_typer(new_cli, name="command", help="description")`
|
||||
- Maintain consistent command structure and help text patterns
|
||||
|
||||
**FastAPI Router Patterns:**
|
||||
- Create dedicated routers for logical endpoint groups instead of adding routes directly to main app
|
||||
- Place routers in dedicated files (e.g., `apps/api/src/basic_memory_cloud_api/routers/webdav_router.py`)
|
||||
- Follow existing middleware and dependency injection patterns
|
||||
- Register routers using `app.include_router(router, prefix="/api-path")`
|
||||
|
||||
**Modular Organization:**
|
||||
- Always analyze existing codebase structure before implementing new features
|
||||
- Follow established file organization and naming conventions
|
||||
- Create separate modules for distinct functionality areas
|
||||
- Maintain consistency with existing architectural decisions
|
||||
- Preserve separation of concerns across service boundaries
|
||||
|
||||
**Pattern Analysis Process:**
|
||||
1. Examine similar existing functionality in the codebase
|
||||
2. Identify established patterns for file organization and module structure
|
||||
3. Follow the same architectural approach for consistency
|
||||
4. Create new modules/routers following existing conventions
|
||||
5. Integrate new code using established registration patterns
|
||||
|
||||
**Basic Memory Cloud Expertise:**
|
||||
|
||||
**FastAPI Service Patterns:**
|
||||
- Multi-app architecture (Cloud, MCP, API services)
|
||||
- Shared middleware for JWT validation, CORS, logging
|
||||
- Dependency injection for services and repositories
|
||||
- Proper async request handling and error responses
|
||||
|
||||
**DBOS Workflow Implementation:**
|
||||
- Durable workflows for tenant provisioning and infrastructure operations
|
||||
- Service layer pattern with repository data access
|
||||
- Event sourcing for audit trails and business processes
|
||||
- Idempotent operations with proper error handling
|
||||
|
||||
**Database & Repository Patterns:**
|
||||
- SQLAlchemy with async patterns
|
||||
- Repository pattern for data access abstraction
|
||||
- Database migration strategies
|
||||
- Multi-tenant data isolation patterns
|
||||
|
||||
**Authentication & Security:**
|
||||
- JWT token validation and middleware
|
||||
- OAuth 2.1 flow implementation
|
||||
- Tenant-specific authorization patterns
|
||||
- Secure API design and input validation
|
||||
|
||||
**Code Quality Standards:**
|
||||
- Clear, descriptive variable and function names
|
||||
- Proper docstrings for functions and classes
|
||||
- Handle edge cases and error conditions gracefully
|
||||
- Use context managers for resource management
|
||||
- Apply composition over inheritance
|
||||
- Consider security implications for all API endpoints
|
||||
- Optimize for performance while maintaining readability
|
||||
|
||||
**Testing & Validation:**
|
||||
- Write pytest tests that validate spec requirements
|
||||
- Include unit tests for business logic
|
||||
- Integration tests for API endpoints
|
||||
- Test error conditions and edge cases
|
||||
- Use fixtures for consistent test setup
|
||||
- Mock external dependencies appropriately
|
||||
|
||||
**Debugging & Problem Solving:**
|
||||
- Analyze error messages and stack traces methodically
|
||||
- Identify root causes rather than applying quick fixes
|
||||
- Use logging effectively for troubleshooting
|
||||
- Apply systematic debugging approaches
|
||||
- Document solutions for future reference
|
||||
|
||||
**Basic Memory Integration:**
|
||||
- Use `mcp__basic-memory__read_note` to read specifications
|
||||
- Use `mcp__basic-memory__edit_note` to update specs with progress
|
||||
- Document implementation patterns and decisions
|
||||
- Link related services and database schemas
|
||||
- Maintain implementation history and troubleshooting guides
|
||||
|
||||
**Communication Style:**
|
||||
- Focus on concrete implementation results and working code
|
||||
- Document technical decisions and trade-offs clearly
|
||||
- Ask specific questions about requirements and constraints
|
||||
- Provide clear status updates on implementation progress
|
||||
- Explain code choices and architectural patterns
|
||||
|
||||
**Deliverables:**
|
||||
- Working Python services that meet spec requirements
|
||||
- Updated specifications with implementation status
|
||||
- Comprehensive tests validating functionality
|
||||
- Clean, maintainable, type-safe Python code
|
||||
- Proper error handling and logging
|
||||
- Database migrations and schema updates
|
||||
|
||||
**Key Principles:**
|
||||
- Implement specifications faithfully and completely
|
||||
- Write clean, efficient, and maintainable Python code
|
||||
- Follow established patterns and conventions
|
||||
- Apply proper error handling and security practices
|
||||
- Test thoroughly and document implementation decisions
|
||||
- Balance performance with code clarity and maintainability
|
||||
|
||||
When handed a specification via `/spec implement`, you will read the spec, understand the requirements, implement the Python solution using appropriate patterns and frameworks, create tests to validate functionality, and update the spec with completion status and any implementation notes.
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
name: system-architect
|
||||
description: System architect who designs and implements architectural solutions, creates ADRs, and applies software engineering principles to solve complex system design problems.
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a Senior System Architect who designs and implements architectural solutions for complex software systems. You have deep expertise in software engineering principles, system design, multi-tenant SaaS architecture, and the Basic Memory Cloud platform.
|
||||
|
||||
**Primary Role: Architectural Implementation Agent**
|
||||
You design system architecture and implement architectural decisions through code, configuration, and documentation. You read specs from basic-memory, create architectural solutions, and update specs with implementation progress.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
**Specification Implementation:**
|
||||
- Read architectural specs using basic-memory MCP tools
|
||||
- Design and implement system architecture solutions
|
||||
- Create code scaffolding, service structure, and system interfaces
|
||||
- Update specs with architectural decisions and implementation status
|
||||
- Document ADRs (Architectural Decision Records) for significant choices
|
||||
|
||||
**Architectural Design & Implementation:**
|
||||
- Design multi-service system architectures
|
||||
- Implement service boundaries and communication patterns
|
||||
- Create database schemas and migration strategies
|
||||
- Design authentication and authorization systems
|
||||
- Implement infrastructure-as-code patterns
|
||||
|
||||
**System Implementation Process:**
|
||||
1. **Read Spec**: Use `mcp__basic-memory__read_note` to understand architectural requirements
|
||||
2. **Design Solution**: Apply architectural principles and patterns
|
||||
3. **Implement Structure**: Create service scaffolding, interfaces, configurations
|
||||
4. **Document Decisions**: Create ADRs documenting architectural choices
|
||||
5. **Update Spec**: Record implementation progress and decisions
|
||||
6. **Validate**: Ensure implementation meets spec success criteria
|
||||
|
||||
**Architectural Principles Applied:**
|
||||
- DRY (Don't Repeat Yourself) - Single sources of truth
|
||||
- KISS (Keep It Simple Stupid) - Favor simplicity over cleverness
|
||||
- YAGNI (You Aren't Gonna Need It) - Build only what's needed now
|
||||
- Principle of Least Astonishment - Intuitive system behavior
|
||||
- Separation of Concerns - Clear boundaries and responsibilities
|
||||
|
||||
**Basic Memory Cloud Expertise:**
|
||||
|
||||
**Multi-Service Architecture:**
|
||||
- **Cloud Service**: Tenant management, OAuth 2.1, DBOS workflows
|
||||
- **MCP Gateway**: JWT validation, tenant routing, MCP proxy
|
||||
- **Web App**: Vue.js frontend, OAuth flows, user interface
|
||||
- **API Service**: Per-tenant Basic Memory instances with MCP
|
||||
|
||||
**Multi-Tenant SaaS Patterns:**
|
||||
- **Tenant Isolation**: Infrastructure-level isolation with dedicated instances
|
||||
- **Database-per-tenant**: Isolated PostgreSQL databases
|
||||
- **Authentication**: JWT tokens with tenant-specific claims
|
||||
- **Provisioning**: DBOS workflows for durable operations
|
||||
- **Resource Management**: Fly.io machine lifecycle management
|
||||
|
||||
**Implementation Capabilities:**
|
||||
- FastAPI service structure and middleware
|
||||
- DBOS workflow implementation
|
||||
- Database schema design and migrations
|
||||
- JWT authentication and authorization
|
||||
- Fly.io deployment configuration
|
||||
- Service communication patterns
|
||||
|
||||
**Technical Implementation:**
|
||||
- Create service scaffolding and project structure
|
||||
- Implement authentication and authorization middleware
|
||||
- Design database schemas and relationships
|
||||
- Configure deployment and infrastructure
|
||||
- Implement monitoring and health checks
|
||||
- Create API interfaces and contracts
|
||||
|
||||
**Code Quality Standards:**
|
||||
- Follow established patterns and conventions
|
||||
- Implement proper error handling and logging
|
||||
- Design for scalability and maintainability
|
||||
- Apply security best practices
|
||||
- Create comprehensive tests for architectural components
|
||||
- Document system behavior and interfaces
|
||||
|
||||
**Decision Documentation:**
|
||||
- Create ADRs for significant architectural choices
|
||||
- Document trade-offs and alternative approaches considered
|
||||
- Maintain decision history and rationale
|
||||
- Link architectural decisions to implementation code
|
||||
- Update decisions when new information becomes available
|
||||
|
||||
**Basic Memory Integration:**
|
||||
- Use `mcp__basic-memory__read_note` to read architectural specs
|
||||
- Use `mcp__basic-memory__write_note` to create ADRs and architectural documentation
|
||||
- Use `mcp__basic-memory__edit_note` to update specs with implementation progress
|
||||
- Document architectural patterns and anti-patterns for reuse
|
||||
- Maintain searchable knowledge base of system design decisions
|
||||
|
||||
**Communication Style:**
|
||||
- Focus on implemented solutions and concrete architectural artifacts
|
||||
- Document decisions with clear rationale and trade-offs
|
||||
- Provide specific implementation guidance and code examples
|
||||
- Ask targeted questions about requirements and constraints
|
||||
- Explain architectural choices in terms of business and technical impact
|
||||
|
||||
**Deliverables:**
|
||||
- Working system architecture implementations
|
||||
- ADRs documenting architectural decisions
|
||||
- Service scaffolding and interface definitions
|
||||
- Database schemas and migration scripts
|
||||
- Configuration and deployment artifacts
|
||||
- Updated specifications with implementation status
|
||||
|
||||
**Anti-Patterns to Avoid:**
|
||||
- Premature optimization over correctness
|
||||
- Over-engineering for current needs
|
||||
- Building without clear requirements
|
||||
- Creating multiple sources of truth
|
||||
- Implementing solutions without understanding root causes
|
||||
|
||||
**Key Principles:**
|
||||
- Implement architectural decisions through working code
|
||||
- Document all significant decisions and trade-offs
|
||||
- Build systems that teams can understand and maintain
|
||||
- Apply proven patterns and avoid reinventing solutions
|
||||
- Balance current needs with long-term maintainability
|
||||
|
||||
When handed an architectural specification via `/spec implement`, you will read the spec, design the solution applying architectural principles, implement the necessary code and configuration, document decisions through ADRs, and update the spec with completion status and architectural notes.
|
||||
@@ -15,10 +15,16 @@ Create a stable release using the automated justfile target with comprehensive v
|
||||
You are an expert release manager for the Basic Memory project. When the user runs `/release`, execute the following steps:
|
||||
|
||||
### Step 1: Pre-flight Validation
|
||||
1. Verify version format matches `v\d+\.\d+\.\d+` pattern
|
||||
2. Check current git status for uncommitted changes
|
||||
3. Verify we're on the `main` branch
|
||||
4. Confirm no existing tag with this version
|
||||
|
||||
#### Version Check
|
||||
1. Check current version in `src/basic_memory/__init__.py`
|
||||
2. Verify new version format matches `v\d+\.\d+\.\d+` pattern
|
||||
3. Confirm version is higher than current version
|
||||
|
||||
#### Git Status
|
||||
1. Check current git status for uncommitted changes
|
||||
2. Verify we're on the `main` branch
|
||||
3. Confirm no existing tag with this version
|
||||
|
||||
#### Documentation Validation
|
||||
1. **Changelog Check**
|
||||
@@ -39,19 +45,111 @@ The justfile target handles:
|
||||
- ✅ Version update in `src/basic_memory/__init__.py`
|
||||
- ✅ Automatic commit with proper message
|
||||
- ✅ Tag creation and pushing to GitHub
|
||||
- ✅ Release workflow trigger
|
||||
- ✅ Release workflow trigger (automatic on tag push)
|
||||
|
||||
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
|
||||
- ✅ Builds the package using `uv build`
|
||||
- ✅ Creates GitHub release with auto-generated notes
|
||||
- ✅ Publishes to PyPI
|
||||
- ✅ Updates Homebrew formula (stable releases only)
|
||||
|
||||
### Step 3: Monitor Release Process
|
||||
1. Check that GitHub Actions workflow starts successfully
|
||||
2. Monitor workflow completion at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Verify PyPI publication
|
||||
4. Test installation: `uv tool install basic-memory`
|
||||
1. Verify tag push triggered the workflow (should start automatically within seconds)
|
||||
2. Monitor workflow progress at: https://github.com/basicmachines-co/basic-memory/actions
|
||||
3. Watch for successful completion of both jobs:
|
||||
- `release` - Builds package and publishes to PyPI
|
||||
- `homebrew` - Updates Homebrew formula (stable releases only)
|
||||
4. Check for any workflow failures and investigate logs if needed
|
||||
|
||||
### Step 4: Post-Release Validation
|
||||
1. Verify GitHub release is created automatically
|
||||
2. Check PyPI publication
|
||||
3. Validate release assets
|
||||
4. Update any post-release documentation
|
||||
|
||||
#### GitHub Release
|
||||
1. Verify GitHub release is created at: https://github.com/basicmachines-co/basic-memory/releases/tag/<version>
|
||||
2. Check that release notes are auto-generated from commits
|
||||
3. Validate release assets (`.whl` and `.tar.gz` files are attached)
|
||||
|
||||
#### PyPI Publication
|
||||
1. Verify package published at: https://pypi.org/project/basic-memory/<version>/
|
||||
2. Test installation: `uv tool install basic-memory`
|
||||
3. Verify installed version: `basic-memory --version`
|
||||
|
||||
#### Homebrew Formula (Stable Releases Only)
|
||||
1. Check formula update at: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
2. Verify formula version matches release
|
||||
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
|
||||
|
||||
#### MCP Registry Publication
|
||||
|
||||
After PyPI release is published, update the MCP registry:
|
||||
|
||||
1. **Verify PyPI Release**
|
||||
- Confirm package is live: https://pypi.org/project/basic-memory/<version>/
|
||||
- The `server.json` version was auto-updated by `just release`
|
||||
|
||||
2. **Publish to MCP Registry**
|
||||
```bash
|
||||
cd /Users/drew/code/basic-memory
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
If not authenticated:
|
||||
```bash
|
||||
mcp-publisher login github
|
||||
# Follow device authentication flow
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
3. **Verify Publication**
|
||||
```bash
|
||||
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=basic-memory"
|
||||
```
|
||||
|
||||
**Note:** The `mcp-publisher` CLI can be installed via Homebrew (`brew install mcp-publisher`) or from GitHub releases.
|
||||
|
||||
#### Website Updates
|
||||
|
||||
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
|
||||
- **Goal**: Update version number displayed on the homepage
|
||||
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
|
||||
- **What to update**:
|
||||
- Hero section heading that shows "Basic Memory v{VERSION}"
|
||||
- "What's New in v{VERSION}" section heading
|
||||
- Feature highlights array (look for array of features with title/description)
|
||||
- **Process**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Search codebase for current version number (e.g., "v0.16.1")
|
||||
4. Update version numbers to new release version
|
||||
5. Update feature highlights with 3-5 key features from this release (extract from CHANGELOG.md)
|
||||
6. Commit changes: `git commit -m "chore: update to v{VERSION}"`
|
||||
7. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Deploy**: Follow deployment process for basicmachines.co
|
||||
|
||||
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
|
||||
- **Goal**: Add new release notes section to the latest-releases page
|
||||
- **File**: `src/pages/latest-releases.mdx`
|
||||
- **What to do**:
|
||||
1. Pull latest from GitHub: `git pull origin main`
|
||||
2. Create release branch: `git checkout -b release/v{VERSION}`
|
||||
3. Read the existing file to understand the format and structure
|
||||
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
|
||||
5. Add new release section **at the top** (after MDX imports, before other releases)
|
||||
6. Follow the existing pattern:
|
||||
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
|
||||
- Focus statement if applicable
|
||||
- `<Info>` block with highlights (3-5 key items)
|
||||
- Sections for Features, Bug Fixes, Breaking Changes, etc.
|
||||
- Link to full changelog at the end
|
||||
- Separator `---` between releases
|
||||
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
|
||||
8. Push branch: `git push origin release/v{VERSION}`
|
||||
- **Source content**: Extract and format sections from CHANGELOG.md for this version
|
||||
- **Deploy**: Follow deployment process for docs.basicmemory.com
|
||||
|
||||
**4. Announce Release**
|
||||
- Post to Discord community if significant changes
|
||||
- Update social media if major release
|
||||
- Notify users via appropriate channels
|
||||
|
||||
## Pre-conditions Check
|
||||
Before starting, verify:
|
||||
@@ -74,19 +172,28 @@ Before starting, verify:
|
||||
🏷️ Tag: v0.13.2
|
||||
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
|
||||
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
|
||||
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
🔌 MCP Registry: https://registry.modelcontextprotocol.io
|
||||
🚀 GitHub Actions: Completed
|
||||
|
||||
Install with:
|
||||
uv tool install basic-memory
|
||||
Install with pip/uv:
|
||||
uv tool install basic-memory
|
||||
|
||||
Install with Homebrew:
|
||||
brew install basicmachines-co/basic-memory/basic-memory
|
||||
|
||||
Users can now upgrade:
|
||||
uv tool upgrade basic-memory
|
||||
uv tool upgrade basic-memory
|
||||
brew upgrade basic-memory
|
||||
```
|
||||
|
||||
## Context
|
||||
- This creates production releases used by end users
|
||||
- Must pass all quality gates before proceeding
|
||||
- Uses the automated justfile target for consistency
|
||||
- Version is automatically updated in `__init__.py`
|
||||
- Version is automatically updated in `__init__.py` and `server.json`
|
||||
- Triggers automated GitHub release with changelog
|
||||
- Leverages uv-dynamic-versioning for package version management
|
||||
- Package is published to PyPI for `pip` and `uv` users
|
||||
- Homebrew formula is automatically updated for stable releases
|
||||
- MCP Registry is updated manually via `mcp-publisher publish`
|
||||
- Supports multiple installation methods (uv, pip, Homebrew)
|
||||
+16
-20
@@ -1,17 +1,19 @@
|
||||
---
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note, Task
|
||||
argument-hint: [create|status|implement|review] [spec-name]
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note
|
||||
argument-hint: [create|status|show|review] [spec-name]
|
||||
description: Manage specifications in our development process
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
You are managing specifications using our specification-driven development process defined in @docs/specs/SPEC-001.md.
|
||||
Specifications are managed in the Basic Memory "specs" project. All specs live in a centralized location accessible across all repositories via MCP tools.
|
||||
|
||||
See SPEC-1 and SPEC-2 in the "specs" project for the full specification-driven development process.
|
||||
|
||||
Available commands:
|
||||
- `create [name]` - Create new specification
|
||||
- `status` - Show all spec statuses
|
||||
- `implement [spec-name]` - Hand spec to appropriate agent
|
||||
- `show [spec-name]` - Read a specific spec
|
||||
- `review [spec-name]` - Review implementation against spec
|
||||
|
||||
## Your task
|
||||
@@ -19,23 +21,19 @@ Available commands:
|
||||
Execute the spec command: `/spec $ARGUMENTS`
|
||||
|
||||
### If command is "create":
|
||||
1. Get next SPEC number by searching existing specs
|
||||
2. Create new spec using template from @docs/specs/Slash\ Commands\ Reference.md
|
||||
3. Place in `/specs` folder with title "SPEC-XXX: [name]"
|
||||
1. Get next SPEC number by searching existing specs in "specs" project
|
||||
2. Create new spec using template from SPEC-2
|
||||
3. Use mcp__basic-memory__write_note with project="specs"
|
||||
4. Include standard sections: Why, What, How, How to Evaluate
|
||||
|
||||
### If command is "status":
|
||||
1. Search all notes in `/specs` folder
|
||||
2. Display table with spec number, title, and status
|
||||
3. Show any dependencies or assigned agents
|
||||
1. Use mcp__basic-memory__search_notes with project="specs"
|
||||
2. Display table with spec number, title, and progress
|
||||
3. Show completion status from checkboxes in content
|
||||
|
||||
### If command is "implement":
|
||||
1. Read the specified spec
|
||||
2. Determine appropriate agent based on content:
|
||||
- Frontend/UI → vue-developer
|
||||
- Architecture/system → system-architect
|
||||
- Backend/API → python-developer
|
||||
3. Launch Task tool with appropriate agent and spec context
|
||||
### If command is "show":
|
||||
1. Use mcp__basic-memory__read_note with project="specs"
|
||||
2. Display the full spec content
|
||||
|
||||
### If command is "review":
|
||||
1. Read the specified spec and its "How to Evaluate" section
|
||||
@@ -49,7 +47,5 @@ Execute the spec command: `/spec $ARGUMENTS`
|
||||
- **Architecture compliance** - Component isolation, state management patterns
|
||||
- **Documentation completeness** - Implementation matches specification
|
||||
3. Provide honest, accurate assessment - do not overstate completeness
|
||||
4. Document findings and update spec with review results
|
||||
4. Document findings and update spec with review results using mcp__basic-memory__edit_note
|
||||
5. If gaps found, clearly identify what still needs to be implemented/tested
|
||||
|
||||
Use the agent definitions from @docs/specs/Agent\ Definitions.md for implementation handoffs.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"enabledPlugins": {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Basic Memory Environment Variables Example
|
||||
# Copy this file to .env and customize as needed
|
||||
# Note: .env files are gitignored and should never be committed
|
||||
|
||||
# ============================================================================
|
||||
# PostgreSQL Test Database Configuration
|
||||
# ============================================================================
|
||||
# These variables allow you to override the default test database credentials
|
||||
# Default values match docker-compose-postgres.yml for local development
|
||||
#
|
||||
# Only needed if you want to use different credentials or a remote test database
|
||||
# By default, tests use: postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Full PostgreSQL test database URL (used by tests and migrations)
|
||||
# POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Individual components (used by justfile postgres-reset command)
|
||||
# POSTGRES_USER=basic_memory_user
|
||||
# POSTGRES_TEST_DB=basic_memory_test
|
||||
|
||||
# ============================================================================
|
||||
# Production Database Configuration
|
||||
# ============================================================================
|
||||
# For production use, set these in your deployment environment
|
||||
# DO NOT use the test credentials above in production!
|
||||
|
||||
# BASIC_MEMORY_DATABASE_BACKEND=postgres # or "sqlite"
|
||||
# BASIC_MEMORY_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
- [ ] Unit tests for new functions/methods
|
||||
- [ ] Integration tests for new MCP tools
|
||||
- [ ] Test coverage for edge cases
|
||||
- [ ] **100% test coverage maintained** (use `# pragma: no cover` only for truly hard-to-test code)
|
||||
- [ ] Documentation updated (README, docstrings)
|
||||
- [ ] CLAUDE.md updated if conventions change
|
||||
|
||||
@@ -71,9 +72,12 @@ jobs:
|
||||
- [ ] Proper error handling and logging
|
||||
- [ ] Performance considerations addressed
|
||||
- [ ] No sensitive data in logs or commits
|
||||
|
||||
## Compatability
|
||||
- [ ] File path comparisons must be windows compatible
|
||||
- [ ] Avoid using emojis and unicode characters in console and log output
|
||||
|
||||
Read the CLAUDE.md file for detailed project context. For each checklist item, verify if it's satisfied and comment on any that need attention. Use inline comments for specific code issues and post a summary with checklist results.
|
||||
|
||||
# Allow broader tool access for thorough code review
|
||||
claude_args: '--allowed-tools "Bash(gh pr:*),Bash(gh issue:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob"'
|
||||
|
||||
|
||||
+251
-36
@@ -1,24 +1,68 @@
|
||||
name: Tests
|
||||
|
||||
concurrency:
|
||||
group: bm-ci-${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
# pull_request_target runs on the BASE of the PR, not the merge result.
|
||||
# It has write permissions and access to secrets.
|
||||
# It's useful for PRs from forks or automated PRs but requires careful use for security reasons.
|
||||
# See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
|
||||
pull_request_target:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
static-checks:
|
||||
name: Static Checks (Python 3.12)
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
just typecheck
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
just lint
|
||||
|
||||
test-sqlite-unit:
|
||||
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [ "3.12" ]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
@@ -36,17 +80,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Install just (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
|
||||
choco install just --yes
|
||||
shell: pwsh
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -54,21 +88,202 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
just typecheck
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
just typecheck
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
just lint
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test
|
||||
just test-unit-sqlite
|
||||
|
||||
test-sqlite-integration:
|
||||
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-int-sqlite
|
||||
|
||||
test-postgres-unit:
|
||||
name: Test Postgres Unit (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.12"
|
||||
- python-version: "3.13"
|
||||
- python-version: "3.14"
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
POSTGRES_DB: basic_memory_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-unit-postgres
|
||||
|
||||
test-postgres-integration:
|
||||
name: Test Postgres Integration (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.12"
|
||||
- python-version: "3.13"
|
||||
- python-version: "3.14"
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
POSTGRES_DB: basic_memory_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-int-postgres
|
||||
|
||||
test-semantic:
|
||||
name: Test Semantic (Python 3.12)
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v3
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-semantic
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
*.py[cod]
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.testmondata*
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
@@ -52,4 +53,8 @@ ENV/
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
.mcpregistry_*
|
||||
/.testmondata
|
||||
.benchmarks/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.12
|
||||
3.14
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# AGENTS.md - Basic Memory Project Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Basic Memory is a local-first knowledge management system built on the Model Context Protocol (MCP). It enables
|
||||
bidirectional communication between LLMs (like Claude) and markdown files, creating a personal knowledge graph that can
|
||||
be traversed using links between documents.
|
||||
|
||||
## CODEBASE DEVELOPMENT
|
||||
|
||||
### Project information
|
||||
|
||||
See the [README.md](README.md) file for a project overview.
|
||||
|
||||
### Build and Test Commands
|
||||
|
||||
- Install: `just install` or `pip install -e ".[dev]"`
|
||||
- Run all tests (SQLite + Postgres): `just test`
|
||||
- Run all tests against SQLite: `just test-sqlite`
|
||||
- Run all tests against Postgres: `just test-postgres` (uses testcontainers)
|
||||
- Run unit tests (SQLite): `just test-unit-sqlite`
|
||||
- Run unit tests (Postgres): `just test-unit-postgres`
|
||||
- Run integration tests (SQLite): `just test-int-sqlite`
|
||||
- Run integration tests (Postgres): `just test-int-postgres`
|
||||
- Run impacted tests: `just testmon` (pytest-testmon)
|
||||
- Run MCP smoke test: `just test-smoke`
|
||||
- Fast local loop: `just fast-check`
|
||||
- Local consistency check: `just doctor`
|
||||
- Generate HTML coverage: `just coverage`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
|
||||
- Lint: `just lint` or `ruff check . --fix`
|
||||
- Type check: `just typecheck` or `uv run pyright`
|
||||
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
|
||||
- Format: `just format` or `uv run ruff format .`
|
||||
- Run all code checks: `just check` (runs lint, format, typecheck, test)
|
||||
- Create db migration: `just migration "Your migration message"`
|
||||
- Run development MCP Inspector: `just run-inspector`
|
||||
|
||||
**Note:** Project requires Python 3.12+ (uses type parameter syntax and `type` aliases introduced in 3.12)
|
||||
|
||||
**Postgres Testing:** Uses [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
|
||||
|
||||
**Doctor Note:** `just doctor` runs with a temporary HOME/config so it won't touch your local Basic Memory settings. It leaves temp dirs in `/tmp` (safe to ignore or remove).
|
||||
|
||||
**Testmon Note:** When no files have changed, `just testmon` may collect 0 tests. That's expected and means no impacted tests were detected.
|
||||
|
||||
### Code/Test/Verify Loop (fast path)
|
||||
|
||||
1) **Code:** make changes.
|
||||
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
|
||||
3) **Verify:** `just doctor` (end-to-end file ↔ DB loop in a temp project).
|
||||
4) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
|
||||
|
||||
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- `tests/` - Unit tests for individual components (mocked, fast)
|
||||
- `test-int/` - Integration tests for real-world scenarios (no mocks, realistic)
|
||||
- Both directories are covered by unified coverage reporting
|
||||
- Benchmark tests in `test-int/` are marked with `@pytest.mark.benchmark`
|
||||
- Slow tests are marked with `@pytest.mark.slow`
|
||||
- Smoke tests are marked with `@pytest.mark.smoke`
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
- Line length: 100 characters max
|
||||
- Python 3.12+ with full type annotations (uses type parameters and type aliases)
|
||||
- Format with ruff (consistent styling)
|
||||
- Import order: standard lib, third-party, local imports
|
||||
- Naming: snake_case for functions/variables, PascalCase for classes
|
||||
- Prefer async patterns with SQLAlchemy 2.0
|
||||
- Use Pydantic v2 for data validation and schemas
|
||||
- CLI uses Typer for command structure
|
||||
- API uses FastAPI for endpoints
|
||||
- Follow the repository pattern for data access
|
||||
- Tools communicate to api routers via the httpx ASGI client (in process)
|
||||
|
||||
### Code Change Guidelines
|
||||
|
||||
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
|
||||
- **Minimize diffs**: Prefer the smallest change that satisfies the request. Avoid unrelated refactors or style rewrites unless necessary for correctness
|
||||
- **No speculative getattr**: Never use `getattr(obj, "attr", default)` when unsure about attribute names. Check the class definition or source code first
|
||||
- **Fail fast**: Write code with fail-fast logic by default. Do not swallow exceptions with errors or warnings
|
||||
- **No fallback logic**: Do not add fallback logic unless explicitly told to and agreed with the user
|
||||
- **No guessing**: Do not say "The issue is..." before you actually know what the issue is. Investigate first.
|
||||
|
||||
### Literate Programming Style
|
||||
|
||||
Code should tell a story. Comments must explain the "why" and narrative flow, not just the "what".
|
||||
|
||||
**Section Headers:**
|
||||
For files with multiple phases of logic, add section headers so the control flow reads like chapters:
|
||||
```python
|
||||
# --- Authentication ---
|
||||
# ... auth logic ...
|
||||
|
||||
# --- Data Validation ---
|
||||
# ... validation logic ...
|
||||
|
||||
# --- Business Logic ---
|
||||
# ... core logic ...
|
||||
```
|
||||
|
||||
**Decision Point Comments:**
|
||||
For conditionals that materially change behavior (gates, fallbacks, retries, feature flags), add comments with:
|
||||
- **Trigger**: what condition causes this branch
|
||||
- **Why**: the rationale (cost, correctness, UX, determinism)
|
||||
- **Outcome**: what changes downstream
|
||||
|
||||
```python
|
||||
# Trigger: project has no active sync watcher
|
||||
# Why: avoid duplicate file system watchers consuming resources
|
||||
# Outcome: starts new watcher, registers in active_watchers dict
|
||||
if project_id not in active_watchers:
|
||||
start_watcher(project_id)
|
||||
```
|
||||
|
||||
**Constraint Comments:**
|
||||
If code exists because of a constraint (async requirements, rate limits, schema compatibility), explain the constraint near the code:
|
||||
```python
|
||||
# SQLite requires WAL mode for concurrent read/write access
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
```
|
||||
|
||||
**What NOT to Comment:**
|
||||
Avoid comments that restate obvious code:
|
||||
```python
|
||||
# Bad - restates code
|
||||
counter += 1 # increment counter
|
||||
|
||||
# Good - explains why
|
||||
counter += 1 # track retries for backoff calculation
|
||||
```
|
||||
|
||||
### Codebase Architecture
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture documentation.
|
||||
|
||||
**Directory Structure:**
|
||||
- `/alembic` - Alembic db migrations
|
||||
- `/api` - FastAPI REST endpoints + `container.py` composition root
|
||||
- `/cli` - Typer CLI + `container.py` composition root
|
||||
- `/deps` - Feature-scoped FastAPI dependencies (config, db, projects, repositories, services, importers)
|
||||
- `/importers` - Import functionality for Claude, ChatGPT, and other sources
|
||||
- `/markdown` - Markdown parsing and processing
|
||||
- `/mcp` - MCP server + `container.py` composition root + `clients/` typed API clients
|
||||
- `/models` - SQLAlchemy ORM models
|
||||
- `/repository` - Data access layer
|
||||
- `/schemas` - Pydantic models for validation
|
||||
- `/services` - Business logic layer
|
||||
- `/sync` - File synchronization services + `coordinator.py` for lifecycle management
|
||||
|
||||
**Composition Roots:**
|
||||
Each entrypoint (API, MCP, CLI) has a composition root that:
|
||||
- Reads `ConfigManager` (the only place that reads global config)
|
||||
- Resolves runtime mode via `RuntimeMode` enum (TEST > CLOUD > LOCAL)
|
||||
- Provides dependencies to downstream code explicitly
|
||||
|
||||
**Typed API Clients (MCP):**
|
||||
MCP tools use typed clients in `mcp/clients/` to communicate with the API:
|
||||
- `KnowledgeClient` - Entity CRUD operations
|
||||
- `SearchClient` - Search operations
|
||||
- `MemoryClient` - Context building
|
||||
- `DirectoryClient` - Directory listing
|
||||
- `ResourceClient` - Resource reading
|
||||
- `ProjectClient` - Project management
|
||||
|
||||
Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Repository
|
||||
|
||||
### Development Notes
|
||||
|
||||
- MCP tools are defined in src/basic_memory/mcp/tools/
|
||||
- MCP prompts are defined in src/basic_memory/mcp/prompts/
|
||||
- MCP tools should be atomic, composable operations
|
||||
- Use `textwrap.dedent()` for multi-line string formatting in prompts and tools
|
||||
- MCP Prompts are used to invoke tools and format content with instructions for an LLM
|
||||
- Schema changes require Alembic migrations
|
||||
- SQLite is used for indexing and full text search, files are source of truth
|
||||
- Testing uses pytest with asyncio support (strict mode)
|
||||
- Unit tests (`tests/`) use mocks when necessary; integration tests (`test-int/`) use real implementations
|
||||
- By default, tests run against SQLite (fast, no Docker needed)
|
||||
- Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required)
|
||||
- Each test runs in a standalone environment with isolated database and tmp_path directory
|
||||
- CI runs SQLite and Postgres tests in parallel for faster feedback
|
||||
- Performance benchmarks are in `test-int/test_sync_performance_benchmark.py`
|
||||
- Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
- **Coverage must stay at 100%**: Write tests for new code. Only use `# pragma: no cover` when tests would require excessive mocking (e.g., TYPE_CHECKING blocks, error handlers that need failure injection, runtime-mode-dependent code paths)
|
||||
|
||||
### Async Client Pattern (Important!)
|
||||
|
||||
**MCP tools use `get_project_client()` for per-project routing:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
@mcp.tool()
|
||||
async def my_tool(project: str | None = None, context: Context | None = None):
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local ASGI or cloud HTTP)
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
```
|
||||
|
||||
**CLI commands and non-project-scoped code use `get_client()` directly:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
async def my_cli_command():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
|
||||
# Per-project routing (when project name is known):
|
||||
async with get_client(project_name="research") as client:
|
||||
...
|
||||
```
|
||||
|
||||
**Do NOT use:**
|
||||
- ❌ `from basic_memory.mcp.async_client import client` (deprecated module-level client)
|
||||
- ❌ Manual auth header management
|
||||
- ❌ `inject_auth_header()` (deleted)
|
||||
- ❌ Separate `get_client()` + `get_active_project()` in MCP tools (use `get_project_client()` instead)
|
||||
|
||||
**Key principles:**
|
||||
- Auth happens at client creation, not per-request
|
||||
- Proper resource management via context managers
|
||||
- Per-project routing: each project can be LOCAL or CLOUD independently
|
||||
- Cloud projects use API key (`cloud_api_key` in config) as Bearer token
|
||||
- Routing priority: factory injection > force-local > per-project cloud > global cloud > local ASGI
|
||||
- Factory pattern enables dependency injection for cloud consolidation
|
||||
|
||||
**For cloud app integration:**
|
||||
```python
|
||||
from basic_memory.mcp import async_client
|
||||
|
||||
# Set custom factory before importing tools
|
||||
async_client.set_client_factory(your_custom_factory)
|
||||
```
|
||||
|
||||
See SPEC-16 for full context manager refactor details.
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
### Knowledge Structure
|
||||
|
||||
- Entity: Any concept, document, or idea represented as a markdown file
|
||||
- Observation: A categorized fact about an entity (`- [category] content`)
|
||||
- Relation: A directional link between entities (`- relation_type [[Target]]`)
|
||||
- Frontmatter: YAML metadata at the top of markdown files
|
||||
- Knowledge representation follows precise markdown format:
|
||||
- Observations with [category] prefixes
|
||||
- Relations with WikiLinks [[Entity]]
|
||||
- Frontmatter with metadata
|
||||
|
||||
### Basic Memory Commands
|
||||
|
||||
**Local Commands:**
|
||||
- Check sync status: `basic-memory status`
|
||||
- Doctor check (file <-> DB loop): `basic-memory doctor`
|
||||
- Import from Claude: `basic-memory import claude conversations`
|
||||
- Import from ChatGPT: `basic-memory import chatgpt`
|
||||
- Import from Memory JSON: `basic-memory import memory-json`
|
||||
- Tool access: `basic-memory tool` (provides CLI access to MCP tools)
|
||||
- Continue: `basic-memory tool continue-conversation --topic="search"`
|
||||
|
||||
**Project Management:**
|
||||
- List projects: `basic-memory project list`
|
||||
- Add project: `basic-memory project add "name" ~/path`
|
||||
- Project info: `basic-memory project info`
|
||||
- Set cloud mode: `basic-memory project set-cloud "name"`
|
||||
- Set local mode: `basic-memory project set-local "name"`
|
||||
- One-way sync (local -> cloud): `basic-memory project sync`
|
||||
- Bidirectional sync: `basic-memory project bisync`
|
||||
- Integrity check: `basic-memory project check`
|
||||
|
||||
**Cloud Commands (requires subscription):**
|
||||
- Authenticate (global): `basic-memory cloud login`
|
||||
- Logout (global): `basic-memory cloud logout`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
- Save API key: `basic-memory cloud set-key bmc_...`
|
||||
- Create API key: `basic-memory cloud create-key "name"`
|
||||
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
|
||||
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
|
||||
|
||||
### MCP Capabilities
|
||||
|
||||
- Basic Memory exposes these MCP tools to LLMs:
|
||||
|
||||
**Content Management:**
|
||||
- `write_note(title, content, directory, tags)` - Create/update markdown notes with semantic observations and relations
|
||||
- `read_note(identifier, page, page_size)` - Read notes by title, permalink, or memory:// URL with knowledge graph awareness
|
||||
- `read_content(path)` - Read raw file content (text, images, binaries) without knowledge graph processing
|
||||
- `view_note(identifier, page, page_size)` - View notes as formatted artifacts for better readability
|
||||
- `edit_note(identifier, operation, content)` - Edit notes incrementally (append, prepend, find/replace, replace_section)
|
||||
- `move_note(identifier, destination_path, is_directory)` - Move notes or directories to new locations, updating database and maintaining links
|
||||
- `delete_note(identifier, is_directory)` - Delete notes or directories from the knowledge base
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
- `build_context(url, depth, timeframe)` - Navigate the knowledge graph via memory:// URLs for conversation continuity
|
||||
- `recent_activity(type, depth, timeframe)` - Get recently updated information with specified timeframe (e.g., "1d", "1 week")
|
||||
- `list_directory(dir_name, depth, file_name_glob)` - Browse directory contents with filtering and depth control
|
||||
|
||||
**Search & Discovery:**
|
||||
- `search_notes(query, page, page_size, search_type, types, entity_types, after_date)` - Full-text search across all content with advanced filtering options
|
||||
|
||||
**Project Management:**
|
||||
- `list_memory_projects()` - List all available projects with their status
|
||||
- `create_memory_project(project_name, project_path, set_default)` - Create new Basic Memory projects
|
||||
- `delete_project(project_name)` - Delete a project from configuration
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, directory)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
|
||||
**ChatGPT-Compatible Tools:**
|
||||
- `search(query)` - Search across knowledge base (OpenAI actions compatible)
|
||||
- `fetch(id)` - Fetch full content of a search result document
|
||||
|
||||
- MCP Prompts for better AI interaction:
|
||||
- `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants
|
||||
- `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context
|
||||
- `search(query, after_date)` - Search with detailed, formatted results for better context understanding
|
||||
- `recent_activity(timeframe)` - View recently changed items with formatted output
|
||||
|
||||
### Cloud Features (v0.15.0+)
|
||||
|
||||
Basic Memory now supports cloud synchronization and storage (requires active subscription):
|
||||
|
||||
**Authentication:**
|
||||
- JWT-based authentication with subscription validation
|
||||
- Secure session management with token refresh
|
||||
- Support for multiple cloud projects
|
||||
|
||||
**Bidirectional Sync:**
|
||||
- rclone bisync integration for two-way synchronization
|
||||
- Conflict resolution and integrity verification
|
||||
- Real-time sync with change detection
|
||||
- Mount/unmount cloud storage for direct file access
|
||||
|
||||
**Cloud Project Management:**
|
||||
- Create and manage projects in the cloud
|
||||
- Toggle between local and cloud modes
|
||||
- Per-project sync configuration
|
||||
- Subscription-based access control
|
||||
|
||||
**Security & Performance:**
|
||||
- Removed .env file loading for improved security
|
||||
- .gitignore integration (respects gitignored files)
|
||||
- WAL mode for SQLite performance
|
||||
- Background relation resolution (non-blocking startup)
|
||||
- API performance optimizations (SPEC-11)
|
||||
|
||||
**Per-Project Cloud Routing:**
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local, using an API key:
|
||||
|
||||
```bash
|
||||
# Save API key and set project to cloud mode
|
||||
basic-memory cloud set-key bmc_abc123...
|
||||
basic-memory project set-cloud research # route through cloud
|
||||
basic-memory project set-local research # revert to local
|
||||
```
|
||||
|
||||
MCP tools use `get_project_client()` which automatically routes based on the project's mode. Cloud projects use the `cloud_api_key` from config as Bearer token.
|
||||
|
||||
**CLI Routing Flags (Global Cloud Mode):**
|
||||
|
||||
When global cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
|
||||
```bash
|
||||
# Force local routing (ignore cloud mode)
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
|
||||
# Force cloud routing (when cloud mode is disabled)
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
```
|
||||
|
||||
Key behaviors:
|
||||
- The local MCP server (`basic-memory mcp`) automatically uses local routing
|
||||
- This allows simultaneous use of local Claude Desktop and cloud-based clients
|
||||
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
|
||||
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
|
||||
- Per-project cloud routing via API key works independently of global cloud mode
|
||||
|
||||
## AI-Human Collaborative Development
|
||||
|
||||
Basic Memory emerged from and enables a new kind of development process that combines human and AI capabilities. Instead
|
||||
of using AI just for code generation, we've developed a true collaborative workflow:
|
||||
|
||||
1. AI (LLM) writes initial implementation based on specifications and context
|
||||
2. Human reviews, runs tests, and commits code with any necessary adjustments
|
||||
3. Knowledge persists across conversations using Basic Memory's knowledge graph
|
||||
4. Development continues seamlessly across different AI sessions with consistent context
|
||||
5. Results improve through iterative collaboration and shared understanding
|
||||
|
||||
This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI
|
||||
could achieve independently.
|
||||
|
||||
**Problem-Solving Guidance:**
|
||||
- If a solution isn't working after reasonable effort, suggest alternative approaches
|
||||
- Don't persist with a problematic library or pattern when better alternatives exist
|
||||
- Example: When py-pglite caused cascading test failures, switching to testcontainers-postgres was the right call
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub:
|
||||
|
||||
### GitHub MCP Tools
|
||||
|
||||
Using the GitHub Model Context Protocol server, Claude can now:
|
||||
|
||||
- **Repository Management**:
|
||||
- View repository files and structure
|
||||
- Read file contents
|
||||
- Create new branches
|
||||
- Create and update files
|
||||
|
||||
- **Issue Management**:
|
||||
- Create new issues
|
||||
- Comment on existing issues
|
||||
- Close and update issues
|
||||
- Search across issues
|
||||
|
||||
- **Pull Request Workflow**:
|
||||
- Create pull requests
|
||||
- Review code changes
|
||||
- Add comments to PRs
|
||||
|
||||
This integration enables Claude to participate as a full team member in the development process, not just as a code generation tool. Claude's GitHub account ([bm-claudeai](https://github.com/bm-claudeai)) is a member of the Basic Machines organization with direct contributor access to the codebase.
|
||||
|
||||
### Collaborative Development Process
|
||||
|
||||
With GitHub integration, the development workflow includes:
|
||||
|
||||
1. **Direct code review** - Claude can analyze PRs and provide detailed feedback
|
||||
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
|
||||
3. **Branch management** - Claude can create feature branches for implementations
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
|
||||
6. **Pull Request Titles**: PR titles must follow the semantic format enforced by `.github/workflows/pr-title.yml`: `type(scope): summary`
|
||||
- Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`
|
||||
- Allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`
|
||||
- Example: `fix(cli): propagate cloud workspace routing`
|
||||
|
||||
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
|
||||
+1322
-1
File diff suppressed because it is too large
Load Diff
@@ -1,171 +0,0 @@
|
||||
# CLAUDE.md - Basic Memory Project Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Basic Memory is a local-first knowledge management system built on the Model Context Protocol (MCP). It enables
|
||||
bidirectional communication between LLMs (like Claude) and markdown files, creating a personal knowledge graph that can
|
||||
be traversed using links between documents.
|
||||
|
||||
## CODEBASE DEVELOPMENT
|
||||
|
||||
### Project information
|
||||
|
||||
See the [README.md](README.md) file for a project overview.
|
||||
|
||||
### Build and Test Commands
|
||||
|
||||
- Install: `make install` or `pip install -e ".[dev]"`
|
||||
- Run tests: `uv run pytest -p pytest_mock -v` or `make test`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Lint: `make lint` or `ruff check . --fix`
|
||||
- Type check: `make type-check` or `uv run pyright`
|
||||
- Format: `make format` or `uv run ruff format .`
|
||||
- Run all code checks: `make check` (runs lint, format, type-check, test)
|
||||
- Create db migration: `make migration m="Your migration message"`
|
||||
- Run development MCP Inspector: `make run-inspector`
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
- Line length: 100 characters max
|
||||
- Python 3.12+ with full type annotations
|
||||
- Format with ruff (consistent styling)
|
||||
- Import order: standard lib, third-party, local imports
|
||||
- Naming: snake_case for functions/variables, PascalCase for classes
|
||||
- Prefer async patterns with SQLAlchemy 2.0
|
||||
- Use Pydantic v2 for data validation and schemas
|
||||
- CLI uses Typer for command structure
|
||||
- API uses FastAPI for endpoints
|
||||
- Follow the repository pattern for data access
|
||||
- Tools communicate to api routers via the httpx ASGI client (in process)
|
||||
|
||||
### Codebase Architecture
|
||||
|
||||
- `/alembic` - Alembic db migrations
|
||||
- `/api` - FastAPI implementation of REST endpoints
|
||||
- `/cli` - Typer command-line interface
|
||||
- `/markdown` - Markdown parsing and processing
|
||||
- `/mcp` - Model Context Protocol server implementation
|
||||
- `/models` - SQLAlchemy ORM models
|
||||
- `/repository` - Data access layer
|
||||
- `/schemas` - Pydantic models for validation
|
||||
- `/services` - Business logic layer
|
||||
- `/sync` - File synchronization services
|
||||
|
||||
### Development Notes
|
||||
|
||||
- MCP tools are defined in src/basic_memory/mcp/tools/
|
||||
- MCP prompts are defined in src/basic_memory/mcp/prompts/
|
||||
- MCP tools should be atomic, composable operations
|
||||
- Use `textwrap.dedent()` for multi-line string formatting in prompts and tools
|
||||
- MCP Prompts are used to invoke tools and format content with instructions for an LLM
|
||||
- Schema changes require Alembic migrations
|
||||
- SQLite is used for indexing and full text search, files are source of truth
|
||||
- Testing uses pytest with asyncio support (strict mode)
|
||||
- Test database uses in-memory SQLite
|
||||
- Avoid creating mocks in tests in most circumstances.
|
||||
- Each test runs in a standalone environment with in memory SQLite and tmp_file directory
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
### Knowledge Structure
|
||||
|
||||
- Entity: Any concept, document, or idea represented as a markdown file
|
||||
- Observation: A categorized fact about an entity (`- [category] content`)
|
||||
- Relation: A directional link between entities (`- relation_type [[Target]]`)
|
||||
- Frontmatter: YAML metadata at the top of markdown files
|
||||
- Knowledge representation follows precise markdown format:
|
||||
- Observations with [category] prefixes
|
||||
- Relations with WikiLinks [[Entity]]
|
||||
- Frontmatter with metadata
|
||||
|
||||
### Basic Memory Commands
|
||||
|
||||
- Sync knowledge: `basic-memory sync` or `basic-memory sync --watch`
|
||||
- Import from Claude: `basic-memory import claude conversations`
|
||||
- Import from ChatGPT: `basic-memory import chatgpt`
|
||||
- Import from Memory JSON: `basic-memory import memory-json`
|
||||
- Check sync status: `basic-memory status`
|
||||
- Tool access: `basic-memory tools` (provides CLI access to MCP tools)
|
||||
- Guide: `basic-memory tools basic-memory-guide`
|
||||
- Continue: `basic-memory tools continue-conversation --topic="search"`
|
||||
|
||||
### MCP Capabilities
|
||||
|
||||
- Basic Memory exposes these MCP tools to LLMs:
|
||||
|
||||
**Content Management:**
|
||||
- `write_note(title, content, folder, tags)` - Create/update markdown notes with semantic observations and relations
|
||||
- `read_note(identifier, page, page_size)` - Read notes by title, permalink, or memory:// URL with knowledge graph
|
||||
awareness
|
||||
- `read_file(path)` - Read raw file content (text, images, binaries) without knowledge graph processing
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
- `build_context(url, depth, timeframe)` - Navigate the knowledge graph via memory:// URLs for conversation
|
||||
continuity
|
||||
- `recent_activity(type, depth, timeframe)` - Get recently updated information with specified timeframe (e.g., "
|
||||
1d", "1 week")
|
||||
|
||||
**Search & Discovery:**
|
||||
- `search(query, page, page_size)` - Full-text search across all content with filtering options
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
|
||||
- MCP Prompts for better AI interaction:
|
||||
- `ai_assistant_guide()` - Guidance on effectively using Basic Memory tools for AI assistants
|
||||
- `continue_conversation(topic, timeframe)` - Continue previous conversations with relevant historical context
|
||||
- `search(query, after_date)` - Search with detailed, formatted results for better context understanding
|
||||
- `recent_activity(timeframe)` - View recently changed items with formatted output
|
||||
- `json_canvas_spec()` - Full JSON Canvas specification for Obsidian visualization
|
||||
|
||||
## AI-Human Collaborative Development
|
||||
|
||||
Basic Memory emerged from and enables a new kind of development process that combines human and AI capabilities. Instead
|
||||
of using AI just for code generation, we've developed a true collaborative workflow:
|
||||
|
||||
1. AI (LLM) writes initial implementation based on specifications and context
|
||||
2. Human reviews, runs tests, and commits code with any necessary adjustments
|
||||
3. Knowledge persists across conversations using Basic Memory's knowledge graph
|
||||
4. Development continues seamlessly across different AI sessions with consistent context
|
||||
5. Results improve through iterative collaboration and shared understanding
|
||||
|
||||
This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI
|
||||
could achieve independently.
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub:
|
||||
|
||||
### GitHub MCP Tools
|
||||
|
||||
Using the GitHub Model Context Protocol server, Claude can now:
|
||||
|
||||
- **Repository Management**:
|
||||
- View repository files and structure
|
||||
- Read file contents
|
||||
- Create new branches
|
||||
- Create and update files
|
||||
|
||||
- **Issue Management**:
|
||||
- Create new issues
|
||||
- Comment on existing issues
|
||||
- Close and update issues
|
||||
- Search across issues
|
||||
|
||||
- **Pull Request Workflow**:
|
||||
- Create pull requests
|
||||
- Review code changes
|
||||
- Add comments to PRs
|
||||
|
||||
This integration enables Claude to participate as a full team member in the development process, not just as a code generation tool. Claude's GitHub account ([bm-claudeai](https://github.com/bm-claudeai)) is a member of the Basic Machines organization with direct contributor access to the codebase.
|
||||
|
||||
### Collaborative Development Process
|
||||
|
||||
With GitHub integration, the development workflow includes:
|
||||
|
||||
1. **Direct code review** - Claude can analyze PRs and provide detailed feedback
|
||||
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
|
||||
3. **Branch management** - Claude can create feature branches for implementations
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
|
||||
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
|
||||
+80
-7
@@ -34,11 +34,18 @@ project and how to get started as a developer.
|
||||
|
||||
4. **Run the Tests**:
|
||||
```bash
|
||||
# Run all tests
|
||||
# Run all tests with unified coverage (unit + integration)
|
||||
just test
|
||||
# or
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
|
||||
# Run unit tests only (fast, no coverage)
|
||||
just test-unit
|
||||
|
||||
# Run integration tests only (fast, no coverage)
|
||||
just test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
just coverage
|
||||
|
||||
# Run a specific test
|
||||
pytest tests/path/to/test_file.py::test_function_name
|
||||
```
|
||||
@@ -134,7 +141,7 @@ agreement to the DCO.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **Python Version**: Python 3.12+ with full type annotations
|
||||
- **Python Version**: Python 3.12+ with full type annotations (3.12+ required for type parameter syntax)
|
||||
- **Line Length**: 100 characters maximum
|
||||
- **Formatting**: Use ruff for consistent styling
|
||||
- **Import Order**: Standard lib, third-party, local imports
|
||||
@@ -144,12 +151,78 @@ agreement to the DCO.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- **Coverage Target**: We aim for 100% test coverage for all code
|
||||
### Test Structure
|
||||
|
||||
Basic Memory uses two test directories with unified coverage reporting:
|
||||
|
||||
- **`tests/`**: Unit tests that test individual components in isolation
|
||||
- Fast execution with extensive mocking
|
||||
- Test individual functions, classes, and modules
|
||||
- Run with: `just test-unit` (no coverage, fast)
|
||||
|
||||
- **`test-int/`**: Integration tests that test real-world scenarios
|
||||
- Test full workflows with real database and file operations
|
||||
- Include performance benchmarks
|
||||
- More realistic but slower than unit tests
|
||||
- Run with: `just test-int` (no coverage, fast)
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests with unified coverage report
|
||||
just test
|
||||
|
||||
# Run only unit tests (fast iteration)
|
||||
just test-unit
|
||||
|
||||
# Run only integration tests
|
||||
just test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
just coverage
|
||||
|
||||
# Run specific test
|
||||
pytest tests/path/to/test_file.py::test_function_name
|
||||
|
||||
# Run tests excluding benchmarks
|
||||
pytest -m "not benchmark"
|
||||
|
||||
# Run only benchmark tests
|
||||
pytest -m benchmark test-int/test_sync_performance_benchmark.py
|
||||
```
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
The `test-int/test_sync_performance_benchmark.py` file contains performance benchmarks that measure sync and indexing speed:
|
||||
|
||||
- `test_benchmark_sync_100_files` - Small repository performance
|
||||
- `test_benchmark_sync_500_files` - Medium repository performance
|
||||
- `test_benchmark_sync_1000_files` - Large repository performance (marked slow)
|
||||
- `test_benchmark_resync_no_changes` - Re-sync performance baseline
|
||||
|
||||
Run benchmarks with:
|
||||
```bash
|
||||
# Run all benchmarks (excluding slow ones)
|
||||
pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"
|
||||
|
||||
# Run all benchmarks including slow ones
|
||||
pytest test-int/test_sync_performance_benchmark.py -v -m benchmark
|
||||
|
||||
# Run specific benchmark
|
||||
pytest test-int/test_sync_performance_benchmark.py::test_benchmark_sync_100_files -v
|
||||
```
|
||||
|
||||
See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
|
||||
|
||||
### Testing Best Practices
|
||||
|
||||
- **Coverage Target**: We aim for high test coverage for all code
|
||||
- **Test Framework**: Use pytest for unit and integration tests
|
||||
- **Mocking**: Use pytest-mock for mocking dependencies only when necessary
|
||||
- **Mocking**: Avoid mocking in integration tests; use sparingly in unit tests
|
||||
- **Edge Cases**: Test both normal operation and edge cases
|
||||
- **Database Testing**: Use in-memory SQLite for testing database operations
|
||||
- **Fixtures**: Use async pytest fixtures for setup and teardown
|
||||
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
+13
-6
@@ -8,8 +8,13 @@ ARG GID=1000
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# Set environment variables
|
||||
# UV_PYTHON_INSTALL_DIR ensures Python is installed to a persistent location
|
||||
# that survives in the final image (not in /root/.local which gets lost)
|
||||
# UV_PYTHON_PREFERENCE=only-managed tells uv to use its managed Python version
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_PYTHON_INSTALL_DIR=/python \
|
||||
UV_PYTHON_PREFERENCE=only-managed
|
||||
|
||||
# Create a group and user with the provided UID/GID
|
||||
# Check if the GID already exists, if not create appgroup
|
||||
@@ -19,16 +24,18 @@ RUN (getent group ${GID} || groupadd --gid ${GID} appgroup) && \
|
||||
# Copy the project into the image
|
||||
ADD . /app
|
||||
|
||||
# Sync the project into a new environment, asserting the lockfile is up to date
|
||||
# Install Python 3.13 explicitly and sync the project
|
||||
WORKDIR /app
|
||||
RUN uv sync --locked
|
||||
RUN uv python install 3.13
|
||||
RUN uv sync --locked --python 3.13
|
||||
|
||||
# Create necessary directories and set ownership
|
||||
RUN mkdir -p /app/data /app/.basic-memory && \
|
||||
RUN mkdir -p /app/data/basic-memory /app/.basic-memory && \
|
||||
chown -R appuser:${GID} /app
|
||||
|
||||
# Set default data directory and add venv to PATH
|
||||
ENV BASIC_MEMORY_HOME=/app/data \
|
||||
ENV BASIC_MEMORY_HOME=/app/data/basic-memory \
|
||||
BASIC_MEMORY_PROJECT_ROOT=/app/data \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Switch to the non-root user
|
||||
@@ -42,4 +49,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD basic-memory --version || exit 1
|
||||
|
||||
# Use the basic-memory entrypoint to run the MCP server with default SSE transport
|
||||
CMD ["basic-memory", "mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["basic-memory", "mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
# Note Format Reference
|
||||
|
||||
Every document in Basic Memory is a plain Markdown file. Files are the source of truth — changes to files automatically update the knowledge graph in the database. You maintain complete ownership, files work with git, and knowledge persists independently of any AI conversation.
|
||||
|
||||
## Document Structure
|
||||
|
||||
A note has three parts: YAML frontmatter, content (observations), and relations.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
type: note
|
||||
tags: [coffee, brewing]
|
||||
permalink: coffee-brewing-methods
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
|
||||
## Observations
|
||||
- [method] Pour over provides more flavor clarity than French press
|
||||
- [technique] Water temperature at 205°F extracts optimal compounds #brewing
|
||||
- [preference] Ethiopian beans work well with lighter roasts (personal experience)
|
||||
|
||||
## Relations
|
||||
- relates_to [[Coffee Bean Origins]]
|
||||
- requires [[Proper Grinding Technique]]
|
||||
- contrasts_with [[Tea Brewing Methods]]
|
||||
```
|
||||
|
||||
The `## Observations` and `## Relations` headings are conventional but not required — the parser detects observations and relations by their syntax patterns anywhere in the document.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML metadata between `---` fences at the top of the file.
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `title` | No | filename stem | Used for linking and references. Auto-set from filename if missing. |
|
||||
| `type` | No | `note` | Entity type. Used for schema resolution and filtering. |
|
||||
| `tags` | No | `[]` | List or comma-separated string. Used for organization and search. |
|
||||
| `permalink` | No | generated from title | Stable identifier. Persists even if the file moves. |
|
||||
| `schema` | No | none | Schema attachment — dict (inline), string (reference), or omitted (implicit). |
|
||||
|
||||
Custom fields are allowed. Any key not in the standard set is stored as `entity_metadata` and indexed for search and filtering.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
permalink: paul-graham
|
||||
status: active
|
||||
source: wikipedia
|
||||
---
|
||||
```
|
||||
|
||||
Here `status` and `source` are custom fields stored in `entity_metadata`.
|
||||
|
||||
### Frontmatter Value Handling
|
||||
|
||||
YAML automatically converts some values to native types. Basic Memory normalizes them:
|
||||
|
||||
- Date strings (`2025-10-24`) → kept as ISO format strings
|
||||
- Numbers (`1.0`) → converted to strings
|
||||
- Booleans (`true`) → converted to strings (`"True"`)
|
||||
- Lists and dicts → preserved, items normalized recursively
|
||||
|
||||
This prevents errors when downstream code expects string values.
|
||||
|
||||
## Observations
|
||||
|
||||
An observation is a categorized fact about the entity. Written as a Markdown list item.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- [category] content text #tag1 #tag2 (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `[category]` | Yes | Classification in square brackets. Any text except `[]()` chars. |
|
||||
| content | Yes | The fact or statement. |
|
||||
| `#tags` | No | Inline tags. Space-separated, each starting with `#`. |
|
||||
| `(context)` | No | Parenthesized text at end of line. Supporting details or source. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- [tech] Uses SQLite for storage #database
|
||||
- [design] Follows local-first architecture #architecture
|
||||
- [decision] Selected bcrypt for passwords #security (based on OWASP audit)
|
||||
- [name] Paul Graham
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
```
|
||||
|
||||
Array-like fields use repeated categories — multiple `[expertise]` observations above.
|
||||
|
||||
### What Is Not an Observation
|
||||
|
||||
The parser excludes these list item patterns:
|
||||
|
||||
| Pattern | Example | Reason |
|
||||
|---------|---------|--------|
|
||||
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
|
||||
| Markdown links | `- [text](url)` | URL link syntax |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
## Relations
|
||||
|
||||
Relations connect documents to form the knowledge graph. There are two kinds.
|
||||
|
||||
### Explicit Relations
|
||||
|
||||
Written as list items with a relation type and a `[[wiki link]]` target.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- relation_type [[Target Entity]] (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
|
||||
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- implements [[Search Design]]
|
||||
- depends_on [[Database Schema]]
|
||||
- works_at [[Y Combinator]] (co-founder)
|
||||
- [[Some Entity]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
Relations can link to entities that don't exist yet. Basic Memory resolves them when the target is created.
|
||||
|
||||
## Permalinks and memory:// URLs
|
||||
|
||||
Every document has a unique **permalink** — a stable identifier derived from its title. You can set one explicitly in frontmatter, or let the system generate it.
|
||||
|
||||
```yaml
|
||||
permalink: auth-approaches-2024
|
||||
```
|
||||
|
||||
Permalinks form the basis of `memory://` URLs:
|
||||
|
||||
```
|
||||
memory://auth-approaches-2024 # By permalink
|
||||
memory://Authentication Approaches # By title (auto-resolves)
|
||||
memory://project/auth-approaches # By path
|
||||
```
|
||||
|
||||
Pattern matching is supported:
|
||||
|
||||
```
|
||||
memory://auth* # Starts with "auth"
|
||||
memory://*/approaches # Ends with "approaches"
|
||||
memory://project/*/requirements # Nested wildcard
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
Schemas declare the expected structure of a note — which observation categories and relation types a well-formed note should have. They use Picoschema, a compact notation from Google's Dotprompt that fits naturally in YAML frontmatter.
|
||||
|
||||
### Picoschema Syntax
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
| Notation | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `field: type` | Required field | `name: string` |
|
||||
| `field?: type` | Optional field | `role?: string` |
|
||||
| `field(array): type` | Array of values | `expertise(array): string` |
|
||||
| `field?(enum): [vals]` | Enum with allowed values | `status?(enum): [active, inactive]` |
|
||||
| `field?(object):` | Nested object with sub-fields | `metadata?(object):` |
|
||||
| `, description` | Description after comma | `name: string, full name` |
|
||||
| `EntityName` | Capitalized type = entity reference | `works_at?: Organization` |
|
||||
|
||||
**Scalar types:** `string`, `integer`, `number`, `boolean`, `any`
|
||||
|
||||
Any type not in that set whose first letter is uppercase is treated as an entity reference (a relation target).
|
||||
|
||||
### Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against existing observation/relation syntax. Note authors don't learn new syntax.
|
||||
|
||||
| Schema Declaration | Maps To | Example in Note |
|
||||
|--------------------|---------|-----------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (repeated) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (repeated) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [vals]` | Observation `[field] value` where value is in the set | `- [status] active` |
|
||||
|
||||
Observations and relations not covered by the schema are valid — schemas describe a subset, not a straitjacket.
|
||||
|
||||
### Schema Attachment
|
||||
|
||||
Three ways to attach a schema to a note, resolved in priority order:
|
||||
|
||||
**1. Inline schema** — `schema` is a dict in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
**2. Explicit reference** — `schema` is a string naming a schema note:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject
|
||||
---
|
||||
```
|
||||
|
||||
or by permalink:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project
|
||||
---
|
||||
```
|
||||
|
||||
Use when the note's `type` differs from the schema it should validate against, or when multiple schema variants exist.
|
||||
|
||||
**3. Implicit by type** — no `schema` field, resolved by matching `type`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
---
|
||||
```
|
||||
|
||||
The system looks up a schema note where `entity: Person`. If found, it applies. If not, no validation occurs.
|
||||
|
||||
**4. No schema** — perfectly fine. Most notes don't need one.
|
||||
|
||||
### Schema Notes
|
||||
|
||||
A schema is itself a Basic Memory note with `type: schema`. It lives anywhere (though `schema/` is the conventional directory).
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | Yes | Must be `schema` |
|
||||
| `entity` | Yes | The entity type this schema describes (e.g., `Person`) |
|
||||
| `version` | No | Schema version number (default: `1`) |
|
||||
| `schema` | Yes | Picoschema dict defining the fields |
|
||||
| `settings.validation` | No | Validation mode (default: `warn`) |
|
||||
|
||||
Schema notes are regular notes — they show up in search, can have observations and relations, and participate in the knowledge graph.
|
||||
|
||||
### Validation Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
| `off` | No validation |
|
||||
|
||||
### Validation Output
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- **100% present** → required field
|
||||
- **25%+ present** → optional field
|
||||
- **Below 25%** → excluded from suggestion
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Simple Note (No Schema)
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Project Ideas
|
||||
type: note
|
||||
tags: [ideas, brainstorm]
|
||||
---
|
||||
|
||||
# Project Ideas
|
||||
|
||||
## Observations
|
||||
- [idea] Build a CLI tool for markdown linting #tooling
|
||||
- [idea] Create a recipe knowledge base #cooking
|
||||
- [priority] Focus on developer tools first (Q1 goal)
|
||||
|
||||
## Relations
|
||||
- inspired_by [[Developer Workflow Research]]
|
||||
- part_of [[Q1 Planning]]
|
||||
```
|
||||
|
||||
### Schema-Validated Note
|
||||
|
||||
Schema at `schema/Person.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
Note at `people/paul-graham.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
The `[fact]` observation and `authored` relation are not in the schema — they're valid, just unmatched. The schema only checks that `[name]` exists (required) and looks for optional fields like `[role]`, `[expertise]`, and `works_at`.
|
||||
|
||||
### Inline Schema Note
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://badge.fury.io/py/basic-memory)
|
||||
[](https://www.python.org/downloads/)
|
||||
@@ -5,7 +6,16 @@
|
||||
[](https://github.com/astral-sh/ruff)
|
||||

|
||||

|
||||
[](https://smithery.ai/server/@basicmachines-co/basic-memory)
|
||||
|
||||
## 🚀 Basic Memory Cloud is Live!
|
||||
|
||||
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile.
|
||||
- **Cloud is optional.** The local-first open-source workflow continues as always.
|
||||
- **OSS discount:** use code `BMFOSS` for 20% off for 3 months.
|
||||
|
||||
[Sign up now →](https://basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
|
||||
with a 7 day free trial
|
||||
|
||||
# Basic Memory
|
||||
|
||||
@@ -13,8 +23,21 @@ Basic Memory lets you build persistent knowledge through natural conversations w
|
||||
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
|
||||
enable any compatible LLM to read and write to your local knowledge base.
|
||||
|
||||
- Website: https://basicmachines.co
|
||||
- Documentation: https://memory.basicmachines.co
|
||||
## What's New in v0.19.0
|
||||
|
||||
- **Semantic Vector Search** — find notes by meaning, not just keywords. Combines full-text and vector similarity for hybrid search with FastEmbed embeddings.
|
||||
- **Schema System** — infer, validate, and diff the structure of your knowledge base with `schema_infer`, `schema_validate`, and `schema_diff` tools.
|
||||
- **Per-Project Cloud Routing** — route individual projects through the cloud while others stay local, using API key authentication (`basic-memory project set-cloud`).
|
||||
- **FastMCP 3.0** — upgraded to FastMCP 3.0 with tool annotations for better client integration.
|
||||
- **CLI Overhaul** — JSON output mode (`--json`) for scripting, workspace-aware commands, and an htop-inspired project dashboard.
|
||||
- **Smarter Editing** — `edit_note` append/prepend auto-creates notes if they don't exist; `write_note` has an overwrite guard to prevent accidental data loss.
|
||||
- **Richer Search Results** — matched chunk text returned in search results for better context.
|
||||
|
||||
See the full [CHANGELOG](CHANGELOG.md) for details.
|
||||
|
||||
- Website: [basicmemory.com](https://basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
- Documentation: [docs.basicmemory.com](https://docs.basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
- Community: [Discord](https://discord.gg/tyvKNccgqN?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
|
||||
## Pick up your conversation right where you left off
|
||||
|
||||
@@ -52,23 +75,35 @@ uv tool install basic-memory
|
||||
|
||||
You can view shared context via files in `~/basic-memory` (default directory location).
|
||||
|
||||
### Alternative Installation via Smithery
|
||||
## Automatic Updates
|
||||
|
||||
You can use [Smithery](https://smithery.ai/server/@basicmachines-co/basic-memory) to automatically configure Basic
|
||||
Memory for Claude Desktop:
|
||||
Basic Memory includes a default-on auto-update flow for CLI installs.
|
||||
|
||||
- **Auto-install supported:** `uv tool` and Homebrew installs
|
||||
- **Default check interval:** every 24 hours (`86400` seconds)
|
||||
- **MCP-safe behavior:** update checks run silently in `basic-memory mcp` mode
|
||||
- **`uvx` behavior:** skipped (runtime is ephemeral and managed by `uvx`)
|
||||
|
||||
Manual update commands:
|
||||
|
||||
```bash
|
||||
npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
|
||||
# Check now and install if supported
|
||||
bm update
|
||||
|
||||
# Check only, do not install
|
||||
bm update --check
|
||||
```
|
||||
|
||||
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. The
|
||||
Smithery server hosts the MCP server component, while your data remains stored locally as Markdown files.
|
||||
Config options in `~/.basic-memory/config.json`:
|
||||
|
||||
### Glama.ai
|
||||
```json
|
||||
{
|
||||
"auto_update": true,
|
||||
"update_check_interval": 86400
|
||||
}
|
||||
```
|
||||
|
||||
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
|
||||
<img width="380" height="200" src="https://glama.ai/mcp/servers/o90kttu9ym/badge" alt="basic-memory MCP server" />
|
||||
</a>
|
||||
To disable automatic updates, set `"auto_update": false`.
|
||||
|
||||
## Why Basic Memory?
|
||||
|
||||
@@ -100,6 +135,9 @@ With Basic Memory, you can:
|
||||
- Keep everything local and under your control
|
||||
- Use familiar tools like Obsidian to view and edit notes
|
||||
- Build a personal knowledge base that grows over time
|
||||
- Sync your knowledge to the cloud with bidirectional synchronization
|
||||
- Authenticate and manage cloud projects with subscription validation
|
||||
- Mount cloud storage for direct file access
|
||||
|
||||
## How It Works in Practice
|
||||
|
||||
@@ -346,14 +384,125 @@ basic-memory sync
|
||||
basic-memory sync --watch
|
||||
```
|
||||
|
||||
3. In Claude Desktop, the LLM can now use these tools:
|
||||
3. Cloud features (optional, requires subscription):
|
||||
|
||||
```bash
|
||||
# Authenticate with cloud (stores OAuth token locally)
|
||||
basic-memory cloud login
|
||||
|
||||
# (Optional) install/configure rclone for file sync commands
|
||||
basic-memory cloud setup
|
||||
|
||||
# Check cloud auth + health
|
||||
basic-memory cloud status
|
||||
```
|
||||
|
||||
**Per-Project Cloud Routing** (API key based):
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local. This uses an API key for routed
|
||||
project calls:
|
||||
|
||||
```bash
|
||||
# Save an API key (create one in the web app or via CLI)
|
||||
basic-memory cloud set-key bmc_abc123...
|
||||
# Or create one via CLI (requires OAuth login first)
|
||||
basic-memory cloud create-key "my-laptop"
|
||||
|
||||
# Set a project to route through cloud
|
||||
basic-memory project set-cloud research
|
||||
|
||||
# Revert a project to local mode
|
||||
basic-memory project set-local research
|
||||
|
||||
# List projects and route metadata
|
||||
basic-memory project list
|
||||
```
|
||||
|
||||
`basic-memory cloud login` / `basic-memory cloud logout` are authentication commands. They do not change default CLI
|
||||
routing behavior.
|
||||
|
||||
**Routing Flags**:
|
||||
|
||||
Use routing flags to disambiguate command targets:
|
||||
|
||||
```bash
|
||||
# Force local routing for this command
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
basic-memory project ls --name main --local
|
||||
|
||||
# Force cloud routing for this command
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
basic-memory project ls --name main --cloud
|
||||
```
|
||||
|
||||
No-flag behavior defaults to local when no project context is present.
|
||||
|
||||
The local MCP server routes per transport: `--transport stdio` honors per-project routing
|
||||
(local or cloud), while `--transport streamable-http` and `--transport sse` always route locally.
|
||||
|
||||
**CLI Note Editing (`tool edit-note`):**
|
||||
|
||||
```bash
|
||||
# Append content
|
||||
basic-memory tool edit-note project-plan --operation append --content $'\n## Next Steps\n- Finalize rollout'
|
||||
|
||||
# Find/replace with replacement count validation
|
||||
basic-memory tool edit-note docs/api --operation find_replace --find-text "v0.14.0" --content "v0.15.0" --expected-replacements 2
|
||||
|
||||
# Replace a section body
|
||||
basic-memory tool edit-note docs/setup --operation replace_section --section "## Installation" --content $'Updated install steps\n- Run just install'
|
||||
|
||||
# JSON metadata output for integrations
|
||||
basic-memory tool edit-note docs/setup --operation append --content $'\n- Added note' --format json
|
||||
```
|
||||
|
||||
4. In Claude Desktop, the LLM can now use these tools:
|
||||
|
||||
**Content Management:**
|
||||
```
|
||||
write_note(title, content, folder, tags, output_format="text"|"json") - Create or update notes
|
||||
read_note(identifier, page, page_size, output_format="text"|"json") - Read notes by title or permalink
|
||||
read_content(path) - Read raw file content (text, images, binaries)
|
||||
view_note(identifier) - View notes as formatted artifacts
|
||||
edit_note(identifier, operation, content, output_format="text"|"json") - Edit notes incrementally
|
||||
move_note(identifier, destination_path, output_format="text"|"json") - Move notes with database consistency
|
||||
delete_note(identifier, output_format="text"|"json") - Delete notes from knowledge base
|
||||
```
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
```
|
||||
build_context(url, depth, timeframe, output_format="json"|"text") - Navigate knowledge graph via memory:// URLs
|
||||
recent_activity(type, depth, timeframe, output_format="text"|"json") - Find recently updated information
|
||||
list_directory(dir_name, depth) - Browse directory contents with filtering
|
||||
```
|
||||
|
||||
**Search & Discovery:**
|
||||
```
|
||||
write_note(title, content, folder, tags) - Create or update notes
|
||||
read_note(identifier, page, page_size) - Read notes by title or permalink
|
||||
build_context(url, depth, timeframe) - Navigate knowledge graph via memory:// URLs
|
||||
search(query, page, page_size) - Search across your knowledge base
|
||||
recent_activity(type, depth, timeframe) - Find recently updated information
|
||||
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters (query is optional for filter-only searches)
|
||||
```
|
||||
|
||||
**Project Management:**
|
||||
```
|
||||
list_memory_projects(output_format="text"|"json") - List all available projects
|
||||
create_memory_project(project_name, project_path, output_format="text"|"json") - Create new projects
|
||||
get_current_project() - Show current project stats
|
||||
sync_status() - Check synchronization status
|
||||
```
|
||||
|
||||
`output_format` defaults to `"text"` for these tools, preserving current human-readable responses.
|
||||
`build_context` defaults to `"json"` and can be switched to `"text"` when compact markdown output is preferred.
|
||||
|
||||
**Cloud Discovery (opt-in):**
|
||||
```
|
||||
cloud_info() - Show optional Cloud overview and setup guidance
|
||||
release_notes() - Show latest release notes
|
||||
```
|
||||
|
||||
**Visualization:**
|
||||
```
|
||||
canvas(nodes, edges, title, folder) - Generate knowledge visualizations
|
||||
```
|
||||
|
||||
@@ -369,12 +518,147 @@ canvas(nodes, edges, title, folder) - Generate knowledge visualizations
|
||||
|
||||
## Futher info
|
||||
|
||||
See the [Documentation](https://memory.basicmachines.co/) for more info, including:
|
||||
See the [Documentation](https://docs.basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme) for more info, including:
|
||||
|
||||
- [Complete User Guide](https://memory.basicmachines.co/docs/user-guide)
|
||||
- [CLI tools](https://memory.basicmachines.co/docs/cli-reference)
|
||||
- [Managing multiple Projects](https://memory.basicmachines.co/docs/cli-reference#project)
|
||||
- [Importing data from OpenAI/Claude Projects](https://memory.basicmachines.co/docs/cli-reference#import)
|
||||
- [Complete User Guide](https://docs.basicmemory.com/user-guide/?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
- [CLI tools](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
- [Cloud CLI and Sync](https://docs.basicmemory.com/guides/cloud-cli/?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme#project)
|
||||
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/?utm_source=github&utm_medium=referral&utm_campaign=readme#import)
|
||||
|
||||
## Telemetry
|
||||
|
||||
Basic Memory collects anonymous, minimal usage events to understand how the CLI-to-cloud conversion funnel performs. This helps us prioritize features and improve the product.
|
||||
|
||||
**What we collect:**
|
||||
- Cloud promo impressions (when the promo banner is shown)
|
||||
- Cloud login attempts and outcomes
|
||||
- Promo opt-out events
|
||||
|
||||
**What we do NOT collect:**
|
||||
- No file contents, note titles, or knowledge base data
|
||||
- No personally identifiable information (PII)
|
||||
- No IP address tracking or fingerprinting
|
||||
- No per-command or per-tool-call tracking
|
||||
|
||||
Events are sent to our [Umami Cloud](https://umami.is) instance, an open-source, privacy-focused analytics platform. Events are fire-and-forget on a background thread — analytics never blocks or slows the CLI.
|
||||
|
||||
**Opt out** by setting the environment variable:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_NO_PROMOS=1
|
||||
```
|
||||
|
||||
This disables both promo messages and all telemetry events.
|
||||
|
||||
## Logging
|
||||
|
||||
Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The logging behavior varies by entry point:
|
||||
|
||||
| Entry Point | Default Behavior | Use Case |
|
||||
|-------------|------------------|----------|
|
||||
| CLI commands | File only | Prevents log output from interfering with command output |
|
||||
| MCP server | File only | Stdout would corrupt the JSON-RPC protocol |
|
||||
| API server | File (local) or stdout (cloud) | Docker/cloud deployments use stdout |
|
||||
|
||||
**Log file location:** `~/.basic-memory/basic-memory.log` (10MB rotation, 10 days retention)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
|
||||
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing |
|
||||
| `BASIC_MEMORY_FORCE_CLOUD` | `false` | When `true`, forces cloud API routing |
|
||||
| `BASIC_MEMORY_EXPLICIT_ROUTING` | `false` | When `true`, marks route selection as explicit (`--local`/`--cloud`) |
|
||||
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
|
||||
| `BASIC_MEMORY_NO_PROMOS` | `false` | When `true`, disables cloud promo messages and telemetry |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
|
||||
|
||||
# View logs
|
||||
tail -f ~/.basic-memory/basic-memory.log
|
||||
|
||||
# Cloud/Docker mode (stdout logging with structured context)
|
||||
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
Basic Memory supports dual database backends (SQLite and Postgres). By default, tests run against SQLite. Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required).
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
# Run all tests against SQLite (default, fast)
|
||||
just test-sqlite
|
||||
|
||||
# Run all tests against Postgres (uses testcontainers)
|
||||
just test-postgres
|
||||
|
||||
# Run both SQLite and Postgres tests
|
||||
just test
|
||||
```
|
||||
|
||||
**Available Test Commands:**
|
||||
|
||||
- `just test` - Run all tests against both SQLite and Postgres
|
||||
- `just test-sqlite` - Run all tests against SQLite (fast, no Docker needed)
|
||||
- `just test-postgres` - Run all tests against Postgres (uses testcontainers)
|
||||
- `just test-unit-sqlite` - Run unit tests against SQLite
|
||||
- `just test-unit-postgres` - Run unit tests against Postgres
|
||||
- `just test-int-sqlite` - Run integration tests against SQLite
|
||||
- `just test-int-postgres` - Run integration tests against Postgres
|
||||
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
|
||||
- `just test-benchmark` - Run performance benchmark tests
|
||||
- `just testmon` - Run tests impacted by recent changes (pytest-testmon)
|
||||
- `just test-smoke` - Run fast MCP end-to-end smoke test
|
||||
- `just fast-check` - Run fix/format/typecheck + impacted tests + smoke test
|
||||
- `just doctor` - Run local file <-> DB consistency checks with temp config
|
||||
|
||||
**Postgres Testing:**
|
||||
|
||||
Postgres tests use [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
|
||||
|
||||
**Testmon Note:** When no files have changed, `just testmon` may collect 0 tests. That's expected and means no impacted tests were detected.
|
||||
|
||||
**Test Markers:**
|
||||
|
||||
Tests use pytest markers for selective execution:
|
||||
- `windows` - Windows-specific database optimizations
|
||||
- `benchmark` - Performance tests (excluded from default runs)
|
||||
- `smoke` - Fast MCP end-to-end smoke tests
|
||||
|
||||
**Other Development Commands:**
|
||||
```bash
|
||||
just install # Install with dev dependencies
|
||||
just lint # Run linting checks
|
||||
just typecheck # Run type checking
|
||||
just typecheck-ty # Run ty type checking (incremental supplement to pyright)
|
||||
just format # Format code with ruff
|
||||
just fast-check # Fast local loop (fix/format/typecheck + testmon + smoke)
|
||||
just doctor # Local consistency check (temp config)
|
||||
just check # Run all quality checks
|
||||
just migration "msg" # Create database migration
|
||||
```
|
||||
|
||||
**Type Checking Strategy:**
|
||||
- `just typecheck` (Pyright) remains the primary, blocking type checker.
|
||||
- `just typecheck-ty` (Astral `ty`) is available as a supplemental checker while rules are adopted incrementally.
|
||||
- We recommend running both locally while reducing `ty` diagnostics over time.
|
||||
|
||||
**Local Consistency Check:**
|
||||
```bash
|
||||
basic-memory doctor # Verifies file <-> database sync in a temp project
|
||||
```
|
||||
|
||||
See the [justfile](justfile) for the complete list of development commands.
|
||||
|
||||
## License
|
||||
|
||||
@@ -393,4 +677,4 @@ and submitting PRs.
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
Built with ♥️ by Basic Machines
|
||||
Built with ♥️ by [Basic Machines](https://basicmachines.co?utm_source=github&utm_medium=referral&utm_campaign=readme)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Docker Compose configuration for Basic Memory with PostgreSQL
|
||||
# Use this for local development and testing with Postgres backend
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
# docker-compose -f docker-compose-postgres.yml down
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: basic-memory-postgres
|
||||
environment:
|
||||
# Local development/test credentials - NOT for production
|
||||
# These values are referenced by tests and justfile commands
|
||||
POSTGRES_DB: basic_memory
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password # Simple password for local testing only
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Named volume for Postgres data
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
# Named volume for persistent configuration
|
||||
# Database will be stored in Postgres, not in this volume
|
||||
basic-memory-config:
|
||||
driver: local
|
||||
|
||||
# Network configuration (optional)
|
||||
# networks:
|
||||
# basic-memory-net:
|
||||
# driver: bridge
|
||||
@@ -0,0 +1,442 @@
|
||||
# Basic Memory Architecture
|
||||
|
||||
This document describes the architectural patterns and composition structure of Basic Memory.
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory is a local-first knowledge management system with three entrypoints:
|
||||
- **API** - FastAPI REST server for HTTP access
|
||||
- **MCP** - Model Context Protocol server for LLM integration
|
||||
- **CLI** - Typer command-line interface
|
||||
|
||||
Each entrypoint uses a **composition root** pattern to manage configuration and dependencies.
|
||||
|
||||
## Composition Roots
|
||||
|
||||
### What is a Composition Root?
|
||||
|
||||
A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that:
|
||||
|
||||
1. Reads configuration from `ConfigManager`
|
||||
2. Resolves runtime mode (local/test)
|
||||
3. Creates and provides dependencies to downstream code
|
||||
|
||||
**Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly.
|
||||
|
||||
### Container Structure
|
||||
|
||||
Each entrypoint has a container dataclass in its package:
|
||||
|
||||
```
|
||||
src/basic_memory/
|
||||
├── api/
|
||||
│ └── container.py # ApiContainer
|
||||
├── mcp/
|
||||
│ └── container.py # McpContainer
|
||||
├── cli/
|
||||
│ └── container.py # CliContainer
|
||||
└── runtime.py # RuntimeMode enum and resolver
|
||||
```
|
||||
|
||||
### Container Pattern
|
||||
|
||||
All containers follow the same structure:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Container:
|
||||
config: BasicMemoryConfig
|
||||
mode: RuntimeMode
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> "Container":
|
||||
"""Create container by reading ConfigManager."""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@property
|
||||
def some_computed_property(self) -> bool:
|
||||
"""Derived values based on config and mode."""
|
||||
return self.mode.is_local and self.config.some_setting
|
||||
|
||||
# Module-level singleton
|
||||
_container: Container | None = None
|
||||
|
||||
def get_container() -> Container:
|
||||
if _container is None:
|
||||
raise RuntimeError("Container not initialized")
|
||||
return _container
|
||||
|
||||
def set_container(container: Container) -> None:
|
||||
global _container
|
||||
_container = container
|
||||
```
|
||||
|
||||
### Runtime Mode Resolution
|
||||
|
||||
The `RuntimeMode` enum centralizes mode detection:
|
||||
|
||||
```python
|
||||
class RuntimeMode(Enum):
|
||||
LOCAL = "local"
|
||||
CLOUD = "cloud"
|
||||
TEST = "test"
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
return self == RuntimeMode.CLOUD
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
return self == RuntimeMode.LOCAL
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
return self == RuntimeMode.TEST
|
||||
```
|
||||
|
||||
Resolution follows this precedence in local app flows: **TEST > LOCAL**
|
||||
|
||||
```python
|
||||
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
return RuntimeMode.LOCAL
|
||||
```
|
||||
|
||||
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync).
|
||||
Per-project routing is orthogonal: individual projects can be set to `cloud` mode via `ProjectMode`,
|
||||
which affects client routing in `get_client(project_name=...)` without changing global runtime mode.
|
||||
`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it.
|
||||
|
||||
## Dependencies Package
|
||||
|
||||
### Structure
|
||||
|
||||
The `deps/` package provides FastAPI dependencies organized by feature:
|
||||
|
||||
```
|
||||
src/basic_memory/deps/
|
||||
├── __init__.py # Re-exports for backwards compatibility
|
||||
├── config.py # Configuration access
|
||||
├── db.py # Database/session management
|
||||
├── projects.py # Project resolution
|
||||
├── repositories.py # Data access layer
|
||||
├── services.py # Business logic layer
|
||||
└── importers.py # Import functionality
|
||||
```
|
||||
|
||||
### Usage in Routers
|
||||
|
||||
```python
|
||||
from basic_memory.deps.services import get_entity_service
|
||||
from basic_memory.deps.projects import get_project_config
|
||||
|
||||
@router.get("/entities/{id}")
|
||||
async def get_entity(
|
||||
id: int,
|
||||
entity_service: EntityService = Depends(get_entity_service),
|
||||
project: ProjectConfig = Depends(get_project_config),
|
||||
):
|
||||
return await entity_service.get(id)
|
||||
```
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
The old `deps.py` file still exists as a thin re-export shim:
|
||||
|
||||
```python
|
||||
# deps.py - backwards compatibility shim
|
||||
from basic_memory.deps import *
|
||||
```
|
||||
|
||||
New code should import from specific submodules (`basic_memory.deps.services`) for clarity.
|
||||
|
||||
## MCP Tools Architecture
|
||||
|
||||
### Typed API Clients
|
||||
|
||||
MCP tools communicate with the API through typed clients that encapsulate HTTP paths and response validation:
|
||||
|
||||
```
|
||||
src/basic_memory/mcp/clients/
|
||||
├── __init__.py # Re-exports all clients
|
||||
├── base.py # BaseClient with common logic
|
||||
├── knowledge.py # KnowledgeClient - entity CRUD
|
||||
├── search.py # SearchClient - search operations
|
||||
├── memory.py # MemoryClient - context building
|
||||
├── directory.py # DirectoryClient - directory listing
|
||||
├── resource.py # ResourceClient - resource reading
|
||||
└── project.py # ProjectClient - project management
|
||||
```
|
||||
|
||||
### Client Pattern
|
||||
|
||||
Each client encapsulates API paths and validates responses:
|
||||
|
||||
```python
|
||||
class KnowledgeClient(BaseClient):
|
||||
"""Client for knowledge/entity operations."""
|
||||
|
||||
async def resolve_entity(self, identifier: str) -> int:
|
||||
"""Resolve identifier to entity ID."""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/resolve/{identifier}",
|
||||
)
|
||||
return int(response.text)
|
||||
|
||||
async def get_entity(self, entity_id: int) -> EntityResponse:
|
||||
"""Get entity by ID."""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
```
|
||||
|
||||
### Tool → Client → API Flow
|
||||
|
||||
```
|
||||
MCP Tool (thin adapter)
|
||||
↓
|
||||
Typed Client (encapsulates paths, validates responses)
|
||||
↓
|
||||
HTTP API (FastAPI router)
|
||||
↓
|
||||
Service Layer (business logic)
|
||||
↓
|
||||
Repository Layer (data access)
|
||||
```
|
||||
|
||||
Example tool using typed client:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def search_notes(
|
||||
query: str,
|
||||
project: str | None = None,
|
||||
metadata_filters: dict | None = None,
|
||||
tags: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
) -> SearchResponse:
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# Import client inside function to avoid circular imports
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
|
||||
search_query = SearchQuery(
|
||||
text=query,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
)
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
return await search_client.search(search_query.model_dump())
|
||||
```
|
||||
|
||||
### Per-Project Client Routing
|
||||
|
||||
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
|
||||
1. Resolves the project name from config (no network call)
|
||||
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
|
||||
3. Validates the project via the API
|
||||
4. Yields `(client, active_project)` tuple
|
||||
|
||||
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local or cloud)
|
||||
# active_project is validated via the API
|
||||
...
|
||||
```
|
||||
|
||||
## Sync Coordination
|
||||
|
||||
### SyncCoordinator
|
||||
|
||||
The `SyncCoordinator` centralizes sync/watch lifecycle management:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SyncCoordinator:
|
||||
"""Coordinates file sync and watch operations."""
|
||||
|
||||
status: SyncStatus = SyncStatus.NOT_STARTED
|
||||
sync_task: asyncio.Task | None = None
|
||||
watch_service: WatchService | None = None
|
||||
|
||||
async def start(self, ...):
|
||||
"""Start sync and watch operations."""
|
||||
|
||||
async def stop(self):
|
||||
"""Stop all sync operations gracefully."""
|
||||
|
||||
def get_status_info(self) -> dict:
|
||||
"""Get current sync status for observability."""
|
||||
```
|
||||
|
||||
### Status Enum
|
||||
|
||||
```python
|
||||
class SyncStatus(Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
ERROR = "error"
|
||||
```
|
||||
|
||||
## Project Resolution
|
||||
|
||||
### ProjectResolver
|
||||
|
||||
Unified project selection across all entrypoints:
|
||||
|
||||
```python
|
||||
class ProjectResolver:
|
||||
"""Resolves which project to use based on context."""
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
explicit_project: str | None = None,
|
||||
) -> ResolvedProject:
|
||||
"""Resolve project using three-tier hierarchy:
|
||||
1. Explicit project parameter
|
||||
2. Default project from config
|
||||
3. Single available project
|
||||
"""
|
||||
```
|
||||
|
||||
### Resolution Modes
|
||||
|
||||
```python
|
||||
class ResolutionMode(Enum):
|
||||
EXPLICIT = "explicit" # User specified project
|
||||
DEFAULT = "default" # Using configured default
|
||||
SINGLE_PROJECT = "single" # Only one project exists
|
||||
FALLBACK = "fallback" # Using first available
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Container Testing
|
||||
|
||||
Each container has corresponding tests:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── api/test_api_container.py
|
||||
├── mcp/test_mcp_container.py
|
||||
└── cli/test_cli_container.py
|
||||
```
|
||||
|
||||
Tests verify:
|
||||
- Container creation from config
|
||||
- Runtime mode properties
|
||||
- Container accessor functions (get/set)
|
||||
|
||||
### Mocking Typed Clients
|
||||
|
||||
When testing MCP tools, mock at the client level:
|
||||
|
||||
```python
|
||||
def test_search_notes(monkeypatch):
|
||||
import basic_memory.mcp.clients as clients_mod
|
||||
|
||||
class MockSearchClient:
|
||||
async def search(self, query):
|
||||
return SearchResponse(results=[...])
|
||||
|
||||
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Explicit Dependencies
|
||||
|
||||
Modules receive configuration explicitly rather than reading globals:
|
||||
|
||||
```python
|
||||
# Good - explicit injection
|
||||
async def sync_files(config: BasicMemoryConfig):
|
||||
...
|
||||
|
||||
# Avoid - hidden global access
|
||||
async def sync_files():
|
||||
config = ConfigManager().config # Hidden coupling
|
||||
```
|
||||
|
||||
### 2. Single Responsibility
|
||||
|
||||
Each layer has a clear responsibility:
|
||||
- **Containers**: Wire dependencies
|
||||
- **Clients**: Encapsulate HTTP communication
|
||||
- **Services**: Business logic
|
||||
- **Repositories**: Data access
|
||||
- **Tools/Routers**: Thin adapters
|
||||
|
||||
### 3. Deferred Imports
|
||||
|
||||
To avoid circular imports, typed clients are imported inside functions:
|
||||
|
||||
```python
|
||||
async def my_tool():
|
||||
async with get_client() as client:
|
||||
# Import here to avoid circular dependency
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
knowledge_client = KnowledgeClient(client, project_id)
|
||||
```
|
||||
|
||||
### 4. Backwards Compatibility
|
||||
|
||||
When refactoring, maintain backwards compatibility via shims:
|
||||
|
||||
```python
|
||||
# Old module becomes a shim
|
||||
from basic_memory.new_location import *
|
||||
|
||||
# Docstring explains migration path
|
||||
"""
|
||||
DEPRECATED: Import from basic_memory.new_location instead.
|
||||
This shim will be removed in a future version.
|
||||
"""
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/basic_memory/
|
||||
├── api/
|
||||
│ ├── container.py # API composition root
|
||||
│ ├── routers/ # FastAPI routers
|
||||
│ └── ...
|
||||
├── mcp/
|
||||
│ ├── container.py # MCP composition root
|
||||
│ ├── clients/ # Typed API clients
|
||||
│ ├── tools/ # MCP tool definitions
|
||||
│ └── server.py # MCP server setup
|
||||
├── cli/
|
||||
│ ├── container.py # CLI composition root
|
||||
│ ├── app.py # Typer app
|
||||
│ └── commands/ # CLI command groups
|
||||
├── deps/
|
||||
│ ├── config.py # Config dependencies
|
||||
│ ├── db.py # Database dependencies
|
||||
│ ├── projects.py # Project dependencies
|
||||
│ ├── repositories.py # Repository dependencies
|
||||
│ ├── services.py # Service dependencies
|
||||
│ └── importers.py # Importer dependencies
|
||||
├── sync/
|
||||
│ ├── coordinator.py # SyncCoordinator
|
||||
│ └── ...
|
||||
├── runtime.py # RuntimeMode resolution
|
||||
├── project_resolver.py # Unified project selection
|
||||
└── config.py # Configuration management
|
||||
```
|
||||
@@ -0,0 +1,494 @@
|
||||
# Note Format Reference
|
||||
|
||||
Every document in Basic Memory is a plain Markdown file. Files are the source of truth — changes to files automatically update the knowledge graph in the database. You maintain complete ownership, files work with git, and knowledge persists independently of any AI conversation.
|
||||
|
||||
## Document Structure
|
||||
|
||||
A note has three parts: YAML frontmatter, content (observations), and relations.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
type: note
|
||||
tags: [coffee, brewing]
|
||||
permalink: coffee-brewing-methods
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
|
||||
## Observations
|
||||
- [method] Pour over provides more flavor clarity than French press
|
||||
- [technique] Water temperature at 205°F extracts optimal compounds #brewing
|
||||
- [preference] Ethiopian beans work well with lighter roasts (personal experience)
|
||||
|
||||
## Relations
|
||||
- relates_to [[Coffee Bean Origins]]
|
||||
- requires [[Proper Grinding Technique]]
|
||||
- contrasts_with [[Tea Brewing Methods]]
|
||||
```
|
||||
|
||||
The `## Observations` and `## Relations` headings are conventional but not required — the parser detects observations and relations by their syntax patterns anywhere in the document.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML metadata between `---` fences at the top of the file.
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `title` | No | filename stem | Used for linking and references. Auto-set from filename if missing. |
|
||||
| `type` | No | `note` | Entity type. Used for schema resolution and filtering. |
|
||||
| `tags` | No | `[]` | List or comma-separated string. Used for organization and search. |
|
||||
| `permalink` | No | generated from title | Stable identifier. Persists even if the file moves. |
|
||||
| `schema` | No | none | Schema attachment — dict (inline), string (reference), or omitted (implicit). |
|
||||
|
||||
Custom fields are allowed. Any key not in the standard set is stored as `entity_metadata` and indexed for search and filtering.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
permalink: paul-graham
|
||||
status: active
|
||||
source: wikipedia
|
||||
---
|
||||
```
|
||||
|
||||
Here `status` and `source` are custom fields stored in `entity_metadata`.
|
||||
|
||||
### Frontmatter Value Handling
|
||||
|
||||
YAML automatically converts some values to native types. Basic Memory normalizes them:
|
||||
|
||||
- Date strings (`2025-10-24`) → kept as ISO format strings
|
||||
- Numbers (`1.0`) → converted to strings
|
||||
- Booleans (`true`) → converted to strings (`"True"`)
|
||||
- Lists and dicts → preserved, items normalized recursively
|
||||
|
||||
This prevents errors when downstream code expects string values.
|
||||
|
||||
## Observations
|
||||
|
||||
An observation is a categorized fact about the entity. Written as a Markdown list item.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- [category] content text #tag1 #tag2 (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `[category]` | Yes | Classification in square brackets. Any text except `[]()` chars. |
|
||||
| content | Yes | The fact or statement. |
|
||||
| `#tags` | No | Inline tags. Space-separated, each starting with `#`. |
|
||||
| `(context)` | No | Parenthesized text at end of line. Supporting details or source. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- [tech] Uses SQLite for storage #database
|
||||
- [design] Follows local-first architecture #architecture
|
||||
- [decision] Selected bcrypt for passwords #security (based on OWASP audit)
|
||||
- [name] Paul Graham
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
```
|
||||
|
||||
Array-like fields use repeated categories — multiple `[expertise]` observations above.
|
||||
|
||||
### What Is Not an Observation
|
||||
|
||||
The parser excludes these list item patterns:
|
||||
|
||||
| Pattern | Example | Reason |
|
||||
|---------|---------|--------|
|
||||
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
|
||||
| Markdown links | `- [text](url)` | URL link syntax |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
## Relations
|
||||
|
||||
Relations connect documents to form the knowledge graph. There are two kinds.
|
||||
|
||||
### Explicit Relations
|
||||
|
||||
Written as list items with a relation type and a `[[wiki link]]` target.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- relation_type [[Target Entity]] (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
|
||||
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- implements [[Search Design]]
|
||||
- depends_on [[Database Schema]]
|
||||
- works_at [[Y Combinator]] (co-founder)
|
||||
- [[Some Entity]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
Relations can link to entities that don't exist yet. Basic Memory resolves them when the target is created.
|
||||
|
||||
## Permalinks and memory:// URLs
|
||||
|
||||
Every document has a unique **permalink** — a stable identifier derived from its title. You can set one explicitly in frontmatter, or let the system generate it.
|
||||
|
||||
```yaml
|
||||
permalink: auth-approaches-2024
|
||||
```
|
||||
|
||||
Permalinks form the basis of `memory://` URLs:
|
||||
|
||||
```
|
||||
memory://auth-approaches-2024 # By permalink
|
||||
memory://Authentication Approaches # By title (auto-resolves)
|
||||
memory://project/auth-approaches # By path
|
||||
```
|
||||
|
||||
Pattern matching is supported:
|
||||
|
||||
```
|
||||
memory://auth* # Starts with "auth"
|
||||
memory://*/approaches # Ends with "approaches"
|
||||
memory://project/*/requirements # Nested wildcard
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
Schemas declare the expected structure of a note — which observation categories and relation types a well-formed note should have. They use Picoschema, a compact notation from Google's Dotprompt that fits naturally in YAML frontmatter.
|
||||
|
||||
### Picoschema Syntax
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
| Notation | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `field: type` | Required field | `name: string` |
|
||||
| `field?: type` | Optional field | `role?: string` |
|
||||
| `field(array): type` | Array of values | `expertise(array): string` |
|
||||
| `field?(enum): [vals]` | Enum with allowed values | `status?(enum): [active, inactive]` |
|
||||
| `field?(object):` | Nested object with sub-fields | `metadata?(object):` |
|
||||
| `, description` | Description after comma | `name: string, full name` |
|
||||
| `EntityName` | Capitalized type = entity reference | `works_at?: Organization` |
|
||||
|
||||
**Scalar types:** `string`, `integer`, `number`, `boolean`, `any`
|
||||
|
||||
Any type not in that set whose first letter is uppercase is treated as an entity reference (a relation target).
|
||||
|
||||
### Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against existing observation/relation syntax. Note authors don't learn new syntax.
|
||||
|
||||
| Schema Declaration | Maps To | Example in Note |
|
||||
|--------------------|---------|-----------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (repeated) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (repeated) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [vals]` | Observation `[field] value` where value is in the set | `- [status] active` |
|
||||
|
||||
Observations and relations not covered by the schema are valid — schemas describe a subset, not a straitjacket.
|
||||
|
||||
### Schema Attachment
|
||||
|
||||
Three ways to attach a schema to a note, resolved in priority order:
|
||||
|
||||
**1. Inline schema** — `schema` is a dict in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
**2. Explicit reference** — `schema` is a string naming a schema note:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject
|
||||
---
|
||||
```
|
||||
|
||||
or by permalink:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project
|
||||
---
|
||||
```
|
||||
|
||||
Use when the note's `type` differs from the schema it should validate against, or when multiple schema variants exist.
|
||||
|
||||
**3. Implicit by type** — no `schema` field, resolved by matching `type`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
---
|
||||
```
|
||||
|
||||
The system looks up a schema note where `entity: Person`. If found, it applies. If not, no validation occurs.
|
||||
|
||||
**4. No schema** — perfectly fine. Most notes don't need one.
|
||||
|
||||
### Schema Notes
|
||||
|
||||
A schema is itself a Basic Memory note with `type: schema`. It lives anywhere (though `schema/` is the conventional directory).
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | Yes | Must be `schema` |
|
||||
| `entity` | Yes | The entity type this schema describes (e.g., `Person`) |
|
||||
| `version` | No | Schema version number (default: `1`) |
|
||||
| `schema` | Yes | Picoschema dict defining the fields |
|
||||
| `settings.validation` | No | Validation mode (default: `warn`) |
|
||||
|
||||
Schema notes are regular notes — they show up in search, can have observations and relations, and participate in the knowledge graph.
|
||||
|
||||
### Validation Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
| `off` | No validation |
|
||||
|
||||
### Validation Output
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- **100% present** → required field
|
||||
- **25%+ present** → optional field
|
||||
- **Below 25%** → excluded from suggestion
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Simple Note (No Schema)
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Project Ideas
|
||||
type: note
|
||||
tags: [ideas, brainstorm]
|
||||
---
|
||||
|
||||
# Project Ideas
|
||||
|
||||
## Observations
|
||||
- [idea] Build a CLI tool for markdown linting #tooling
|
||||
- [idea] Create a recipe knowledge base #cooking
|
||||
- [priority] Focus on developer tools first (Q1 goal)
|
||||
|
||||
## Relations
|
||||
- inspired_by [[Developer Workflow Research]]
|
||||
- part_of [[Q1 Planning]]
|
||||
```
|
||||
|
||||
### Schema-Validated Note
|
||||
|
||||
Schema at `schema/Person.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
Note at `people/paul-graham.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
The `[fact]` observation and `authored` relation are not in the schema — they're valid, just unmatched. The schema only checks that `[name]` exists (required) and looks for optional fields like `[role]`, `[expertise]`, and `works_at`.
|
||||
|
||||
### Inline Schema Note
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Simplified Local/Cloud Routing
|
||||
|
||||
## Context
|
||||
|
||||
Basic Memory now uses explicit, project-aware routing without a global cloud-mode toggle.
|
||||
Routing is determined by command-level flags and project mode, not by a global `cloud_mode` state.
|
||||
|
||||
This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Remove global `cloud_mode` from runtime/routing semantics.
|
||||
2. Keep MCP HTTP/SSE local-only; let stdio honor per-project routing.
|
||||
3. Make CLI routing explicit and easy to reason about.
|
||||
4. Support projects that exist in both local and cloud without ambiguity.
|
||||
|
||||
## Routing Contract
|
||||
|
||||
Routing is resolved in this order:
|
||||
|
||||
1. Injected client factory (for composition/integration contexts)
|
||||
2. Explicit routing override (`--local` / `--cloud` or env vars below)
|
||||
3. Project-scoped routing (`project.mode`) when a project is known
|
||||
4. Default local routing
|
||||
|
||||
### Routing Environment Variables
|
||||
|
||||
- `BASIC_MEMORY_FORCE_LOCAL=true`: force local transport
|
||||
- `BASIC_MEMORY_FORCE_CLOUD=true`: force cloud proxy transport
|
||||
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`: marks routing as explicitly chosen for this command
|
||||
|
||||
When explicit routing is active, project mode does not override the selected route.
|
||||
|
||||
## Config Semantics
|
||||
|
||||
- `project.mode` is the only config-based routing signal for project-scoped operations.
|
||||
- Legacy `cloud_mode` values may be encountered during migration/loading but are not used for routing behavior.
|
||||
- Normalization saves remove stale `cloud_mode` from `~/.basic-memory/config.json`.
|
||||
|
||||
### Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"main": {
|
||||
"path": "/Users/me/basic-memory",
|
||||
"mode": "local",
|
||||
"local_sync_path": null,
|
||||
"bisync_initialized": false,
|
||||
"last_sync": null
|
||||
},
|
||||
"specs": {
|
||||
"path": "specs",
|
||||
"mode": "cloud",
|
||||
"local_sync_path": "/Users/me/dev/specs",
|
||||
"bisync_initialized": true,
|
||||
"last_sync": "2026-02-06T17:36:38.544153"
|
||||
}
|
||||
},
|
||||
"default_project": "main",
|
||||
"cloud_api_key": "bmc_abc123...",
|
||||
"cloud_host": "https://cloud.basicmemory.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Cloud Commands Are Auth-Only
|
||||
|
||||
`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state.
|
||||
|
||||
- `bm cloud login`
|
||||
- performs OAuth device flow
|
||||
- stores/refreshes token material
|
||||
- may verify cloud health/subscription
|
||||
- does not change routing defaults
|
||||
- `bm cloud logout`
|
||||
- removes stored OAuth session tokens
|
||||
- does not change routing defaults
|
||||
- `bm cloud status`
|
||||
- reports auth state (API key, OAuth token validity)
|
||||
- runs health checks only when credentials are available
|
||||
|
||||
## MCP Transport Routing
|
||||
|
||||
### Stdio (default)
|
||||
|
||||
`bm mcp --transport stdio` uses natural per-project routing.
|
||||
|
||||
- Local-mode projects route through the in-process ASGI transport.
|
||||
- Cloud-mode projects route to the cloud proxy with Bearer auth (API key).
|
||||
- No explicit routing env vars are injected by the CLI command.
|
||||
- Externally-set env vars are honored (e.g. `BASIC_MEMORY_FORCE_CLOUD=true` for cloud deployments).
|
||||
- Users who need all projects forced local can set `BASIC_MEMORY_FORCE_LOCAL=true` externally.
|
||||
|
||||
### HTTP and SSE Transports
|
||||
|
||||
`bm mcp --transport streamable-http` and `bm mcp --transport sse` always route locally.
|
||||
|
||||
These transports set explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
|
||||
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud
|
||||
routing regardless of project mode, since HTTP/SSE serve as local API endpoints.
|
||||
|
||||
## Project List UX for Dual Presence
|
||||
|
||||
Projects may exist in both local and cloud. `bm project list` should display that clearly in one row per logical
|
||||
project identity, with explicit source/target signals.
|
||||
|
||||
Recommended display contract:
|
||||
|
||||
1. Keep one row per normalized project name/permalink.
|
||||
2. Show both local and cloud presence as separate columns/indicators.
|
||||
3. Show an explicit `MCP (stdio)` target column that always resolves to `local`.
|
||||
4. Keep CLI route semantics explicit:
|
||||
- no flags: default local for non-project commands
|
||||
- `--cloud`: force cloud
|
||||
- `--local`: force local
|
||||
|
||||
## Project LS Targeting
|
||||
|
||||
`bm project ls` should clearly identify which project instance is being listed.
|
||||
|
||||
Targeting rules:
|
||||
|
||||
1. No routing flags: list local project files.
|
||||
2. `--cloud`: list cloud project files.
|
||||
3. `--local`: list local project files (explicit override).
|
||||
4. Output should label the active target (`LOCAL` or `CLOUD`) in heading or status line.
|
||||
|
||||
## Runtime Mode
|
||||
|
||||
Runtime mode is no longer a cloud/local routing switch for local app flows.
|
||||
|
||||
- `resolve_runtime_mode(is_test_env)` resolves to:
|
||||
- `TEST` when running in test environment
|
||||
- `LOCAL` otherwise
|
||||
- `RuntimeMode.CLOUD` may remain for compatibility with existing tests/call sites but is not selected by normal local
|
||||
runtime resolution.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
1. Loading config with legacy `cloud_mode` succeeds.
|
||||
2. Saving config strips legacy `cloud_mode`.
|
||||
3. `--local/--cloud` always override per-project mode for that command.
|
||||
4. No-project + no-flags commands route local by default.
|
||||
5. `bm cloud login/logout` do not toggle routing behavior.
|
||||
6. `bm mcp` stdio routes per-project mode; HTTP/SSE remain local-forced.
|
||||
7. `bm project list` communicates dual local/cloud presence without ambiguity.
|
||||
8. `bm project ls` output identifies route target explicitly.
|
||||
File diff suppressed because it is too large
Load Diff
+765
-344
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
# Cloud Semantic Search Value (Customer-Facing Technical Story)
|
||||
|
||||
This document explains why teams should buy cloud semantic search even when local search exists.
|
||||
|
||||
## Core Promise
|
||||
|
||||
Markdown files remain the source of truth in both local and cloud modes.
|
||||
|
||||
- Files are portable.
|
||||
- Search indexes are derived and rebuildable.
|
||||
- You never get locked into proprietary document storage.
|
||||
|
||||
## The Customer Problem
|
||||
|
||||
Teams paying for cloud are usually not optimizing for "can this run locally." They are optimizing for:
|
||||
|
||||
- finding the right note the first time,
|
||||
- keeping retrieval quality high as note volume grows,
|
||||
- avoiding search slowdowns while content is actively changing,
|
||||
- getting consistent results across users, agents, and sessions.
|
||||
|
||||
## Why Cloud Is the Aspirin
|
||||
|
||||
Cloud semantic search is the immediate pain reliever because it fixes the problems users feel right now.
|
||||
|
||||
### 1) Better hit rate on real queries
|
||||
|
||||
Cloud uses stronger managed embeddings than the default local model, which improves semantic recall for paraphrases and vague questions.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "I know this exists but search missed it" moments,
|
||||
- less query rewording,
|
||||
- faster time to answer.
|
||||
|
||||
### 2) Better behavior under active workloads
|
||||
|
||||
Cloud indexing runs out of band in workers, so indexing does not compete with interactive read/write traffic.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- stable search responsiveness during heavy updates,
|
||||
- fresher semantic results shortly after edits,
|
||||
- less user-visible performance variance.
|
||||
|
||||
### 3) Better consistency for shared knowledge
|
||||
|
||||
Cloud retrieval runs against a centralized tenant index, so teams and agents resolve against the same semantic state.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "works on my machine" search differences,
|
||||
- more predictable agent behavior across environments,
|
||||
- easier cross-user collaboration on large knowledge bases.
|
||||
|
||||
### 4) Better quality at higher scale
|
||||
|
||||
With Postgres + `pgvector` per tenant, cloud can sustain larger note collections and higher query volumes than typical local setups.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- confidence as repositories grow to tens of thousands of notes,
|
||||
- less need for user-side tuning,
|
||||
- fewer quality regressions as usage increases.
|
||||
|
||||
## Local Is the Vitamin
|
||||
|
||||
Local semantic search still matters and should stay strong.
|
||||
|
||||
- offline use,
|
||||
- privacy-first operation,
|
||||
- no cloud dependency,
|
||||
- user-controlled runtime.
|
||||
|
||||
It compounds long-term ownership and resilience, but does not remove the immediate pain points cloud solves for teams at scale.
|
||||
|
||||
## Recommended Messaging
|
||||
|
||||
One-liner:
|
||||
|
||||
"Cloud semantic search is the aspirin: it fixes retrieval quality and performance pain now. Local semantic search is the vitamin: it builds long-term control and resilience."
|
||||
|
||||
Long form:
|
||||
|
||||
"Basic Memory keeps markdown as the source of truth everywhere. Local gives privacy and offline control. Cloud adds immediate, measurable improvements in search quality, consistency, and responsiveness for teams and agents running at scale."
|
||||
|
||||
## Packaging Guidance
|
||||
|
||||
- Base: local FTS plus optional local semantic search.
|
||||
- Cloud value: higher semantic quality, stable performance under load, and consistent team-wide retrieval.
|
||||
- Keep interfaces pluggable (`EmbeddingProvider`, vector backend protocol) so implementation can evolve without changing user workflows.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Logfire Instrumentation Strategy
|
||||
|
||||
## Why
|
||||
|
||||
We want Logfire in Basic Memory for two specific use cases:
|
||||
|
||||
1. Local development and performance investigation
|
||||
2. Cloud deployments where Basic Memory runs inside Basic Memory Cloud
|
||||
|
||||
This instrumentation must be:
|
||||
|
||||
- Disabled by default
|
||||
- Useful when enabled
|
||||
- Safe for local-first users
|
||||
- Searchable in Logfire over time
|
||||
|
||||
The previous integration added telemetry, but it leaned too much on generic framework instrumentation. That created noisy spans with weak names and made the trace view harder to navigate. This strategy favors manual instrumentation around Basic Memory's real units of work.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Default-off
|
||||
|
||||
Basic Memory should ship with Logfire disabled unless the operator explicitly enables it.
|
||||
|
||||
That means:
|
||||
|
||||
- no required token for normal local usage
|
||||
- no surprise outbound telemetry
|
||||
- no behavior change for existing users
|
||||
|
||||
### 2. Manual spans over automatic framework spans
|
||||
|
||||
We should not rely on broad auto-instrumentation for FastAPI, MCP, SQLAlchemy, or HTTP as the primary experience.
|
||||
|
||||
Why:
|
||||
|
||||
- auto-generated span names are often generic
|
||||
- routes and middleware produce too many low-signal spans
|
||||
- it becomes harder to answer product questions like "why was `write_note` slow?" or "where did sync time go?"
|
||||
|
||||
The preferred model is:
|
||||
|
||||
- one meaningful root span per high-level operation
|
||||
- a small number of child spans for important phases
|
||||
- optional targeted instrumentation only where it adds clear value
|
||||
|
||||
### 3. Logs must live inside traces
|
||||
|
||||
Basic Memory already uses `loguru` pervasively. The Logfire integration should preserve that and make those logs visible inside the active trace/span context.
|
||||
|
||||
If traces exist but the logs are detached from them, the integration is not doing its job.
|
||||
|
||||
### 4. Stable names, selective attributes
|
||||
|
||||
Span names should describe the operation class, not the specific input.
|
||||
|
||||
Good:
|
||||
|
||||
- `mcp.tool.write_note`
|
||||
- `sync.project.scan`
|
||||
- `search.execute`
|
||||
- `routing.resolve_project`
|
||||
|
||||
Bad:
|
||||
|
||||
- `Searching for "foo bar baz"`
|
||||
- `POST /v2/projects/123/search/`
|
||||
- `write note to /specs/api.md`
|
||||
|
||||
Dynamic values belong in attributes, not in the span name.
|
||||
|
||||
## What We Should Not Do
|
||||
|
||||
### Avoid broad FastAPI auto-instrumentation
|
||||
|
||||
We should not turn on `instrument_fastapi()` and treat that as the main telemetry story.
|
||||
|
||||
It may still be useful in narrowly scoped debugging, but it should not define the production trace shape. The meaningful root spans should come from Basic Memory's own entrypoints and service boundaries.
|
||||
|
||||
### Avoid per-file spans by default
|
||||
|
||||
`sync` can process many files. A span per file will explode trace cardinality and make performance views noisy.
|
||||
|
||||
Default behavior should be:
|
||||
|
||||
- one span for the project sync
|
||||
- child spans for scan, move handling, delete handling, markdown sync batch, relation resolution, embedding sync, watermark update
|
||||
- per-file spans only for failures or very slow outliers
|
||||
|
||||
### Avoid high-cardinality attributes on every span
|
||||
|
||||
Do not attach large or highly variable values everywhere:
|
||||
|
||||
- raw note content
|
||||
- file bodies
|
||||
- long search text
|
||||
- arbitrary metadata blobs
|
||||
- unique IDs that make every span shape distinct
|
||||
|
||||
Prefer compact, queryable attributes:
|
||||
|
||||
- `project_name`
|
||||
- `workspace_id`
|
||||
- `route_mode`
|
||||
- `scan_type`
|
||||
- `file_count`
|
||||
- `result_count`
|
||||
- `search_type`
|
||||
- `retrieval_mode`
|
||||
- `duration_ms`
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
Add a dedicated telemetry module in core Basic Memory, separate from logging setup.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```python
|
||||
# basic_memory/telemetry.py
|
||||
|
||||
def configure_telemetry(service_name: str, *, enable_logfire: bool) -> None: ...
|
||||
def telemetry_enabled() -> bool: ...
|
||||
def span(name: str, **attrs): ...
|
||||
def bind_telemetry_context(**attrs): ...
|
||||
```
|
||||
|
||||
This module should:
|
||||
|
||||
- configure Logfire only when explicitly enabled
|
||||
- set up the Logfire `loguru` handler
|
||||
- expose lightweight helpers so application code does not import `logfire` directly everywhere
|
||||
- degrade cleanly to no-op behavior when disabled
|
||||
|
||||
This keeps the rest of the codebase readable and makes it easy to reason about what telemetry is doing.
|
||||
|
||||
## Logging Integration Strategy
|
||||
|
||||
### Goal
|
||||
|
||||
When a span is active, logs emitted through `loguru` during that operation should show up in the same trace.
|
||||
|
||||
### Preferred design
|
||||
|
||||
1. Configure Logfire once in the telemetry bootstrap
|
||||
2. Add the Logfire `loguru` handler to the existing `loguru` configuration
|
||||
3. At operation boundaries, bind stable contextual fields with `loguru`
|
||||
4. Let logs emitted inside the span inherit the active trace context
|
||||
|
||||
### Context to bind
|
||||
|
||||
Bind only the fields that help correlate work across the system:
|
||||
|
||||
- `service_name`
|
||||
- `entrypoint`
|
||||
- `project_name`
|
||||
- `workspace_id`
|
||||
- `route_mode`
|
||||
- `tool_name`
|
||||
- `command_name`
|
||||
|
||||
This binding should happen at the root of an operation, not deep in leaf functions.
|
||||
|
||||
### Important nuance
|
||||
|
||||
We should not try to encode the entire trace model into logger extras. The logger context should be a human-meaningful slice of the active operation. Trace linkage comes from the active Logfire/OpenTelemetry context; logger extras are there to improve searchability and readability.
|
||||
|
||||
## Span Model
|
||||
|
||||
### Root spans
|
||||
|
||||
Each user-visible or system-visible operation should get one root span.
|
||||
|
||||
Examples:
|
||||
|
||||
- `cli.command.status`
|
||||
- `cli.command.project_sync`
|
||||
- `api.request.search`
|
||||
- `mcp.tool.write_note`
|
||||
- `mcp.tool.read_note`
|
||||
- `mcp.tool.search_notes`
|
||||
- `sync.project.run`
|
||||
- `db.semantic_backfill`
|
||||
|
||||
### Child spans
|
||||
|
||||
Child spans should represent real phases whose duration we care about.
|
||||
|
||||
Examples:
|
||||
|
||||
- `routing.client_session`
|
||||
- `routing.resolve_project`
|
||||
- `routing.resolve_workspace`
|
||||
- `api.search.execute`
|
||||
- `sync.project.scan`
|
||||
- `sync.project.detect_moves`
|
||||
- `sync.project.apply_changes`
|
||||
- `sync.project.resolve_relations`
|
||||
- `sync.project.sync_embeddings`
|
||||
- `sync.file.markdown`
|
||||
- `sync.file.regular`
|
||||
- `search.execute`
|
||||
- `search.relaxed_fts_retry`
|
||||
- `db.init`
|
||||
- `db.migrate`
|
||||
|
||||
### Span naming rules
|
||||
|
||||
- Use dot-separated names
|
||||
- Start with subsystem
|
||||
- Keep the verb at the end
|
||||
- Keep names stable across runs
|
||||
- Never include request-specific text in the span name
|
||||
|
||||
## Attribute Taxonomy
|
||||
|
||||
### Required attributes on root spans
|
||||
|
||||
Every root span should have a small common set:
|
||||
|
||||
- `service_name`
|
||||
- `entrypoint`
|
||||
- `project_name` when applicable
|
||||
- `workspace_id` when applicable
|
||||
- `route_mode` with values like `local_asgi`, `cloud_proxy`, `factory`
|
||||
|
||||
### Operation-specific attributes
|
||||
|
||||
Examples:
|
||||
|
||||
For search:
|
||||
|
||||
- `search_type`
|
||||
- `retrieval_mode`
|
||||
- `page`
|
||||
- `page_size`
|
||||
- `result_count`
|
||||
- `fallback_used`
|
||||
|
||||
For sync:
|
||||
|
||||
- `scan_type`
|
||||
- `force_full`
|
||||
- `new_count`
|
||||
- `modified_count`
|
||||
- `deleted_count`
|
||||
- `move_count`
|
||||
- `skipped_count`
|
||||
- `embeddings_enabled`
|
||||
|
||||
For note operations:
|
||||
|
||||
- `tool_name`
|
||||
- `note_type`
|
||||
- `directory`
|
||||
- `overwrite`
|
||||
- `output_format`
|
||||
|
||||
### Attributes to avoid by default
|
||||
|
||||
- full `query.text`
|
||||
- full note titles if they create privacy or cardinality issues
|
||||
- file content
|
||||
- raw frontmatter
|
||||
- raw HTTP bodies
|
||||
|
||||
If we need richer payloads for a local debugging session, that should be an explicit temporary mode, not the default telemetry shape.
|
||||
|
||||
## Instrumentation Plan By Layer
|
||||
|
||||
### 1. Entrypoints
|
||||
|
||||
Instrument these first:
|
||||
|
||||
- `cli.app` callback and major commands
|
||||
- API lifespan and selected routers
|
||||
- MCP server lifespan
|
||||
- MCP tool entrypoints
|
||||
|
||||
Why:
|
||||
|
||||
- this establishes clean root spans
|
||||
- it gives us trace boundaries that match how users think about the product
|
||||
|
||||
### 2. Routing and context resolution
|
||||
|
||||
Instrument:
|
||||
|
||||
- client routing decisions
|
||||
- workspace resolution
|
||||
- project resolution
|
||||
- default-project fallback
|
||||
|
||||
Why:
|
||||
|
||||
- Basic Memory has local/cloud/per-project routing logic
|
||||
- when something is slow or surprising, we need to know which path was taken
|
||||
|
||||
### 3. Sync and indexing
|
||||
|
||||
This is the highest-value area to instrument deeply.
|
||||
|
||||
Instrument:
|
||||
|
||||
- sync root
|
||||
- scan strategy decision
|
||||
- filesystem scan
|
||||
- move detection
|
||||
- delete handling
|
||||
- markdown sync phase
|
||||
- relation resolution
|
||||
- vector embedding sync
|
||||
- scan watermark update
|
||||
|
||||
Why:
|
||||
|
||||
- this is where performance work will happen
|
||||
- cloud and local both benefit from this visibility
|
||||
|
||||
### 4. Search
|
||||
|
||||
Instrument:
|
||||
|
||||
- search execution
|
||||
- retrieval mode
|
||||
- relaxed FTS fallback
|
||||
- result shaping
|
||||
|
||||
Why:
|
||||
|
||||
- search is user-facing and latency-sensitive
|
||||
- hybrid/vector/FTS paths need to be distinguishable
|
||||
|
||||
### 5. Database and initialization
|
||||
|
||||
Instrument selectively:
|
||||
|
||||
- DB init
|
||||
- migrations
|
||||
- semantic backfill
|
||||
- connection mode selection
|
||||
|
||||
Avoid full automatic SQL span firehose by default.
|
||||
|
||||
## Recommended Rollout Phases
|
||||
|
||||
## Task List
|
||||
|
||||
- [x] Phase 1: Bootstrap and config gating
|
||||
- [x] Phase 2: Root spans for entrypoints and primary operations
|
||||
- [x] Phase 3: Child spans for sync, search, and routing
|
||||
- [x] Phase 4: Failure-focused detail and final verification
|
||||
- [x] Phase 5: Loguru context binding and scoped context inheritance
|
||||
|
||||
## Recommended Rollout Phases
|
||||
|
||||
### Phase 1: Bootstrap and config gating
|
||||
|
||||
Add:
|
||||
|
||||
- telemetry bootstrap module
|
||||
- config/env gating
|
||||
- `loguru` + Logfire handler integration
|
||||
|
||||
This gives immediate value with low noise.
|
||||
|
||||
### Phase 2: Root spans for entrypoints and primary operations
|
||||
|
||||
Add:
|
||||
|
||||
- root spans for CLI, API, MCP, and main MCP tools
|
||||
- stable root attributes for project, workspace, route mode, and operation type
|
||||
|
||||
This gives us clean top-level traces that match how users think about the product.
|
||||
|
||||
### Phase 3: Child spans for sync, search, and routing
|
||||
|
||||
Add child spans to:
|
||||
|
||||
- sync
|
||||
- search
|
||||
- routing
|
||||
|
||||
This is the main performance-investigation layer.
|
||||
|
||||
### Phase 4: Failure-focused detail
|
||||
|
||||
Add selective deeper spans/log enrichment for:
|
||||
|
||||
- sync failures
|
||||
- relation resolution failures
|
||||
- slow file operations
|
||||
- cloud routing/auth failures
|
||||
|
||||
This keeps normal traces clean while improving debuggability.
|
||||
|
||||
### Phase 5: Loguru context binding and scoped context inheritance
|
||||
|
||||
Add:
|
||||
|
||||
- context-local telemetry state in `basic_memory.telemetry`
|
||||
- a shared `scope(...)` helper that opens a span and binds stable logger context together
|
||||
- context inheritance for routing, sync, and search so downstream `loguru` logs carry the active operation fields
|
||||
|
||||
This makes the trace view and the log stream tell the same story without forcing logger rewrites across the codebase.
|
||||
|
||||
## Local Dev Playbook
|
||||
|
||||
The fastest way to sanity-check the current trace shape is:
|
||||
|
||||
```bash
|
||||
LOGFIRE_TOKEN=lf_... just telemetry-smoke
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
- creates an isolated temp home, config dir, and project path
|
||||
- enables Logfire for the run
|
||||
- automatically exports to Logfire when `LOGFIRE_TOKEN` is present
|
||||
- defaults `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=false` so the smoke run stays fast and trace-friendly
|
||||
- disables promo telemetry so the trace is about Basic Memory work, not analytics noise
|
||||
- runs a small CLI workflow:
|
||||
- `project add`
|
||||
- `tool write-note`
|
||||
- `tool read-note`
|
||||
- `tool edit-note`
|
||||
- `tool build-context`
|
||||
- `tool search-notes`
|
||||
- `doctor`
|
||||
|
||||
If you want to exercise the instrumentation without exporting anything upstream:
|
||||
|
||||
```bash
|
||||
BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=false just telemetry-smoke
|
||||
```
|
||||
|
||||
If you want the smoke run to include vector or hybrid retrieval spans too:
|
||||
|
||||
```bash
|
||||
LOGFIRE_TOKEN=lf_... BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true just telemetry-smoke
|
||||
```
|
||||
|
||||
The recipe sets `BASIC_MEMORY_LOGFIRE_ENVIRONMENT=telemetry-smoke` by default so these traces are easy to isolate in Logfire. Override it if you want the smoke traces grouped under a different environment name.
|
||||
|
||||
### What to look for
|
||||
|
||||
You should see a small set of comparable root spans rather than a framework-generated span forest:
|
||||
|
||||
- `cli.command.project`
|
||||
- `cli.command.tool`
|
||||
- `mcp.tool.write_note`
|
||||
- `mcp.tool.read_note`
|
||||
- `mcp.tool.edit_note`
|
||||
- `mcp.tool.build_context`
|
||||
- `mcp.tool.search_notes`
|
||||
- `sync.project.run`
|
||||
|
||||
You should also see correlated logs under those traces with stable fields like:
|
||||
|
||||
- `project_name`
|
||||
- `route_mode`
|
||||
- `tool_name`
|
||||
- `entrypoint`
|
||||
|
||||
### Expected nuance
|
||||
|
||||
`doctor` creates its own temporary project on purpose. That means the sync trace will usually show a different project name than the `telemetry-smoke` write/search traces. That is fine for smoke testing because the goal is to confirm:
|
||||
|
||||
- root span names are meaningful
|
||||
- scoped logs stay attached to the active trace
|
||||
- routing, tool, search, and sync phases are easy to distinguish
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
We should consider the integration successful when the following are true:
|
||||
|
||||
1. With telemetry disabled, Basic Memory behaves exactly as it does today.
|
||||
2. With telemetry enabled, one user action produces one obvious root span.
|
||||
3. Logs emitted during that action are visible inside the same trace.
|
||||
4. A search in Logfire for `mcp.tool.write_note` or `sync.project.run` returns comparable spans across runs.
|
||||
5. Trace views show phase timing clearly without drowning in framework noise.
|
||||
6. Sensitive payloads are not captured by default.
|
||||
|
||||
## Immediate Implementation Direction
|
||||
|
||||
When we start coding, the first pass should be:
|
||||
|
||||
1. Add `basic_memory.telemetry`
|
||||
2. Add config/env switches for `enabled`, `send_to_logfire`, and service name
|
||||
3. Wire telemetry bootstrap into CLI, API, and MCP entrypoints
|
||||
4. Configure `loguru` to emit to both existing sinks and the Logfire handler when enabled
|
||||
5. Add manual root spans around:
|
||||
- CLI commands
|
||||
- API request handlers we care about
|
||||
- MCP tool entrypoints
|
||||
- sync root
|
||||
- search root
|
||||
6. Add child spans to the sync and routing phases only after the root span model feels clean
|
||||
|
||||
That gives us a strong foundation without repeating the earlier "turn on instrumentation everywhere" approach.
|
||||
@@ -0,0 +1,138 @@
|
||||
# MCP UI Bakeoff - Instructions & Test Plan
|
||||
|
||||
Last updated: 2026-02-02
|
||||
|
||||
## Scope
|
||||
|
||||
Compare three presentation paths for Basic Memory MCP tools:
|
||||
|
||||
1. **Tool‑UI (React)** via MCP App resources.
|
||||
2. **MCP‑UI Python SDK** embedded UI resources (legacy host path).
|
||||
3. **ASCII/ANSI** output for TUI clients.
|
||||
|
||||
This doc is the running instruction set and test plan. Update as implementation progresses.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
|
||||
- Node for tool‑ui build (already used for POC)
|
||||
- Python 3.12+ with `uv`
|
||||
|
||||
Optional (for MCP‑UI Python SDK path):
|
||||
|
||||
- Local repo: `/Users/phernandez/dev/mcp-ui`
|
||||
- Install the server SDK into the Basic Memory venv:
|
||||
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
|
||||
|
||||
---
|
||||
|
||||
## Build / Refresh Steps
|
||||
|
||||
### Tool‑UI React bundle
|
||||
|
||||
```bash
|
||||
cd ui/tool-ui-react
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This regenerates:
|
||||
|
||||
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
|
||||
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
|
||||
|
||||
---
|
||||
|
||||
## How to Run the MCP Server
|
||||
|
||||
```bash
|
||||
basic-memory mcp --transport stdio
|
||||
```
|
||||
|
||||
Optional to pick UI variant for MCP App resources:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1) MCP App Resource UI (tool‑ui / vanilla / mcp‑ui)
|
||||
|
||||
Tools:
|
||||
- `search_notes`
|
||||
- `read_note`
|
||||
|
||||
Expect:
|
||||
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
|
||||
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
|
||||
- Variant‑specific URIs also available:
|
||||
- `ui://basic-memory/search-results/vanilla`
|
||||
- `ui://basic-memory/search-results/tool-ui`
|
||||
- `ui://basic-memory/search-results/mcp-ui`
|
||||
- `ui://basic-memory/note-preview/vanilla`
|
||||
- `ui://basic-memory/note-preview/tool-ui`
|
||||
- `ui://basic-memory/note-preview/mcp-ui`
|
||||
|
||||
Manual check:
|
||||
- Trigger tool in MCP‑App‑capable host and confirm UI renders.
|
||||
|
||||
---
|
||||
|
||||
### 2) Text / JSON Output Modes
|
||||
|
||||
Tools:
|
||||
- `search_notes(output_format="text" | "json")`
|
||||
- `read_note(output_format="text" | "json")`
|
||||
- `write_note(output_format="text" | "json")`
|
||||
- `edit_note(output_format="text" | "json")`
|
||||
- `recent_activity(output_format="text" | "json")`
|
||||
- `list_memory_projects(output_format="text" | "json")`
|
||||
- `create_memory_project(output_format="text" | "json")`
|
||||
- `delete_note(output_format="text" | "json")`
|
||||
- `move_note(output_format="text" | "json")`
|
||||
- `build_context(output_format="json" | "text")`
|
||||
|
||||
Expect:
|
||||
- `text` mode preserves existing human-readable responses.
|
||||
- `json` mode returns structured dict/list payloads for machine-readable clients.
|
||||
|
||||
Automated:
|
||||
- `uv run pytest test-int/mcp/test_output_format_json_integration.py`
|
||||
|
||||
---
|
||||
|
||||
### 3) MCP‑UI Python SDK (embedded UI resource)
|
||||
|
||||
Tools (embedded resource responses):
|
||||
- `search_notes_ui` (MCP‑UI SDK)
|
||||
- `read_note_ui` (MCP‑UI SDK)
|
||||
|
||||
Expected output:
|
||||
- Tool response content contains an EmbeddedResource (`type: "resource"`)
|
||||
- `mimeType` is `text/html`
|
||||
- `_meta` includes:
|
||||
- `mcpui.dev/ui-preferred-frame-size`
|
||||
- `mcpui.dev/ui-initial-render-data`
|
||||
|
||||
Manual check:
|
||||
- Render tool responses using `UIResourceRenderer` (legacy host flow).
|
||||
|
||||
Automated (if SDK installed):
|
||||
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
---
|
||||
|
||||
## Bakeoff Notes Template
|
||||
|
||||
Fill in after running:
|
||||
|
||||
- Tool‑UI (React): __
|
||||
- MCP‑UI SDK (embedded): __
|
||||
- Text/JSON modes: __
|
||||
|
||||
Decision + rationale: __
|
||||
@@ -0,0 +1,260 @@
|
||||
# Metadata Search Reference
|
||||
|
||||
Basic Memory automatically indexes custom frontmatter fields so you can query them with structured filters. Any YAML key in a note's frontmatter beyond the standard set (`title`, `type`, `tags`, `permalink`, `schema`) is stored as `entity_metadata` and becomes searchable.
|
||||
|
||||
## Querying with `search_notes`
|
||||
|
||||
`search_notes` is the single search tool for all queries — text, metadata filters, or both. The `query` parameter is optional, so you can use metadata filters alone without passing an empty string.
|
||||
|
||||
## Filter Syntax
|
||||
|
||||
Filters are a JSON dictionary where each key targets a frontmatter field and the value specifies the match condition. Multiple keys combine with **AND** logic — every filter must match.
|
||||
|
||||
### Equality
|
||||
|
||||
Match a single value exactly.
|
||||
|
||||
```json
|
||||
{"status": "active"}
|
||||
```
|
||||
|
||||
Finds notes whose frontmatter contains `status: active`.
|
||||
|
||||
### Array Contains (all)
|
||||
|
||||
Pass a list to require **all** listed values to be present in the field.
|
||||
|
||||
```json
|
||||
{"tags": ["security", "oauth"]}
|
||||
```
|
||||
|
||||
Finds notes tagged with both `security` and `oauth`.
|
||||
|
||||
### `$in` (any of)
|
||||
|
||||
Match if the field equals **any** value in the list.
|
||||
|
||||
```json
|
||||
{"priority": {"$in": ["high", "critical"]}}
|
||||
```
|
||||
|
||||
### `$gt`, `$gte`, `$lt`, `$lte`
|
||||
|
||||
Numeric and text comparisons. Numeric values use numeric comparison; strings use lexicographic comparison.
|
||||
|
||||
```json
|
||||
{"confidence": {"$gt": 0.7}}
|
||||
{"score": {"$lte": 100}}
|
||||
```
|
||||
|
||||
### `$between`
|
||||
|
||||
Range filter (inclusive). Takes a `[min, max]` pair.
|
||||
|
||||
```json
|
||||
{"score": {"$between": [0.3, 0.8]}}
|
||||
```
|
||||
|
||||
### Nested Access (dot notation)
|
||||
|
||||
Access nested frontmatter values using dots.
|
||||
|
||||
```json
|
||||
{"schema.version": "2"}
|
||||
```
|
||||
|
||||
This queries the `version` key inside a `schema` object in frontmatter.
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Operator | Syntax | Example |
|
||||
|----------|--------|---------|
|
||||
| Equality | `{"field": "value"}` | `{"status": "active"}` |
|
||||
| Array contains (all) | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
|
||||
| `$in` (any of) | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
|
||||
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
|
||||
| `$lt` / `$lte` | `{"field": {"$lt": N}}` | `{"score": {"$lt": 0.5}}` |
|
||||
| `$between` | `{"field": {"$between": [min, max]}}` | `{"score": {"$between": [0.3, 0.8]}}` |
|
||||
| Nested access | `{"a.b": "value"}` | `{"schema.version": "2"}` |
|
||||
|
||||
**Key rules:**
|
||||
- Filter keys must match `[A-Za-z0-9_-]+` (dots separate nesting levels).
|
||||
- Each operator dict must contain exactly one operator.
|
||||
- `$in` and array-contains require non-empty lists.
|
||||
- `$between` requires exactly two values `[min, max]`.
|
||||
|
||||
## MCP Tool — `search_notes`
|
||||
|
||||
`search_notes` is the single search tool for text queries, metadata filters, or both. The `query` parameter is optional.
|
||||
|
||||
**Relevant parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `query` | string (optional) | Text search query. Omit for filter-only searches. |
|
||||
| `metadata_filters` | dict | Structured filter dict (see syntax above) |
|
||||
| `tags` | list[str] | Convenience shorthand — merged into `metadata_filters["tags"]` |
|
||||
| `status` | string | Convenience shorthand — merged into `metadata_filters["status"]` |
|
||||
|
||||
**Merging rules:** `tags` and `status` are convenience shortcuts. They are merged into `metadata_filters` using `setdefault` — if the same key already exists in `metadata_filters`, the explicit filter wins.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Text search filtered by metadata
|
||||
await search_notes("authentication", metadata_filters={"status": "draft"})
|
||||
|
||||
# Filter-only search (no query needed)
|
||||
await search_notes(metadata_filters={"type": "spec"})
|
||||
|
||||
# Combine text, tags shortcut, and metadata
|
||||
await search_notes(
|
||||
"oauth flow",
|
||||
tags=["security"],
|
||||
metadata_filters={"confidence": {"$gt": 0.7}},
|
||||
)
|
||||
|
||||
# Convenience shortcuts
|
||||
await search_notes("planning", status="active")
|
||||
await search_notes(tags=["tier1", "alpha"])
|
||||
```
|
||||
|
||||
## Tag Search Shortcuts
|
||||
|
||||
The `tag:` prefix in a search query is a shorthand for tag-based metadata filtering. When `search_notes` receives a query starting with `tag:`, it converts the query into a `tags` filter and clears the text query.
|
||||
|
||||
```python
|
||||
# These are equivalent:
|
||||
await search_notes("tag:tier1")
|
||||
await search_notes("", tags=["tier1"])
|
||||
|
||||
# Multiple tags (comma or space separated) — all must be present:
|
||||
await search_notes("tag:tier1,alpha")
|
||||
await search_notes("tag:tier1 alpha")
|
||||
```
|
||||
|
||||
## CLI Access
|
||||
|
||||
The `bm tool search-notes` command exposes metadata filtering via `--meta` and `--filter` flags.
|
||||
|
||||
### `--meta` — simple key=value filters
|
||||
|
||||
Repeatable flag for equality filters on frontmatter fields.
|
||||
|
||||
```bash
|
||||
# Single filter
|
||||
bm tool search-notes "my query" --meta status=draft
|
||||
|
||||
# Multiple filters (AND logic)
|
||||
bm tool search-notes "" --meta status=active --meta priority=high
|
||||
```
|
||||
|
||||
### `--filter` — advanced JSON filters
|
||||
|
||||
Pass a full JSON filter dictionary for operator-based queries.
|
||||
|
||||
```bash
|
||||
# Range filter
|
||||
bm tool search-notes "" --filter '{"score": {"$between": [0.3, 0.8]}}'
|
||||
|
||||
# $in filter
|
||||
bm tool search-notes "" --filter '{"priority": {"$in": ["high", "critical"]}}'
|
||||
```
|
||||
|
||||
### `--tag` and `--status` — convenience shortcuts
|
||||
|
||||
```bash
|
||||
bm tool search-notes "query" --tag security --tag oauth
|
||||
bm tool search-notes "" --status draft
|
||||
```
|
||||
|
||||
### Combined example
|
||||
|
||||
```bash
|
||||
bm tool search-notes "authentication" --tag security --meta status=draft --type spec
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example notes with custom frontmatter
|
||||
|
||||
**`specs/auth-design.md`:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Auth Design
|
||||
type: spec
|
||||
tags: [security, oauth]
|
||||
status: in-progress
|
||||
priority: high
|
||||
confidence: 0.85
|
||||
---
|
||||
|
||||
# Auth Design
|
||||
|
||||
## Observations
|
||||
- [decision] Use OAuth 2.1 with PKCE for all client types #security
|
||||
- [requirement] Token refresh must be transparent to the user
|
||||
|
||||
## Relations
|
||||
- implements [[Security Requirements]]
|
||||
```
|
||||
|
||||
**`specs/search-redesign.md`:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Search Redesign
|
||||
type: spec
|
||||
tags: [search, performance]
|
||||
status: draft
|
||||
priority: medium
|
||||
confidence: 0.6
|
||||
---
|
||||
|
||||
# Search Redesign
|
||||
|
||||
## Observations
|
||||
- [goal] Sub-100ms search response times #performance
|
||||
- [approach] Hybrid FTS + vector retrieval
|
||||
|
||||
## Relations
|
||||
- depends_on [[Database Schema]]
|
||||
```
|
||||
|
||||
### Queries that find them
|
||||
|
||||
```python
|
||||
# Find all in-progress specs
|
||||
await search_notes(metadata_filters={"status": "in-progress", "type": "spec"})
|
||||
# → Auth Design
|
||||
|
||||
# Find high-confidence specs
|
||||
await search_notes(metadata_filters={"confidence": {"$gt": 0.7}})
|
||||
# → Auth Design (confidence: 0.85)
|
||||
|
||||
# Find specs with priority high or medium
|
||||
await search_notes(metadata_filters={"priority": {"$in": ["high", "medium"]}})
|
||||
# → Auth Design, Search Redesign
|
||||
|
||||
# Find specs in a confidence range
|
||||
await search_notes(metadata_filters={"confidence": {"$between": [0.5, 0.9]}})
|
||||
# → Auth Design (0.85), Search Redesign (0.6)
|
||||
|
||||
# Find notes tagged with security
|
||||
await search_notes("tag:security")
|
||||
# → Auth Design
|
||||
|
||||
# Combined: text search + metadata filter
|
||||
await search_notes("OAuth", metadata_filters={"status": "in-progress"})
|
||||
# → Auth Design
|
||||
```
|
||||
|
||||
### CLI equivalents
|
||||
|
||||
```bash
|
||||
bm tool search-notes "" --meta status=in-progress --type spec
|
||||
bm tool search-notes "" --filter '{"confidence": {"$gt": 0.7}}'
|
||||
bm tool search-notes "OAuth" --meta status=in-progress
|
||||
bm tool search-notes --tag security
|
||||
```
|
||||
@@ -0,0 +1,344 @@
|
||||
# Post-v0.18.0 Test Plan and Acceptance Criteria
|
||||
|
||||
## Goal
|
||||
|
||||
Define a complete validation plan for all major features merged after `v0.18.0`, combining:
|
||||
|
||||
- Coverage-gap-driven automated tests
|
||||
- Real MCP server integration tests (no mocks for target flows)
|
||||
- Manual MCP verification via LLM-driven tool calls
|
||||
|
||||
This plan is based on commits in `v0.18.0..HEAD` and the latest `just check` coverage output.
|
||||
|
||||
## Scope Window
|
||||
|
||||
- Start tag: `v0.18.0` (2026-01-28)
|
||||
- End: current `main`
|
||||
- Change volume: 12 feature commits + 14 bug-fix commits (+ release chores/hotfixes)
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
1. Stabilize all feature-level acceptance criteria in automated tests first.
|
||||
2. Add black-box MCP integration tests for semantic search + schema (real server startup).
|
||||
3. Run manual MCP tool-call verification to confirm real UX and routing behavior.
|
||||
4. Re-run full gate: `just check` + targeted integration packs.
|
||||
|
||||
## Global Quality Gates
|
||||
|
||||
- Feature criteria below must all pass.
|
||||
- No regressions in existing suites.
|
||||
- Coverage improves in targeted low-coverage feature modules.
|
||||
- SQLite and Postgres parity for search/semantic features.
|
||||
|
||||
## Priority Coverage Gaps (from latest run)
|
||||
|
||||
These are the most important post-`v0.18.0` feature modules currently under-covered:
|
||||
|
||||
- `src/basic_memory/mcp/tools/schema.py` (27%)
|
||||
- `src/basic_memory/mcp/clients/schema.py` (36%)
|
||||
- `src/basic_memory/mcp/tools/ui_sdk.py` (43%)
|
||||
- `src/basic_memory/mcp/tools/search.py` (73%)
|
||||
- `src/basic_memory/repository/postgres_search_repository.py` (63%)
|
||||
- `src/basic_memory/mcp/async_client.py` (82%)
|
||||
- `src/basic_memory/api/v2/routers/schema_router.py` (80%)
|
||||
|
||||
## Feature Acceptance Criteria and Test Plan
|
||||
|
||||
### 1) Schema System (`c97733d`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `schema_validate`, `schema_infer`, and `schema_diff` produce consistent outcomes across CLI/API/MCP for the same fixture set.
|
||||
- Strict validation fails deterministically on required-field/type violations.
|
||||
- Validation warnings are stable and machine-readable in non-strict mode.
|
||||
- Inference output is deterministic for unchanged input corpus.
|
||||
- Drift diff output is deterministic and identifies missing/extra/type-mismatch fields correctly.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/schema/*`
|
||||
- `tests/api/v2/test_schema_router.py`
|
||||
- `test-int/test_schema/*`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~MCP schema tool branches (`src/basic_memory/mcp/tools/schema.py`)~~ — 18 tests in `tests/mcp/test_tool_schema.py`
|
||||
- ~~MCP schema client behavior (`src/basic_memory/mcp/clients/schema.py`)~~ — `tests/mcp/test_client_schema.py`
|
||||
- ~~Schema router error-path branches (`src/basic_memory/api/v2/routers/schema_router.py`)~~ — `tests/api/v2/test_schema_router.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add MCP tool tests for `schema_validate` strict + non-strict result shapes.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_infer` with explicit `entity_type` and inferred type fallback.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_diff` empty-diff and non-empty-diff paths.~~ **DONE**
|
||||
- ~~Add API tests for schema router invalid payload/edge error handling.~~ **DONE**
|
||||
- Add integration test that starts MCP server and calls schema tools end-to-end on fixture notes. — deferred to backlog item 4.
|
||||
|
||||
### 2) Semantic Search (`0777879`, `1428d18`, `344e651`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `search_type=text|vector|hybrid` returns expected ranked results on canonical semantic corpus.
|
||||
- Missing semantic dependencies fail fast with actionable install guidance.
|
||||
- Reindex and provider/model changes produce valid vectors without dimension mismatch.
|
||||
- SQLite and Postgres produce equivalent behavior for semantic modes on the same dataset.
|
||||
- Generated-column migration path is valid on SQLite environments in use.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_sqlite_vector_search_repository.py`
|
||||
- `tests/repository/test_postgres_search_repository.py`
|
||||
- `tests/services/test_semantic_search.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `test-int/test_search_performance_benchmark.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Uncovered Postgres vector/hybrid branches~~ — 20 tests in `tests/repository/test_postgres_search_repository_unit.py` + 5 integration tests in `test-int/semantic/test_semantic_coverage.py`
|
||||
- ~~MCP search semantic/output branches~~ — expanded `tests/mcp/test_tool_search.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Expand Postgres repository tests for vector query composition edge cases.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for hybrid fusion ranking and pagination branches.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for embedding/provider error handling branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for vector/hybrid output formatting branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for semantic-disabled and missing-dependency failures.~~ **DONE**
|
||||
- Add MCP integration tests that start server and execute semantic `search_notes` tool calls. — deferred to backlog item 4.
|
||||
|
||||
### Semantic search quality benchmarks (NEW)
|
||||
|
||||
Full benchmark suite in `test-int/semantic/` covering 5 backend×provider combinations:
|
||||
- `sqlite-fts`, `sqlite-fastembed`, `postgres-fts`, `postgres-fastembed`, `postgres-openai`
|
||||
- Quality metrics: hit@1, recall@5, MRR@10 with per-query timing
|
||||
- Realistic corpus with cross-topic vocabulary overlap (240 notes, 4 topics)
|
||||
- Rich CLI viewer: `just semantic-report`
|
||||
- JSON artifact output: `just test-semantic-report`
|
||||
|
||||
Key finding: **FastEmbed (384-d local ONNX) matches or exceeds OpenAI (1536-d) quality at 30x lower latency.** Recommending FastEmbed as default for both local and cloud deployments.
|
||||
|
||||
### 3) Per-Project Local/Cloud Routing + API Key Auth (`d84708c`, `ed94877`, `312662f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project mode (`local`/`cloud`) persists and displays correctly.
|
||||
- Routing selects ASGI for local projects and HTTP+Bearer for cloud projects.
|
||||
- Cloud project without key fails with explicit remediation (`cloud set-key`/`cloud create-key`).
|
||||
- Resolution precedence is correct (factory > force-local > per-project cloud > global fallback > local).
|
||||
- Watch/sync only run for local projects.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_async_client_modes.py`
|
||||
- `tests/cli/test_project_set_cloud_local.py`
|
||||
- `tests/mcp/test_project_context.py`
|
||||
- `tests/test_project_resolver.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Cloud routing branch gaps in `src/basic_memory/mcp/async_client.py`~~ — expanded `tests/mcp/test_async_client_modes.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add branch-focused tests for all unresolved routing branches in `get_client()`.~~ **DONE**
|
||||
- Add MCP integration scenario with mixed local/cloud project config — deferred to backlog item 4.
|
||||
|
||||
### 4) Project-Prefixed Permalinks + Memory URL Routing (`545804f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project-prefixed permalinks are generated consistently on create/update/import flows.
|
||||
- Memory URLs resolve to the correct project/entity even with duplicate note titles.
|
||||
- `read_note`, `search`, `build_context`, write/edit/move flows preserve project identity correctly.
|
||||
- Link resolution remains correct for context-aware wikilinks.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/utils/test_permalink_formatting.py`
|
||||
- `tests/mcp/test_tool_read_note.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `tests/services/test_context_service.py`
|
||||
- `test-int/mcp/test_read_note_integration.py`
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- No major coverage alarm in report, but keep as regression-critical due broad impact surface.
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one integration test with colliding titles across two projects and assert URL routing invariants.~~ **DONE** — `test-int/mcp/test_permalink_collision_integration.py` (2 tests: collision across projects + memory:// URL routing with project prefix)
|
||||
|
||||
### 5) MCP UI Variants + TUI Output (`8bc03d1`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- UI resource variant selection (`tool-ui`, `vanilla`, `mcp-ui`) follows env configuration.
|
||||
- `search_notes` and `read_note` expose expected resource metadata for UI hosts.
|
||||
- `ascii`/`ansi` outputs are deterministic and stable for terminal clients.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_tool_contracts.py`
|
||||
- `test-int/mcp/test_output_format_json_integration.py`
|
||||
- `test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~`src/basic_memory/mcp/tools/ui_sdk.py` branch coverage~~ — `tests/mcp/test_ui_sdk.py`
|
||||
- ~~`src/basic_memory/mcp/ui/sdk.py` and `src/basic_memory/mcp/ui/templates.py` branch coverage~~ — `tests/mcp/test_ui_templates.py` + `tests/mcp/test_ui_resources.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add unit tests for UI SDK metadata generation and template selection branches.~~ **DONE** — 31 tests
|
||||
- ~~Add integration assertion for variant-specific resource URIs and metadata payload shape.~~ **DONE**
|
||||
|
||||
### 6) Watch Command (`8df88e4`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `basic-memory watch` starts and processes create/update/delete events.
|
||||
- Watch restart/reload path does not duplicate watchers.
|
||||
- Cloud-mode projects are excluded from active watcher set.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_watch.py`
|
||||
- `tests/sync/test_coordinator.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one stress-style integration test for rapid file changes and watcher stability.~~ **DONE** — `tests/sync/test_watch_service_stress.py` (3 tests: 50-file batch, mixed add/modify/delete batch, rapid modifications to same file)
|
||||
|
||||
### 7) CLI JSON Output (`a47c9c0`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `--format json` returns valid JSON with stable keys for success paths.
|
||||
- Error paths also return JSON-shaped output with correct non-zero exits.
|
||||
- Default human output remains unchanged.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_cli_tool_json_output.py`
|
||||
- `test-int/cli/test_cli_tool_json_integration.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one failure-path integration test per high-use tool command.~~ **DONE** — `test-int/cli/test_cli_tool_json_failure_integration.py` (4 tests: read-note not found, write-note missing content, write→read roundtrip, recent-activity empty project)
|
||||
|
||||
### 8) Search/Edit and Metadata Fixes (`530cbac`, `f1d50c2`, `8838571`, `009e849`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Metadata filters produce consistent results on SQLite and Postgres.
|
||||
- `tag:` shorthand works alone and with mixed query terms.
|
||||
- Fast write/edit paths preserve `external_id` and metadata integrity.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_metadata_filters.py`
|
||||
- `tests/repository/test_search_repository.py`
|
||||
- `tests/services/test_search_service.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add Postgres-specific metadata filter edge-case tests to mirror SQLite assertions exactly.~~ **DONE** — `tests/repository/test_metadata_filters_edge_cases.py` (6 tests: missing field, AND logic, contains single-element array, nested path missing intermediate, $gte/$lte boundaries, $between inclusive — all pass on both SQLite and Postgres)
|
||||
|
||||
### 9) Compatibility and Hotfix Regression Pack (`c46d7a6`, `a0e754b`, `343a6e1`, `24ca5f6`, `e3ced49`, `8489a3d`, `b609c4e`, `f6e0a5b`, `7624a20`)
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Legacy endpoints required by older CLI versions function without `405` (`GET /projects/projects`, `POST /projects/projects`, `POST /projects/config/sync`).
|
||||
- Entity creation conflicts map to conflict status (not 500).
|
||||
- `recent_activity` prompt defaults are correct.
|
||||
- No spurious `metadata: {}` in serialized frontmatter.
|
||||
- Tigris/rclone uses global consistency headers for all transaction types.
|
||||
- `bm --version` fast path avoids heavy import path and remains responsive.
|
||||
- Default SQLite DB path is isolated by config dir.
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- ~~Commits with no direct tests added (`c46d7a6`, `344e651`, `f6e0a5b`) need explicit regression tests.~~ **DONE**
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add API compat test covering all legacy endpoint methods and payloads.~~ **DONE** — `test_legacy_v1_add_project_endpoint`, `test_legacy_v1_sync_config_endpoint`
|
||||
- ~~Add CLI fast-path test for `--version` import behavior/performance guard.~~ **DONE** — `test_bm_version_does_not_import_heavy_modules`
|
||||
- ~~Add empty metadata serialization regression test.~~ **DONE** — `test_schema_to_markdown_empty_metadata_no_metadata_key`
|
||||
- Add migration safety test for SQLite generated columns (`VIRTUAL` expectation) — deferred, low risk.
|
||||
|
||||
## MCP Manual Verification Plan (LLM Tool Calls)
|
||||
|
||||
Run after automated tests pass.
|
||||
|
||||
### Setup
|
||||
|
||||
- Start MCP server: `basic-memory mcp --transport stdio`
|
||||
- Use an MCP-capable client and issue tool calls directly.
|
||||
|
||||
### Manual scenarios
|
||||
|
||||
- Schema: call `schema_validate`, `schema_infer`, and `schema_diff` on known fixtures.
|
||||
- Schema: verify error and success payloads match acceptance criteria.
|
||||
- Semantic search: call `search_notes` with `search_type=text|vector|hybrid`.
|
||||
- Semantic search: verify ranking relevance on semantic fixture queries.
|
||||
- Routing: call tools with explicit project on mixed local/cloud setup.
|
||||
- Routing: verify success/failure paths with and without API key.
|
||||
- Permalink routing: read/write/search notes across projects with colliding titles.
|
||||
- Permalink routing: verify memory URL routing correctness.
|
||||
- UI/TUI: call `search_notes` and `read_note` with UI variants and `output_format=text|json`.
|
||||
- UI/TUI: verify payload/resource format and metadata completeness.
|
||||
|
||||
## Implementation Backlog (Ordered)
|
||||
|
||||
1. ~~Fill schema MCP/client/router coverage gaps.~~ **DONE** — 18 tests in `test_tool_schema.py` + `test_client_schema.py`
|
||||
2. ~~Fill semantic search MCP + Postgres repository gaps.~~ **DONE** — 20 tests in `test_postgres_search_repository_unit.py` + `test_tool_search.py`
|
||||
3. ~~Add compatibility regression tests (legacy endpoints, migration, version fast path).~~ **DONE** — 5 tests across 3 files (see below)
|
||||
4. ~~Add feature-level integration tests (permalinks, watch, CLI JSON, metadata filters).~~ **DONE** — 15 tests across 4 files (see items 4, 6, 7, 8 above)
|
||||
5. ~~Expand UI SDK and template branch tests.~~ **DONE** — 31 tests in `test_ui_templates.py` + `test_ui_sdk.py` + `test_ui_resources.py`
|
||||
6. ~~Run full gate and capture results in a short release readiness summary.~~ **DONE** — see results below
|
||||
|
||||
### Full Gate Results (`just check`)
|
||||
|
||||
| Phase | Result |
|
||||
|-------|--------|
|
||||
| lint | PASS |
|
||||
| format | PASS |
|
||||
| typecheck | PASS |
|
||||
| Unit tests (SQLite) | 1788 passed, 15 skipped |
|
||||
| Integration tests (SQLite) | 243 passed, 4 skipped, 10 deselected |
|
||||
| Unit tests (Postgres) | 1760 passed, 28 skipped |
|
||||
| Integration tests (Postgres) | 234 passed, 13 skipped, 10 deselected |
|
||||
|
||||
**0 failures. 10 deselected = semantic benchmark tests (run separately via `just test-semantic`).**
|
||||
|
||||
### Item 3 Details — Compatibility Regression Tests
|
||||
|
||||
| Test | File | What it covers |
|
||||
|------|------|----------------|
|
||||
| `test_legacy_v1_add_project_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/projects` legacy route reachable (idempotent path) |
|
||||
| `test_legacy_v1_sync_config_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/config/sync` legacy route reachable |
|
||||
| `test_bm_version_does_not_import_heavy_modules` | `tests/cli/test_cli_exit.py` | `bm --version` fast path does not load `basic_memory.mcp` |
|
||||
| `test_schema_to_markdown_empty_metadata_no_metadata_key` | `tests/markdown/test_entity_parser_error_handling.py` | `schema_to_markdown()` with `entity_metadata={}` emits no `metadata:` key |
|
||||
| `test_legacy_v1_list_projects_endpoint` | `tests/api/v2/test_project_router.py` | (pre-existing) GET `/projects/projects` legacy route |
|
||||
|
||||
**Suite totals after item 3: 1764 passed, 15 skipped, 0 failures.**
|
||||
|
||||
## Suggested Commands
|
||||
|
||||
- Full suite: `just check`
|
||||
- Fast loop: `just fast-check`
|
||||
- E2E consistency: `just doctor`
|
||||
- SQLite focused: `just test-sqlite`
|
||||
- Postgres focused: `just test-postgres`
|
||||
- Schema integration: `pytest test-int/test_schema -q`
|
||||
- Semantic + repo focus: `pytest tests/repository/test_postgres_search_repository.py tests/mcp/test_tool_search.py tests/services/test_semantic_search.py -q`
|
||||
- MCP integration focus: `pytest test-int/mcp -q`
|
||||
|
||||
## Exit Criteria for This Plan
|
||||
|
||||
- All feature acceptance criteria above are validated.
|
||||
- All identified high-priority coverage gaps are addressed or explicitly documented as intentional.
|
||||
- Manual MCP verification scenarios complete with no P0/P1 findings.
|
||||
@@ -0,0 +1,318 @@
|
||||
# v0.19.0 Release Notes
|
||||
|
||||
## Overview
|
||||
|
||||
v0.19.0 is a major release that introduces semantic vector search, a schema validation system,
|
||||
project-prefixed permalinks, per-project cloud routing, and a significant upgrade to FastMCP 3.0.
|
||||
It includes 90+ commits since v0.18.0 spanning new features, architectural improvements, and
|
||||
stability fixes across both SQLite and Postgres backends.
|
||||
|
||||
---
|
||||
|
||||
## Major Features
|
||||
|
||||
### Semantic Vector Search
|
||||
|
||||
Full vector and hybrid search for SQLite (via sqlite-vec) and Postgres (via pgvector).
|
||||
|
||||
- **Hybrid search mode** combines full-text search (FTS) with vector similarity for best results
|
||||
- **Score-based fusion** replaces RRF for hybrid ranking — `max(vec, fts) + 0.3 * min(vec, fts)` preserves dominant signals and rewards dual-source agreement (#577)
|
||||
- **Default search mode** is now `hybrid` when semantic search is enabled, `text` when disabled
|
||||
- Embedding providers: FastEmbed (local, default) or OpenAI API
|
||||
- Configurable similarity threshold via `semantic_min_similarity` (default 0.55)
|
||||
- Per-query `min_similarity` override on `search_notes` tool
|
||||
- Auto-backfill: existing entities get embeddings generated on first startup
|
||||
- Backend-specific distance-to-similarity conversion (cosine for SQLite, inner product for Postgres)
|
||||
- FTS fallback: if semantic dependencies are missing, search gracefully degrades to text-only
|
||||
- sqlite-vec knn `k` parameter capped at 4096 to prevent backend errors
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"semantic_search_enabled": true,
|
||||
"semantic_embedding_provider": "fastembed",
|
||||
"semantic_embedding_model": "bge-small-en-v1.5",
|
||||
"semantic_min_similarity": 0.55
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
search_notes("machine learning concepts", search_type="hybrid")
|
||||
search_notes("similar to my notes on coffee", search_type="vector")
|
||||
search_notes("exact phrase match", search_type="text")
|
||||
search_notes("broad search", min_similarity=0.3) # lower threshold for more results
|
||||
```
|
||||
|
||||
### Schema System
|
||||
|
||||
Validate note structure against user-defined schemas with frontmatter-based rules.
|
||||
|
||||
- Define schemas as YAML in note frontmatter with field types, required fields, and constraints
|
||||
- Frontmatter validation during sync — malformed notes get clear error messages
|
||||
- Schema inference from existing notes to bootstrap schemas from your content
|
||||
- Schema diff to compare two schemas and see changes
|
||||
- Available via MCP tools and CLI
|
||||
|
||||
### Project-Prefixed Permalinks
|
||||
|
||||
Permalinks now include the project name for unambiguous cross-project references.
|
||||
|
||||
- Memory URLs like `memory://project-name/folder/note` route to the correct project
|
||||
- Existing non-prefixed permalinks continue to work (backwards compatible)
|
||||
- Controlled by `permalinks_include_project` config (default: true)
|
||||
- `build_context` and `search_notes` auto-detect project from URL prefix
|
||||
|
||||
### Per-Project Cloud Routing
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local.
|
||||
|
||||
- Set a project to cloud mode: `bm project set-cloud research`
|
||||
- Revert to local: `bm project set-local research`
|
||||
- Uses API key authentication: `bm cloud set-key bmc_abc123...`
|
||||
- MCP tools automatically route based on each project's mode
|
||||
- Local MCP server (`bm mcp`) still uses local routing for all projects by default
|
||||
- `--local` and `--cloud` CLI flags override per-command
|
||||
|
||||
### Workspace Selection
|
||||
|
||||
Cloud projects can target specific workspaces for multi-tenant environments.
|
||||
|
||||
- `workspace` parameter on MCP tools for explicit workspace targeting
|
||||
- CLI workspace-aware project listing with `bm project list`
|
||||
- Spinner feedback while fetching cloud projects
|
||||
|
||||
---
|
||||
|
||||
## New Tools and Capabilities
|
||||
|
||||
### Dashboard (`bm project info`)
|
||||
|
||||
`bm project info` now displays an htop-inspired compact dashboard with:
|
||||
|
||||
- Horizontal bar charts for note types (top 5)
|
||||
- Embedding coverage bar with Unicode block characters
|
||||
- Colored status dots for at-a-glance health
|
||||
- `EmbeddingStatus` schema and `get_embedding_status()` service method for programmatic access
|
||||
|
||||
### Unified Metadata Search
|
||||
|
||||
`search_by_metadata` has been merged into `search_notes` — one tool for all searches.
|
||||
`query` is now optional, so you can search purely by frontmatter metadata.
|
||||
|
||||
```
|
||||
search_notes(metadata_filters={"status": "in-progress"})
|
||||
search_notes(metadata_filters={"tags": ["security", "oauth"]})
|
||||
search_notes(metadata_filters={"priority": {"$in": ["high", "critical"]}})
|
||||
search_notes(metadata_filters={"schema.confidence": {"$gt": 0.7}})
|
||||
search_notes(tags=["security"]) # convenience shorthand
|
||||
search_notes(status="draft") # convenience shorthand
|
||||
```
|
||||
|
||||
### JSON Output Mode
|
||||
|
||||
All MCP tools now support `output_format="json"` for machine-readable responses.
|
||||
|
||||
- Default remains `"text"` for human-readable output (no breaking changes)
|
||||
- `build_context` defaults to `"json"` with slimmed payloads (redundant fields stripped)
|
||||
- CLI tool commands support `--format json` flag
|
||||
|
||||
### `tag:` Search Shorthand
|
||||
|
||||
Search by tag using convenient shorthand syntax.
|
||||
|
||||
```
|
||||
search_notes("tag:security")
|
||||
search_notes("tag:coffee AND tag:brewing")
|
||||
```
|
||||
|
||||
### Entity User Tracking
|
||||
|
||||
Entities now track `created_by` and `last_updated_by` fields for attribution.
|
||||
|
||||
### Improved Search Result Content (#609)
|
||||
|
||||
Search results now surface more relevant context:
|
||||
|
||||
- `matched_chunk_text` populated for FTS-only hybrid results (no more fallback to truncated content)
|
||||
- `TOP_CHUNKS_PER_RESULT` increased from 3 to 5, catching answers deeper in large notes (~2700 → ~4500 chars)
|
||||
- `CONTENT_DISPLAY_LIMIT` doubled from 2000 to 4000 chars for results without matched chunks
|
||||
|
||||
### `write_note` Overwrite Guard (#632)
|
||||
|
||||
`write_note` is now non-idempotent by default. If a note already exists, the tool returns an
|
||||
error instead of silently overwriting. Pass `overwrite=True` to replace, or use `edit_note`
|
||||
for incremental updates. Config option `write_note_overwrite_default` restores the old upsert
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### Score-Based Hybrid Fusion (#577)
|
||||
|
||||
RRF (Reciprocal Rank Fusion) compressed all fused scores to ~0.016, destroying ranking
|
||||
differentiation. The new formula `max(vec, fts) + FUSION_BONUS * min(vec, fts)` preserves
|
||||
dominant signals and rewards dual-source agreement. Zero-score results now produce zero
|
||||
fused score instead of receiving a 0.1 weight floor.
|
||||
|
||||
### FastMCP 3.0 Upgrade
|
||||
|
||||
Upgraded from FastMCP 2.12.3 to 3.0.1.
|
||||
|
||||
- Tool annotations (`readOnlyHint`, `openWorldHint`) for better client integration
|
||||
- Improved MCP protocol compliance
|
||||
- Better error handling and context management
|
||||
|
||||
### Prompts Call MCP Tools Directly
|
||||
|
||||
MCP prompts (`search`, `continue_conversation`) now call MCP tools directly instead of
|
||||
going through API endpoints. This fixes empty results in discovery mode and ensures prompts
|
||||
use the same resolution logic as tools (including LinkResolver fallback).
|
||||
|
||||
### build_context LinkResolver Fallback
|
||||
|
||||
`build_context` now falls back to LinkResolver when an exact permalink lookup returns empty.
|
||||
This uses the same 7-strategy resolution pipeline as `read_note`, so callers no longer get
|
||||
empty results for valid note identifiers that don't match exact permalinks.
|
||||
|
||||
### Sync Handles Semantic Dependency Errors Gracefully
|
||||
|
||||
When sqlite-vec or another embedding provider is unavailable, `sync_file` now catches
|
||||
`SemanticDependenciesMissingError` separately. The entity is created and FTS-indexed
|
||||
successfully — only vector embeddings are skipped, with a clear warning:
|
||||
|
||||
```
|
||||
WARNING: Semantic search dependencies missing — vector embeddings skipped for path=note.md.
|
||||
Run 'bm reindex --embeddings' after resolving the dependency issue.
|
||||
```
|
||||
|
||||
### Unified Project Path
|
||||
|
||||
Cloud projects with bisync now store the local filesystem path in `path` (not the Docker
|
||||
container path). Config migration automatically promotes `local_sync_path` → `path` for
|
||||
existing configs.
|
||||
|
||||
---
|
||||
|
||||
## CLI Improvements
|
||||
|
||||
### Status and Doctor Default to Local Routing
|
||||
|
||||
`bm status` and `bm doctor` now default to local routing since they scan the local filesystem.
|
||||
Previously, cloud-mode projects would route these commands to the cloud API, which returned
|
||||
Docker-internal paths that don't exist locally.
|
||||
|
||||
### `--format json` for CLI Tool Commands
|
||||
|
||||
All `bm tool` subcommands support `--format json` for machine-readable output, enabling
|
||||
integration with scripts and plugins.
|
||||
|
||||
### `--json` for Top-Level CLI Commands
|
||||
|
||||
Five additional CLI commands now support `--json` for machine-readable output:
|
||||
|
||||
- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files
|
||||
- `bm project list --json` — structured project list with name, paths, routing mode, and defaults
|
||||
- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors
|
||||
- `bm schema infer --json` — field frequency analysis and suggested schema definition
|
||||
- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes
|
||||
|
||||
This complements the existing `bm project info --json` and `bm tool --format json` support,
|
||||
making all major CLI commands scriptable for CI pipelines and automation.
|
||||
|
||||
### Cloud Promo and Analytics
|
||||
|
||||
- Cloud promo panel shown on first run or version bump with OSS discount code
|
||||
- Anonymous usage telemetry via Umami Cloud (promo/login funnel events only)
|
||||
- Opt out with `BASIC_MEMORY_NO_PROMOS=1`
|
||||
- No PII, no file contents, no per-command tracking
|
||||
- See [Telemetry](https://github.com/basicmachines-co/basic-memory#telemetry) in README
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **#577**: RRF fusion compressed all hybrid scores to ~0.016, destroying ranking differentiation
|
||||
- **#582**: build_context returns empty results on valid note identifiers
|
||||
- **#575**: Remove hardcoded "main" default from default_project
|
||||
- **#595**: recent_activity dedup and pagination across MCP tools
|
||||
- **#593**: Backend-specific distance-to-similarity conversion
|
||||
- **#592**: Strip NUL bytes from content before PostgreSQL search indexing
|
||||
- **#562**: Use VIRTUAL instead of STORED columns in SQLite migration
|
||||
- **#558**: Add X-Tigris-Consistent headers to all rclone commands
|
||||
- **#541**: Handle EntityCreationError as conflict
|
||||
- **#536**: Stabilize metadata filters on Postgres
|
||||
- **#533**: Fix recent_activity prompt defaults
|
||||
- **#530**: Prevent spurious `metadata: {}` in frontmatter output
|
||||
- **#601**: Return matched chunk text in search results
|
||||
- **#606**: Accept `null` for `expected_replacements` in `edit_note`
|
||||
- **#579, #607**: Guard against closed streams in promo panel and missing vector tables on shutdown
|
||||
- **#609**: FTS-only hybrid results missing `matched_chunk_text`; content limits too conservative
|
||||
- **#631**: `build_context` related_results schema validation failure — replaced fragile `_slim_context()` stripping with Pydantic `exclude=True` field config
|
||||
- **#630**: Skip workspace resolution when client factory is active — prevents 401 errors in cloud MCP server mode
|
||||
- **#30**: `tag:` prefix query fails with hybrid search — moved tag prefix parsing to MCP tool level so it works with all search modes
|
||||
- **#31**: `search_notes` returns cluttered observation/relation-level results — now defaults to entity-level results
|
||||
- **#28**: `schema_infer` and `schema_diff` return raw Pydantic models as "undefined" in LLM output — added markdown formatters
|
||||
- Fix `schema_validate` identifier resolution (now uses LinkResolver) and text rendering (markdown formatter)
|
||||
- **#634**: `schema_validate` and `schema_diff` use stale database metadata instead of reading schema definitions from file — now reads frontmatter directly from the file with fallback to database metadata
|
||||
- Fix `Post(**metadata)` crash when frontmatter contains `content` or `handler` keys
|
||||
- Fix list-valued frontmatter fields (`title`, `type`) crashing on `.strip()` — now coerced to strings
|
||||
- Cap sqlite-vec knn `k` parameter at 4096 to prevent backend errors
|
||||
- Parameterize SQL queries in search repository type filters
|
||||
- Double-default display in project list
|
||||
- `ensure_frontmatter_on_sync` default changed to `True`
|
||||
- Status/doctor commands fail with cloud-mode projects (Docker path error)
|
||||
- Prompts return "0 projects" in discovery mode
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Upgrade `cryptography` for CVE advisory
|
||||
- Upgrade `python-multipart` for security advisory
|
||||
|
||||
---
|
||||
|
||||
## Internal / Developer
|
||||
|
||||
- **#598**: Upgrade FastMCP 2.12.3 → 3.0.1 with tool annotations
|
||||
- **#594**: Add `ty` as supplemental type checker
|
||||
- **#538**: Add fast feedback loop tooling (`just fast-check`, `just doctor`, `just testmon`)
|
||||
- **#600**: Rename `entity_type` to `note_type` for consistency
|
||||
- **#596**: Fix CLI runtime defects and audit regressions
|
||||
- CLI refactoring and workspace-aware cloud project listing
|
||||
- Split and speed up PR test matrix in CI
|
||||
- Fix CI: collect coverage from test jobs instead of re-running all tests
|
||||
- Create `search_vector_chunks` in test fixtures for Postgres compatibility
|
||||
|
||||
---
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
| Setting | Old Default | New Default | Notes |
|
||||
|---------|-------------|-------------|-------|
|
||||
| `semantic_search_enabled` | `false` | `true` | Semantic search on by default |
|
||||
| `ensure_frontmatter_on_sync` | `false` | `true` | Frontmatter added during sync |
|
||||
| `permalinks_include_project` | `false` | `true` | Project prefix in permalinks |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- **Semantic search dependencies** are now included by default. If sqlite-vec fails to load,
|
||||
search gracefully falls back to FTS. Run `bm reindex --embeddings` to generate embeddings
|
||||
for existing content.
|
||||
- **Hybrid search scoring** has changed from RRF to score-based fusion. Search result ordering
|
||||
may differ — results should be more accurate with better score differentiation.
|
||||
- **`search_by_metadata`** is removed as a standalone tool. Use `search_notes` with
|
||||
`metadata_filters` instead (same parameters, same behavior).
|
||||
- **Project-prefixed permalinks** are enabled by default. Existing notes keep their current
|
||||
permalinks until modified. Set `permalinks_include_project: false` to disable.
|
||||
- **Frontmatter on sync** is now enabled by default. Files without frontmatter will have it
|
||||
added on next sync. Set `ensure_frontmatter_on_sync: false` to preserve old behavior.
|
||||
- **Config migration** runs automatically for cloud projects with bisync — `local_sync_path`
|
||||
is promoted to `path` so filesystem operations work correctly.
|
||||
- **`write_note` is no longer idempotent** — calls to `write_note` for existing notes now
|
||||
return an error unless `overwrite=True` is passed. Use `edit_note` for incremental changes,
|
||||
or set `write_note_overwrite_default: true` in config to restore the old behavior.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Semantic Search Manual Test Log
|
||||
|
||||
## Overview
|
||||
|
||||
Manual test session for semantic (vector) search on the main project.
|
||||
- Date: 2026-02-15
|
||||
- Database: ~/.basic-memory/memory.db (SQLite)
|
||||
- Entities: 456 embedded, 2714 vector chunks
|
||||
- Search index: 2390 FTS entries
|
||||
- Embedding model: default (384-dim, sqlite-vec)
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. **Search Type Routing** — verify vector/hybrid/text dispatch, invalid search_type handling
|
||||
2. **Conceptual Queries** — natural language where vector should beat FTS
|
||||
3. **Keyword Queries** — exact terms where FTS should be strong
|
||||
4. **Hybrid Ranking** — queries where both FTS and vector contribute
|
||||
5. **Result Types** — entities, observations, relations in vector results
|
||||
6. **Filters + Vector** — combine vector with types/entity_types/after_date
|
||||
7. **Edge Cases** — short queries, long queries, empty, special chars, no-match
|
||||
8. **Pagination** — page > 1, page_size respected
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
### Test 1: Search Type Routing
|
||||
|
||||
#### 1a: search_type="semantic" (invalid value)
|
||||
- **Input:** query="how does the knowledge graph work", search_type="semantic"
|
||||
- **Expected:** error or explicit fallback
|
||||
- **Actual:** Silently falls through to text search (else branch in search.py:430)
|
||||
- **Verdict:** BUG — should either be a recognized alias for "vector" or return an error
|
||||
|
||||
#### 1b: search_type="vector"
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Actual:** 5 results, scores ~0.58-0.59, found "Maintaining context across conversation boundaries" observation
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1c: search_type="text" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="text"
|
||||
- **Actual:** 0 results (no exact keyword match)
|
||||
- **Verdict:** PASS (expected — FTS requires token overlap)
|
||||
|
||||
#### 1d: search_type="hybrid" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="hybrid"
|
||||
- **Actual:** 5 results, same ranking as vector (FTS contributed nothing here)
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1e: search_type="text" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="text"
|
||||
- **Actual:** 3 results — AUTH.md Supabase OAuth, OAuth Rip-and-Replace, OAuth Integration Analysis
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1f: search_type="vector" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="vector"
|
||||
- **Actual:** Same top results as text (keyword-rich content also scores well in vector space)
|
||||
- **Verdict:** PASS
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Conceptual Queries (vector advantage)
|
||||
|
||||
#### 2a: Natural language question
|
||||
- **Input:** query="why do AI assistants forget things", search_type="vector"
|
||||
- **Actual:** 5 results — Manual Testing Session, "Balance security and usability" observation, "Tools should match thought patterns" observation. Scores ~0.56-0.57
|
||||
- **Vector advantage:** Found conceptually related content despite no exact keyword overlap
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 2b: Same query, text search
|
||||
- **Input:** query="why do AI assistants forget things", search_type="text"
|
||||
- **Actual:** 1 result — "What is Basic Memory?" (likely matched on "AI" token)
|
||||
- **Verdict:** PASS (demonstrates vector advantage — text barely matched)
|
||||
|
||||
#### 2c: Domain concept with no jargon
|
||||
- **Input:** query="pricing strategy for cloud product", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-16 MCP Cloud Service Consolidation, knowledge architecture observation, Visual Knowledge Spaces relation. Scores ~0.56-0.57
|
||||
- **Verdict:** PASS (found cloud-related content conceptually)
|
||||
|
||||
#### 2d: Technical concept, long query
|
||||
- **Input:** query="SQLite performance optimization WAL mode concurrent writes", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-11 API Performance Optimization, Real-Time Updates with WebSockets, marketing status update. Scores ~0.55-0.58
|
||||
- **Verdict:** PASS (found performance-related content)
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Keyword Queries (FTS strength)
|
||||
|
||||
#### 3a: Exact term match — "OAuth authentication"
|
||||
- **Text:** 3 results with high relevance (exact matches in titles)
|
||||
- **Vector:** Same top results (keyword overlap helps vector too)
|
||||
- **Verdict:** PASS — FTS and vector converge on keyword-rich queries
|
||||
|
||||
#### 3b: "OAuth" single keyword, hybrid mode
|
||||
- **Input:** query="OAuth", search_type="hybrid"
|
||||
- **Actual:** 5 results — Basic Memory Coding Guide, AI Collaboration Examples, SPEC-18, daily note, Manual Testing Session. FTS + vector blended. Scores ~0.016-0.032
|
||||
- **Note:** Top hybrid result is "Basic Memory Coding Guide" not an OAuth-specific doc — suggests hybrid scoring may dilute strong FTS matches
|
||||
- **Verdict:** PASS but hybrid ranking questionable for single-keyword queries
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Hybrid Ranking
|
||||
|
||||
#### 4a: Hybrid vs vector on "OAuth authentication"
|
||||
- **Hybrid with entity_types=["entity"]:** 5 results — RLS Implementation Lessons, Cloud Readiness Assessment, AUTH.md OAuth, Core Service Implementation, OAuth Rip-and-Replace. Scores ~0.016-0.023
|
||||
- **Vector with entity_types=["entity"]:** 5 results — Core Service Implementation, SPEC-13 CLI Auth, Coding Guide, Authentication Service, ADR Production Auth. Scores ~0.55-0.60
|
||||
- **Observation:** Hybrid surfaces different top results than vector-only. Hybrid found RLS and Cloud Readiness docs that vector didn't prioritize. Different ranking is expected from RRF fusion.
|
||||
- **Verdict:** PASS — hybrid produces meaningfully different ranking
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Result Types
|
||||
|
||||
#### 5a: Vector returns all result types
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Entities:** SPEC-18 AI Memory Management Tool (type=entity)
|
||||
- **Relations:** Prompt Builder integrates_with (type=relation)
|
||||
- **Observations:** "Translation layer is key" (type=observation), "Maintaining context across conversation boundaries" (type=observation)
|
||||
- **Verdict:** PASS — all three types appear in vector results
|
||||
|
||||
#### 5b: Observations carry metadata
|
||||
- **Observation result:** category="challenge", content="Maintaining context across conversation boundaries", from_entity="research/ai-knowledge-management-research"
|
||||
- **Verdict:** PASS — category, content, from_entity, tags all present
|
||||
|
||||
#### 5c: Relations carry link info
|
||||
- **Relation result:** relation_type="integrates_with", from_entity="development/features/prompt-builder...", to_entity (present but truncated in some)
|
||||
- **Verdict:** PASS — relation metadata present
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Filters + Vector Search
|
||||
|
||||
#### 6a: entity_types=["entity"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" (Core Service Implementation, SPEC-13, Coding Guide, Authentication Service, ADR Auth)
|
||||
- **Verdict:** PASS — filter correctly restricts to entities only
|
||||
|
||||
#### 6b: types=["note"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["note"]
|
||||
- **Actual:** Same 5 results (all have entity_type="note" in metadata)
|
||||
- **Verdict:** PASS — types filter works with vector search
|
||||
|
||||
#### 6c: after_date with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", after_date="2025-06-01"
|
||||
- **Actual:** 3 results — Core Service Implementation, Cloud Web App analysis observation, SPEC-13. Filtered out older OAuth docs.
|
||||
- **Verdict:** PASS — date filter applied correctly
|
||||
|
||||
#### 6d: entity_types=["entity"] with hybrid
|
||||
- **Input:** query="OAuth authentication", search_type="hybrid", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" — RLS lessons, Cloud Readiness, AUTH.md OAuth, Core Service, OAuth Rip-and-Replace
|
||||
- **Verdict:** PASS — filter works with hybrid mode too
|
||||
|
||||
#### 6e: types=["entity"] with vector (WRONG filter name)
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["entity"]
|
||||
- **Actual:** 0 results
|
||||
- **Note:** `types` filters by entity_type metadata (e.g., "note", "person"), NOT by SearchItemType. Using types=["entity"] looks for entity_type="entity" which few/no notes have. This is a UX confusion point — the param names are ambiguous.
|
||||
- **Verdict:** PASS (correct behavior) but USABILITY ISSUE — easy to confuse types vs entity_types
|
||||
|
||||
---
|
||||
|
||||
### Test 7: Edge Cases
|
||||
|
||||
#### 7a: Single character query
|
||||
- **Input:** query="x", search_type="vector"
|
||||
- **Actual:** 3 results — "Self-contained application bundle" observation, Non-Markdown File Support relation, quick-win-tools entity. Scores ~0.57-0.59
|
||||
- **Note:** Single character still produces an embedding and returns results. Quality is low/random as expected.
|
||||
- **Verdict:** PASS (no crash, returns results)
|
||||
|
||||
#### 7b: Whitespace-only query
|
||||
- **Input:** query=" ", search_type="vector"
|
||||
- **Actual:** 0 results
|
||||
- **Verdict:** PASS (handled gracefully — _check_vector_eligible strips and rejects empty)
|
||||
|
||||
#### 7c: Query with no relevant content
|
||||
- **Input:** query="quantum computing blockchain", search_type="vector"
|
||||
- **Actual:** 3 results — Inter-Agent Communication relation, Self-contained bundle observation, JSON-LD interop observation. Scores ~0.54
|
||||
- **Note:** Still returns results because vector search always finds nearest neighbors. Scores are lower (~0.54) than relevant queries (~0.58-0.60). No relevance threshold applied.
|
||||
- **Verdict:** PASS (expected behavior) but NOTE — no relevance cutoff means irrelevant queries always return something
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Pagination
|
||||
|
||||
#### 8a: Vector search page 2
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector", page=2, page_size=3
|
||||
- **Actual:** 3 results on page 2, current_page=2. Different results from page 1. Top: "Maintaining context across conversation boundaries" observation (score 0.587)
|
||||
- **Note:** Interestingly, page 2 had a higher-scoring result than some page 1 results. This may indicate pagination doesn't sort globally — it might be paginating within a pre-scored set.
|
||||
- **Verdict:** PASS (pagination works) but POSSIBLE ISSUE — result ordering across pages needs investigation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Passing Tests: 20/21
|
||||
|
||||
### Bugs Found
|
||||
1. **search_type="semantic" silently falls through** (Test 1a) — Invalid search_type values fall to the `else` branch and default to text search without any warning. Should either alias "semantic" to "vector" or raise an error.
|
||||
|
||||
### Usability Issues
|
||||
2. **types vs entity_types confusion** (Test 6e) — `types` filters by entity_type metadata (note, person, etc.) while `entity_types` filters by SearchItemType (entity, observation, relation). The naming is ambiguous and easy to mix up.
|
||||
3. **No relevance threshold** (Test 7c) — Vector search always returns nearest neighbors even for completely irrelevant queries. Consider adding a minimum score threshold or at least documenting expected score ranges.
|
||||
4. **Hybrid ranking for single keywords** (Test 3b) — Hybrid mode on simple keyword queries produced less intuitive rankings than pure FTS or pure vector. The RRF fusion may dilute strong FTS signals.
|
||||
|
||||
### Observations
|
||||
- Vector search successfully finds conceptually related content that FTS misses entirely
|
||||
- Score ranges: relevant queries ~0.56-0.60, irrelevant queries ~0.54 (narrow spread)
|
||||
- All three result types (entity, observation, relation) appear correctly in vector results
|
||||
- Filters (entity_types, types, after_date) all work correctly with vector and hybrid modes
|
||||
- Pagination works but cross-page ordering may need investigation
|
||||
@@ -0,0 +1,270 @@
|
||||
# Semantic Search
|
||||
|
||||
This guide covers Basic Memory's semantic (vector) search feature, which adds meaning-based retrieval alongside the existing full-text search.
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory's search supports both full-text search (FTS) and semantic retrieval. Semantic search adds vector embeddings that capture the *meaning* of your content, enabling:
|
||||
|
||||
- **Paraphrase matching**: Find "authentication flow" when searching for "login process"
|
||||
- **Conceptual queries**: Search for "ways to improve performance" and find notes about caching, indexing, and optimization
|
||||
- **Hybrid retrieval**: Combine the precision of keyword search with the recall of semantic similarity
|
||||
|
||||
Semantic search is enabled by default when semantic dependencies are available at runtime. It works on both SQLite (local) and Postgres (cloud) backends.
|
||||
|
||||
## Installation
|
||||
|
||||
Semantic search dependencies (fastembed, sqlite-vec, openai) are included in the default `basic-memory` install.
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
You can always override with `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true|false`.
|
||||
|
||||
### Platform Compatibility
|
||||
|
||||
| Platform | FastEmbed (local) | OpenAI (API) |
|
||||
|---|---|---|
|
||||
| macOS ARM64 (Apple Silicon) | Yes | Yes |
|
||||
| macOS x86_64 (Intel Mac) | No — see workaround below | Yes |
|
||||
| Linux x86_64 | Yes | Yes |
|
||||
| Linux ARM64 | Yes | Yes |
|
||||
| Windows x86_64 | Yes | Yes |
|
||||
|
||||
#### Intel Mac Workaround
|
||||
|
||||
The default install includes FastEmbed, which depends on ONNX Runtime. ONNX Runtime dropped Intel Mac (x86_64) wheels starting in v1.24, so install with a compatible ONNX Runtime pin first:
|
||||
|
||||
```bash
|
||||
pip install basic-memory 'onnxruntime<1.24'
|
||||
```
|
||||
|
||||
After installation, Intel Mac users have two runtime options:
|
||||
|
||||
**Option 1: Use OpenAI embeddings (recommended)**
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
**Option 2: Use FastEmbed locally**
|
||||
|
||||
Keep the same pinned installation and use FastEmbed (default provider):
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=fastembed
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install Basic Memory:
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
2. (Optional) Explicitly enable semantic search:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
3. Build vector embeddings for your existing content:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
4. Search using semantic modes:
|
||||
|
||||
```python
|
||||
# Pure vector similarity
|
||||
search_notes("login process", search_type="vector")
|
||||
|
||||
# Hybrid: combines FTS precision with vector recall (recommended)
|
||||
search_notes("login process", search_type="hybrid")
|
||||
|
||||
# Explicit full-text search
|
||||
search_notes("login process", search_type="text")
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
All settings are fields on `BasicMemoryConfig` and can be set via environment variables (prefixed with `BASIC_MEMORY_`).
|
||||
|
||||
| Config Field | Env Var | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto (`true` when semantic deps are available) | Enable semantic search. Required before vector/hybrid modes work. |
|
||||
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
|
||||
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
|
||||
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
|
||||
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
|
||||
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
|
||||
|
||||
## Embedding Providers
|
||||
|
||||
### FastEmbed (default)
|
||||
|
||||
FastEmbed runs entirely locally using ONNX models — no API key, no network calls, no cost.
|
||||
|
||||
- **Model**: `BAAI/bge-small-en-v1.5`
|
||||
- **Dimensions**: 384
|
||||
- **Tradeoff**: Smaller model, fast inference, good quality for most use cases
|
||||
|
||||
```bash
|
||||
# Install basic-memory and enable semantic search
|
||||
pip install basic-memory
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
Uses OpenAI's embeddings API for higher-dimensional vectors. Requires an API key.
|
||||
|
||||
- **Model**: `text-embedding-3-small`
|
||||
- **Dimensions**: 1536
|
||||
- **Tradeoff**: Higher quality embeddings, requires API calls and an OpenAI key
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
## Search Modes
|
||||
|
||||
### `text` (default)
|
||||
|
||||
Full-text keyword search using FTS5 (SQLite) or tsvector (Postgres). Supports boolean operators (`AND`, `OR`, `NOT`), phrase matching, and prefix wildcards.
|
||||
|
||||
```python
|
||||
search_notes("project AND planning", search_type="text")
|
||||
```
|
||||
|
||||
This is the existing default and does not require semantic search to be enabled.
|
||||
|
||||
### `vector`
|
||||
|
||||
Pure semantic similarity search. Embeds your query and finds the nearest content vectors. Good for conceptual or paraphrase queries where exact keywords may not appear in the content.
|
||||
|
||||
```python
|
||||
search_notes("how to speed up the app", search_type="vector")
|
||||
```
|
||||
|
||||
Returns results ranked by cosine similarity. Individual observations and relations surface as first-class results, not collapsed into parent entities.
|
||||
|
||||
### `hybrid`
|
||||
|
||||
Combines FTS and vector results using score-based fusion. This is generally the best mode when you want both keyword precision and semantic recall.
|
||||
|
||||
```python
|
||||
search_notes("authentication security", search_type="hybrid")
|
||||
```
|
||||
|
||||
Score-based fusion uses the formula `max(vec, fts) + bonus * min(vec, fts)` to preserve the dominant signal while rewarding results found by both methods.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Mode | Best For |
|
||||
|---|---|
|
||||
| `text` | Exact keyword matching, boolean queries, tag/category searches |
|
||||
| `vector` | Conceptual queries, paraphrase matching, exploratory searches |
|
||||
| `hybrid` | General-purpose search combining precision and recall |
|
||||
|
||||
## The Reindex Command
|
||||
|
||||
The `bm reindex` command rebuilds search indexes without dropping the database.
|
||||
|
||||
```bash
|
||||
# Rebuild everything (FTS + embeddings if semantic is enabled)
|
||||
bm reindex
|
||||
|
||||
# Only rebuild vector embeddings
|
||||
bm reindex --embeddings
|
||||
|
||||
# Only rebuild the full-text search index
|
||||
bm reindex --search
|
||||
|
||||
# Target a specific project
|
||||
bm reindex -p my-project
|
||||
```
|
||||
|
||||
### When You Need to Reindex
|
||||
|
||||
- **Upgrade note**: Migration now performs a one-time automatic embedding backfill on upgrade.
|
||||
- **Manual enable case**: If you explicitly had `semantic_search_enabled=false` and then turn it on
|
||||
- **Provider change**: After switching between `fastembed` and `openai`
|
||||
- **Model change**: After changing `semantic_embedding_model`
|
||||
- **Dimension change**: After changing `semantic_embedding_dimensions`
|
||||
|
||||
The reindex command shows progress with embedded/skipped/error counts:
|
||||
|
||||
```
|
||||
Project: main
|
||||
Building vector embeddings...
|
||||
✓ Embeddings complete: 142 entities embedded, 0 skipped, 0 errors
|
||||
|
||||
Reindex complete!
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Chunking
|
||||
|
||||
Each entity in the search index is split into semantic chunks before embedding:
|
||||
|
||||
- **Headers**: Markdown headers (`#`, `##`, etc.) start new chunks
|
||||
- **Bullets**: Each bullet item (`-`, `*`) becomes its own chunk for granular fact retrieval
|
||||
- **Prose sections**: Non-bullet text is merged up to ~900 characters per chunk
|
||||
- **Long sections**: Oversized content is split with ~120 character overlap to preserve context at boundaries
|
||||
|
||||
Each search index item type (entity, observation, relation) is chunked independently, so observations and relations are embeddable as discrete facts.
|
||||
|
||||
### Deduplication
|
||||
|
||||
Each chunk has a `source_hash` (SHA-256 of the chunk text). On re-sync, unchanged chunks skip re-embedding entirely. This makes incremental updates fast — only modified content triggers API calls or model inference.
|
||||
|
||||
### Hybrid Fusion
|
||||
|
||||
Hybrid search uses score-based fusion to merge FTS and vector results:
|
||||
|
||||
1. Run FTS search to get keyword-ranked results; normalize scores to [0, 1]
|
||||
2. Run vector search to get similarity-ranked results (already [0, 1])
|
||||
3. For each result, compute: `fused = max(vec_score, fts_score) + 0.3 * min(vec_score, fts_score)`
|
||||
4. Sort by fused score
|
||||
|
||||
The dominant signal (whichever source scored higher) is preserved, and dual-source agreement adds a bonus. Unlike rank-based fusion, this approach retains score magnitude — a strong vector match stays strong even without an FTS hit.
|
||||
|
||||
### Observation-Level Results
|
||||
|
||||
Vector and hybrid modes return individual observations and relations as first-class search results, not just parent entities. This means a search for "water temperature for brewing" can surface the specific observation about 205°F without returning the entire "Coffee Brewing Methods" entity.
|
||||
|
||||
## Database Backends
|
||||
|
||||
### SQLite (local)
|
||||
|
||||
- **Vector storage**: [sqlite-vec](https://github.com/asg017/sqlite-vec) virtual table
|
||||
- **Table creation**: At runtime when semantic search is first used — no migration needed
|
||||
- **Embedding table**: `search_vector_embeddings` using `vec0(embedding float[N])` where N is the configured dimensions
|
||||
- **Chunk metadata**: `search_vector_chunks` table stores chunk text, keys, and source hashes
|
||||
|
||||
The sqlite-vec extension is loaded per-connection. Vector tables are created lazily on first use.
|
||||
|
||||
### Postgres (cloud)
|
||||
|
||||
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
|
||||
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
|
||||
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
|
||||
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
|
||||
|
||||
The Alembic migration creates the dimension-independent chunks table. The embeddings table and HNSW index are deferred to runtime because they depend on the configured vector dimensions.
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPEC-LOCAL-PLUS-PUBLISH: Local+ Published Notes and Privacy Tiers
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-02-14
|
||||
**Owner:** Basic Memory
|
||||
|
||||
## Summary
|
||||
|
||||
Add a paid Local+ feature that lets users publish selected notes to shareable URLs while keeping the
|
||||
main knowledge base local-first. Use this as a product wedge for users who do not want full cloud
|
||||
hosting but do want collaboration and distribution features.
|
||||
|
||||
This spec also captures a practical position on "zero knowledge" for Local+.
|
||||
|
||||
## Context
|
||||
|
||||
Basic Memory already has strong local-first primitives and optional cloud routing/sync. A recurring
|
||||
request is:
|
||||
|
||||
- keep knowledge local by default,
|
||||
- pay for selective value-add,
|
||||
- share specific outputs externally.
|
||||
|
||||
Published Notes fits this model: explicit per-note opt-in, reversible, and easy to understand.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Provide an Obsidian Publish-style sharing experience for selected notes.
|
||||
2. Keep local markdown files as source of truth.
|
||||
3. Make sharing compatible with current cloud/auth/billing primitives.
|
||||
4. Define clear Local+ packaging that does not degrade OSS local workflows.
|
||||
5. Document zero-knowledge constraints so product decisions are explicit.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Full hosted editing for all notes (Cloud Full remains separate).
|
||||
2. Public website builder/CMS features.
|
||||
3. Strict cryptographic zero-knowledge server processing for MCP/search in v1.
|
||||
|
||||
## Local+ Feature Catalog (Sellable)
|
||||
|
||||
Core Local+ candidates:
|
||||
|
||||
1. Published Notes (share URL, revoke, expiry, password).
|
||||
2. Snapshot Time Machine (point-in-time restore for local projects).
|
||||
3. Recovery Drill Reports (automated restore verification).
|
||||
4. Device/API Key Governance (per-device keys, revocation, audit trail).
|
||||
5. BYO Storage Orchestration (managed setup for user-owned object storage).
|
||||
6. Semantic Boost Add-on (higher quality retrieval options while files remain source-of-truth).
|
||||
|
||||
Team-oriented add-ons:
|
||||
|
||||
1. Team-owned shared links and domain branding.
|
||||
2. Role-based publish permissions.
|
||||
3. Shared workspace policies for what can be published.
|
||||
|
||||
## Proposed MVP: Published Notes
|
||||
|
||||
### User Experience
|
||||
|
||||
Per note actions:
|
||||
|
||||
1. Publish.
|
||||
2. Unpublish.
|
||||
3. Copy URL.
|
||||
4. Regenerate URL.
|
||||
5. Set visibility and controls.
|
||||
|
||||
Controls:
|
||||
|
||||
1. Visibility: `unlisted` (default) or `public`.
|
||||
2. Optional password gate.
|
||||
3. Optional expiration datetime.
|
||||
4. Optional "disable indexing" flag for public mode.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Source note remains local markdown.
|
||||
2. Publish is explicit opt-in per note.
|
||||
3. Unpublish removes public access immediately.
|
||||
4. Republish creates a new URL token unless user chooses to keep current URL.
|
||||
|
||||
### URL Model
|
||||
|
||||
1. Unlisted share URL: high-entropy token path.
|
||||
2. Public URL: slug path (optional, later phase).
|
||||
3. Team plans can support custom domain mapping in later phase.
|
||||
|
||||
### Content Model
|
||||
|
||||
v1 published page includes:
|
||||
|
||||
1. Rendered markdown body.
|
||||
2. Optional metadata (title, updated_at).
|
||||
|
||||
v1 excludes:
|
||||
|
||||
1. Full graph traversal expansion.
|
||||
2. Related note auto-discovery on public pages.
|
||||
|
||||
### Sync Model
|
||||
|
||||
1. Local file remains canonical.
|
||||
2. Publish stores a rendered snapshot plus metadata in cloud.
|
||||
3. Update path:
|
||||
- manual "update published version", or
|
||||
- optional auto-update on note change (plan-gated).
|
||||
|
||||
## Architecture (v1)
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
1. Client selects a note to publish.
|
||||
2. Client sends publish request with note identifier and policy.
|
||||
3. Service resolves note content (local sync artifact or explicit upload payload).
|
||||
4. Service stores published artifact and returns share URL.
|
||||
|
||||
### Data Model
|
||||
|
||||
`published_notes`
|
||||
|
||||
1. `id` (uuid)
|
||||
2. `tenant_id` or `workspace_id`
|
||||
3. `project_id`
|
||||
4. `entity_permalink` (or stable external_id)
|
||||
5. `share_token` (hashed in DB)
|
||||
6. `visibility` (`unlisted`|`public`)
|
||||
7. `password_hash` (nullable)
|
||||
8. `expires_at` (nullable)
|
||||
9. `is_active`
|
||||
10. `published_content` (rendered snapshot or reference)
|
||||
11. `published_at`
|
||||
12. `updated_at`
|
||||
|
||||
### API Shape (Draft)
|
||||
|
||||
1. `POST /api/published-notes`
|
||||
2. `GET /api/published-notes`
|
||||
3. `GET /api/published-notes/{id}`
|
||||
4. `PATCH /api/published-notes/{id}`
|
||||
5. `DELETE /api/published-notes/{id}` (unpublish)
|
||||
6. `POST /api/published-notes/{id}/regenerate-url`
|
||||
7. `GET /p/{token}` (public resolver)
|
||||
|
||||
### CLI Shape (Draft)
|
||||
|
||||
1. `bm cloud publish <identifier>`
|
||||
2. `bm cloud publish list`
|
||||
3. `bm cloud publish update <id>`
|
||||
4. `bm cloud publish unpublish <id>`
|
||||
5. `bm cloud publish rotate-url <id>`
|
||||
|
||||
### Security
|
||||
|
||||
1. Default to unlisted URLs.
|
||||
2. Store only hashed share tokens.
|
||||
3. Passwords hashed server-side.
|
||||
4. Enforce expiration at request time.
|
||||
5. Log publish/unpublish/rotate events for auditability.
|
||||
|
||||
## Packaging and Pricing Direction
|
||||
|
||||
Suggested split:
|
||||
|
||||
1. OSS Local: no publish URLs.
|
||||
2. Local+ Solo: publish URLs + snapshots + recovery.
|
||||
3. Local+ Team: solo features + team governance and branding.
|
||||
4. Cloud Full: hosted app + full cloud workflows.
|
||||
|
||||
Key message:
|
||||
"Keep everything local. Publish only what you choose."
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Phase 1: Unlisted publish URLs + unpublish + regenerate URL.
|
||||
2. Phase 2: Password/expiry controls.
|
||||
3. Phase 3: Auto-update on note change and basic analytics.
|
||||
4. Phase 4: Team branding/domains/policies.
|
||||
|
||||
## Zero-Knowledge Position
|
||||
|
||||
### Strict Zero-Knowledge Definition
|
||||
|
||||
Strict zero-knowledge means the server cannot decrypt note content at all.
|
||||
|
||||
### Why This Conflicts with MCP and Search
|
||||
|
||||
If server cannot decrypt:
|
||||
|
||||
1. MCP tool execution against cloud content cannot read/write semantic content.
|
||||
2. Full-text search cannot index plaintext content.
|
||||
3. Semantic/vector search cannot generate or query embeddings on plaintext.
|
||||
4. Server-side relation resolution and context building become severely limited.
|
||||
|
||||
This matches earlier findings: strict zero-knowledge materially handicaps MCP-driven behavior and
|
||||
search quality.
|
||||
|
||||
### Viable Alternatives (Not Strict Zero-Knowledge)
|
||||
|
||||
1. Encryption at rest/in transit with server-side decrypt in trusted runtime.
|
||||
- Preserves MCP/search quality.
|
||||
- Not zero-knowledge cryptographically.
|
||||
|
||||
2. Client-side retrieval mode.
|
||||
- Keep MCP/search local; cloud is sync/share/backup relay.
|
||||
- Best for privacy-first users.
|
||||
- Requires local agent availability for advanced retrieval.
|
||||
|
||||
3. Limited encrypted indexing.
|
||||
- Blind indexes for exact keywords only.
|
||||
- No high-quality semantic search.
|
||||
- Usually poor UX for natural-language memory recall.
|
||||
|
||||
### Recommendation
|
||||
|
||||
For Local+:
|
||||
|
||||
1. Do not promise strict zero-knowledge for cloud MCP/search paths.
|
||||
2. Offer a privacy-first local mode where advanced retrieval stays local.
|
||||
3. Clearly label tradeoffs:
|
||||
- "Local private mode" (best privacy, best local retrieval).
|
||||
- "Cloud-assisted mode" (best cross-device/MCP consistency, trusted-runtime decrypt).
|
||||
|
||||
This keeps messaging honest and avoids repeating the known incompatibility.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# SPEC-SCHEMA-IMPL: Schema System Implementation Plan
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
**Depends on:** [SPEC-SCHEMA](SPEC-SCHEMA.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Implementation plan for the Basic Memory Schema System. The system is entirely programmatic —
|
||||
no LLM agent runtime or API key required. The LLM already in the user's session (Claude Code,
|
||||
Claude Desktop, etc.) provides the intelligence layer by reading schema notes via existing
|
||||
MCP tools.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Entry Points │
|
||||
│ CLI (bm schema ...) │ MCP (schema_validate) │
|
||||
└──────────┬────────────┴──────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Schema Service Layer │
|
||||
│ resolve_schema · validate · infer · diff │
|
||||
└──────────┬────────────────────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ Picoschema Parser │ │ Note/Entity Access │
|
||||
│ YAML → SchemaModel │ │ (existing repository) │
|
||||
└──────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
No new database tables. Schemas are notes with `type: schema` — they're already indexed.
|
||||
Validation reads observations and relations from existing data.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Picoschema Parser
|
||||
|
||||
**Location:** `src/basic_memory/schema/parser.py`
|
||||
|
||||
Parses Picoschema YAML into an internal representation.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaField:
|
||||
name: str
|
||||
type: str # string, integer, number, boolean, any, or EntityName
|
||||
required: bool # True unless field name ends with ?
|
||||
is_array: bool # True if (array) notation
|
||||
is_enum: bool # True if (enum) notation
|
||||
enum_values: list[str] # Populated for enums
|
||||
description: str | None # Text after comma
|
||||
is_entity_ref: bool # True if type is capitalized (entity reference)
|
||||
children: list[SchemaField] # For (object) types
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaDefinition:
|
||||
entity: str # The entity type this schema describes
|
||||
version: int # Schema version
|
||||
fields: list[SchemaField] # Parsed fields
|
||||
validation_mode: str # "warn" | "strict" | "off"
|
||||
frontmatter_fields: list[SchemaField] # From settings.frontmatter (default: [])
|
||||
|
||||
|
||||
def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
|
||||
"""Parse a Picoschema YAML dict into a list of SchemaField objects."""
|
||||
|
||||
|
||||
def parse_schema_note(frontmatter: dict) -> SchemaDefinition:
|
||||
"""Parse a full schema note's frontmatter into a SchemaDefinition."""
|
||||
```
|
||||
|
||||
**Input/Output:**
|
||||
```yaml
|
||||
# Input (YAML dict from frontmatter)
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
```
|
||||
|
||||
```python
|
||||
# Output
|
||||
[
|
||||
SchemaField(name="name", type="string", required=True, description="full name", ...),
|
||||
SchemaField(name="role", type="string", required=False, description="job title", ...),
|
||||
SchemaField(name="works_at", type="Organization", required=False, is_entity_ref=True, ...),
|
||||
SchemaField(name="expertise", type="string", required=False, is_array=True, ...),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Schema Resolver
|
||||
|
||||
**Location:** `src/basic_memory/schema/resolver.py`
|
||||
|
||||
Finds the applicable schema for a note using the resolution order.
|
||||
|
||||
```python
|
||||
async def resolve_schema(
|
||||
note_frontmatter: dict,
|
||||
search_fn: Callable, # injected search capability
|
||||
) -> SchemaDefinition | None:
|
||||
"""Resolve schema for a note.
|
||||
|
||||
Resolution order:
|
||||
1. Inline schema (frontmatter['schema'] is a dict)
|
||||
2. Explicit reference (frontmatter['schema'] is a string)
|
||||
3. Implicit by type (frontmatter['type'] → schema note with matching entity)
|
||||
4. No schema (returns None)
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Schema Validator
|
||||
|
||||
**Location:** `src/basic_memory/schema/validator.py`
|
||||
|
||||
Validates a note's observations and relations against a resolved schema.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldResult:
|
||||
field: SchemaField
|
||||
status: str # "present" | "missing" | "type_mismatch"
|
||||
values: list[str] # Matched observation values or relation targets
|
||||
message: str | None # Human-readable detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
note_identifier: str
|
||||
schema_entity: str
|
||||
passed: bool # True if no errors (warnings are OK)
|
||||
field_results: list[FieldResult]
|
||||
unmatched_observations: dict[str, int] # category → count
|
||||
unmatched_relations: list[str] # relation types not in schema
|
||||
warnings: list[str]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
async def validate_note(
|
||||
note: Note,
|
||||
schema: SchemaDefinition,
|
||||
frontmatter: dict | None = None,
|
||||
) -> ValidationResult:
|
||||
"""Validate a note against a schema definition.
|
||||
|
||||
Mapping rules:
|
||||
- field: string → observation [field] exists
|
||||
- field?(array): type → multiple [field] observations
|
||||
- field?: EntityType → relation 'field [[...]]' exists
|
||||
- field?(enum): [v] → observation [field] value ∈ enum values
|
||||
- settings.frontmatter field → frontmatter key presence/value
|
||||
"""
|
||||
```
|
||||
|
||||
### 4. Schema Inference Engine
|
||||
|
||||
**Location:** `src/basic_memory/schema/inference.py`
|
||||
|
||||
Analyzes notes of a given type and suggests a schema based on usage frequency.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldFrequency:
|
||||
name: str
|
||||
source: str # "observation" | "relation"
|
||||
count: int # notes containing this field
|
||||
total: int # total notes analyzed
|
||||
percentage: float
|
||||
sample_values: list[str] # representative values
|
||||
is_array: bool # True if typically appears multiple times per note
|
||||
target_type: str | None # For relations, the most common target entity type
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceResult:
|
||||
entity_type: str
|
||||
notes_analyzed: int
|
||||
field_frequencies: list[FieldFrequency]
|
||||
suggested_schema: dict # Ready-to-use Picoschema YAML dict
|
||||
suggested_required: list[str]
|
||||
suggested_optional: list[str]
|
||||
excluded: list[str] # Below threshold
|
||||
|
||||
|
||||
async def infer_schema(
|
||||
entity_type: str,
|
||||
notes: list[Note],
|
||||
required_threshold: float = 0.95, # 95%+ = required
|
||||
optional_threshold: float = 0.25, # 25%+ = optional
|
||||
) -> InferenceResult:
|
||||
"""Analyze notes and suggest a Picoschema definition."""
|
||||
```
|
||||
|
||||
### 5. Schema Diff
|
||||
|
||||
**Location:** `src/basic_memory/schema/diff.py`
|
||||
|
||||
Compares current note usage against an existing schema definition.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaDrift:
|
||||
new_fields: list[FieldFrequency] # Fields not in schema but common in notes
|
||||
dropped_fields: list[FieldFrequency] # Fields in schema but rare in notes
|
||||
cardinality_changes: list[str] # one → many or many → one
|
||||
type_mismatches: list[str] # observation values don't match declared type
|
||||
|
||||
|
||||
async def diff_schema(
|
||||
schema: SchemaDefinition,
|
||||
notes: list[Note],
|
||||
) -> SchemaDrift:
|
||||
"""Compare a schema against actual note usage to detect drift."""
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### CLI Commands
|
||||
|
||||
**Location:** `src/basic_memory/cli/schema.py`
|
||||
|
||||
```python
|
||||
import typer
|
||||
|
||||
schema_app = typer.Typer(name="schema", help="Schema management commands")
|
||||
|
||||
@schema_app.command()
|
||||
async def validate(
|
||||
target: str = typer.Argument(None, help="Note path or entity type"),
|
||||
strict: bool = typer.Option(False, help="Override to strict mode"),
|
||||
):
|
||||
"""Validate notes against their schemas."""
|
||||
|
||||
@schema_app.command()
|
||||
async def infer(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to analyze"),
|
||||
threshold: float = typer.Option(0.25, help="Minimum frequency for optional fields"),
|
||||
save: bool = typer.Option(False, help="Save to schema/ directory"),
|
||||
):
|
||||
"""Infer schema from existing notes of a type."""
|
||||
|
||||
@schema_app.command()
|
||||
async def diff(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to diff"),
|
||||
):
|
||||
"""Show drift between schema and actual usage."""
|
||||
```
|
||||
|
||||
Registered as subcommand: `bm schema validate`, `bm schema infer`, `bm schema diff`.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
**Location:** `src/basic_memory/mcp/tools/schema.py`
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Validate notes against their resolved schema."""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Analyze existing notes and suggest a schema definition."""
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Location:** `src/basic_memory/api/schema_router.py`
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/schema", tags=["schema"])
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_schema(...) -> ValidationReport: ...
|
||||
|
||||
@router.post("/infer")
|
||||
async def infer_schema(...) -> InferenceResult: ...
|
||||
|
||||
@router.get("/diff/{entity_type}")
|
||||
async def diff_schema(...) -> SchemaDrift: ...
|
||||
```
|
||||
|
||||
MCP tools call these endpoints via the typed client pattern (consistent with existing
|
||||
architecture).
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Parser + Resolver
|
||||
|
||||
Build the foundation — can parse Picoschema and find schemas for notes.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/parser.py` — Picoschema YAML → `SchemaDefinition`
|
||||
- `schema/resolver.py` — Resolution order (inline → explicit ref → implicit by type → none)
|
||||
- Unit tests for all Picoschema syntax variations
|
||||
- Unit tests for resolution order
|
||||
|
||||
**No external dependencies.** Pure Python parsing of YAML dicts. Can develop and test
|
||||
in isolation.
|
||||
|
||||
### Phase 2: Validator
|
||||
|
||||
Connect schemas to notes and produce validation results.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/validator.py` — Validate note observations/relations against schema fields
|
||||
- API endpoint: `POST /schema/validate`
|
||||
- MCP tool: `schema_validate`
|
||||
- CLI command: `bm schema validate`
|
||||
- Integration tests with real notes and schemas
|
||||
|
||||
**Depends on:** Phase 1 (parser + resolver)
|
||||
|
||||
### Phase 3: Inference
|
||||
|
||||
Analyze existing notes to suggest schemas.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/inference.py` — Frequency analysis across notes of a type
|
||||
- API endpoint: `POST /schema/infer`
|
||||
- MCP tool: `schema_infer`
|
||||
- CLI command: `bm schema infer`
|
||||
- Option to save inferred schema as a note via `write_note`
|
||||
|
||||
**Depends on:** Phase 1 (parser for output format)
|
||||
|
||||
### Phase 4: Diff
|
||||
|
||||
Compare schemas against current usage.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/diff.py` — Drift detection between schema and actual notes
|
||||
- API endpoint: `GET /schema/diff/{entity_type}`
|
||||
- CLI command: `bm schema diff`
|
||||
|
||||
**Depends on:** Phase 1 (parser), Phase 3 (inference, for frequency analysis)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** (`tests/schema/`): Parser edge cases, resolution logic, validation mapping,
|
||||
inference thresholds
|
||||
- **Integration tests** (`test-int/schema/`): End-to-end with real markdown files, schema notes
|
||||
on disk, CLI invocation
|
||||
- Coverage target: 100% (consistent with project standard)
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- No new database tables or migrations
|
||||
- No new markdown syntax (schemas validate existing observations/relations)
|
||||
- No LLM agent runtime or API key management
|
||||
- No hook integration (deferred)
|
||||
- No schema composition/inheritance (deferred)
|
||||
- No OWL/RDF export (deferred)
|
||||
- No built-in templates (deferred)
|
||||
@@ -0,0 +1,492 @@
|
||||
# SPEC-SCHEMA: Basic Memory Schema System
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
|
||||
## Summary
|
||||
|
||||
A schema system for Basic Memory that uses [Picoschema](https://genkit.dev/docs/dotprompt/)
|
||||
syntax in YAML frontmatter. Schemas validate notes against their existing observation/relation
|
||||
structure — no new data model, no migration, just a declarative lens over what's already there.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Schemas are just notes** — A schema is a note with `type: schema`, lives anywhere
|
||||
2. **Use prior art** — Picoschema syntax in YAML frontmatter, no custom notation
|
||||
3. **Validation maps to existing format** — Observations and relations, not a parallel data model
|
||||
4. **Validation is soft** — Warnings by default, not blocking errors
|
||||
5. **Inference over prescription** — Schemas describe reality, emerge from usage
|
||||
6. **No built-in agent** — Programmatic core; the LLM already in the session provides intelligence
|
||||
|
||||
## Picoschema Syntax
|
||||
|
||||
Picoschema is a compact schema notation from Google's Dotprompt that fits naturally in YAML
|
||||
frontmatter.
|
||||
|
||||
### Supported Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `string` | Text value |
|
||||
| `integer` | Whole number |
|
||||
| `number` | Decimal number |
|
||||
| `boolean` | True/false |
|
||||
| `any` | Any scalar type |
|
||||
| `EntityName` | Reference to another entity (capitalized = entity reference) |
|
||||
|
||||
### Syntax Rules
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
- `field: type` — required field
|
||||
- `field?: type` — optional field
|
||||
- `field(array): type` — array of values
|
||||
- `field?(enum): [values]` — enumeration
|
||||
- `field?(object):` — nested object with sub-fields
|
||||
- `, description` — description after comma
|
||||
- `EntityName` as type (capitalized) — reference to another entity
|
||||
|
||||
## Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against the existing Basic Memory note format. No new syntax for note
|
||||
authors to learn.
|
||||
|
||||
### Mapping Rules
|
||||
|
||||
| Schema Declaration | Grounded In | Example Match |
|
||||
|--------------------|-------------|---------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (×N) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (×N) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [values]` | Observation `[field] value` where value ∈ set | `- [status] active` |
|
||||
| `settings.frontmatter` field | Frontmatter key presence/value | `tags: [python, ai]` |
|
||||
|
||||
### Key Insight
|
||||
|
||||
Schemas don't introduce a new way to store data. They describe the patterns already present
|
||||
in observations and relations. A note doesn't have to change how it's written — the schema
|
||||
just says "a good Person note has a `[name]` observation and a `works_at` relation."
|
||||
|
||||
## Schema Definition
|
||||
|
||||
### As a Dedicated Schema Note
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
email?: string, contact email
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
settings:
|
||||
validation: warn # warn | strict | off
|
||||
frontmatter:
|
||||
tags?(array): string, note categories
|
||||
status?(enum): [draft, review, published]
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
|
||||
Any documentation about this entity type goes here as prose.
|
||||
```
|
||||
|
||||
Schema notes are regular Basic Memory notes. They show up in search, can have their own
|
||||
observations and relations, and can be organized in any folder (though `schema/` is
|
||||
the suggested convention).
|
||||
|
||||
### Inline Schema in a Note
|
||||
|
||||
Notes can carry their own schema directly:
|
||||
|
||||
```yaml
|
||||
# meetings/2024-01-15-standup.md
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
### Explicit Schema Reference
|
||||
|
||||
A note can reference a schema by entity name or permalink:
|
||||
|
||||
```yaml
|
||||
# projects/basic-memory.md
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject # by entity name
|
||||
---
|
||||
|
||||
# research/llm-memory-patterns.md
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project # by permalink
|
||||
---
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- Note's `type` differs from the schema it should validate against
|
||||
- Multiple schema variants exist for the same domain
|
||||
- Applying structure to existing notes without changing their type
|
||||
|
||||
## Schema Resolution
|
||||
|
||||
When validating a note, schemas resolve in priority order:
|
||||
|
||||
```
|
||||
1. Inline schema → schema: { ... } (dict in frontmatter)
|
||||
2. Explicit ref → schema: Person (string in frontmatter)
|
||||
3. Implicit by type → type: Person (lookup schema note with entity: Person)
|
||||
4. No schema → no validation (perfectly fine)
|
||||
```
|
||||
|
||||
```python
|
||||
async def resolve_schema(note: Note) -> Schema | None:
|
||||
schema_value = note.frontmatter.get('schema')
|
||||
|
||||
# 1. Inline schema (dict)
|
||||
if isinstance(schema_value, dict):
|
||||
return parse_picoschema(schema_value)
|
||||
|
||||
# 2. Explicit reference (string)
|
||||
if isinstance(schema_value, str):
|
||||
schema_note = await find_schema_note(schema_value)
|
||||
if schema_note:
|
||||
return parse_picoschema(schema_note.frontmatter['schema'])
|
||||
|
||||
# 3. Implicit by type
|
||||
note_type = note.frontmatter.get('type')
|
||||
if note_type:
|
||||
results = await search_notes(f"type:schema entity:{note_type}")
|
||||
if results:
|
||||
return parse_picoschema(results[0].frontmatter['schema'])
|
||||
|
||||
# 4. No schema
|
||||
return None
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Modes
|
||||
|
||||
Configured in the schema's `settings.validation`:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `off` | No validation |
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
|
||||
### Validation Output
|
||||
|
||||
For a note missing required fields:
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
They're valid. Schemas are a subset, not a straitjacket.
|
||||
|
||||
### Frontmatter Validation
|
||||
|
||||
Schema notes can declare validation rules for frontmatter keys under `settings.frontmatter`
|
||||
using the same Picoschema syntax as the `schema` block:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
validation: warn
|
||||
frontmatter:
|
||||
tags?(array): string
|
||||
status?(enum): [draft, review, published]
|
||||
```
|
||||
|
||||
- Frontmatter rules use the same Picoschema key syntax (`?` for optional, `(enum)`, `(array)`)
|
||||
- Only available on schema notes (inline schemas skip frontmatter validation)
|
||||
- Checks key presence (required vs optional) and enum value membership
|
||||
- Unmatched frontmatter keys not in the schema are silently ignored
|
||||
- Missing required frontmatter keys produce a warning (or error in strict mode)
|
||||
|
||||
Example output for a missing required frontmatter key:
|
||||
|
||||
```
|
||||
⚠ Person schema validation:
|
||||
- Missing required frontmatter key: status
|
||||
```
|
||||
|
||||
### Batch Validation
|
||||
|
||||
```
|
||||
$ bm schema validate Person
|
||||
|
||||
Validating 30 notes against Person schema...
|
||||
|
||||
✓ people/paul-graham.md — all fields present
|
||||
✓ people/rich-hickey.md — all fields present
|
||||
⚠ people/ada-lovelace.md — missing: name
|
||||
⚠ people/alan-kay.md — missing: name, role
|
||||
✓ people/linus-torvalds.md — all fields present
|
||||
...
|
||||
|
||||
Summary: 22/30 valid, 8 warnings, 0 errors
|
||||
```
|
||||
|
||||
## Emerging Schemas
|
||||
|
||||
### The Problem with Traditional Schemas
|
||||
|
||||
Most schema systems require: define schema → create conforming content → fight the schema
|
||||
when reality doesn't match. This is backwards. Knowledge grows organically.
|
||||
|
||||
### The Basic Memory Approach
|
||||
|
||||
```
|
||||
Write notes freely → Patterns emerge → Crystallize into schema → Validate future notes
|
||||
```
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[fact] 25/30 83% (generic — no single field)
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
[born] 6/30 20% (below threshold)
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
authored 11/30 37% → authored?(array): string
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- 100% present → required field
|
||||
- 25%+ present → optional field
|
||||
- Below 25% → excluded from suggestion (but noted)
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## LLM Integration (AI Guidance)
|
||||
|
||||
No agent runtime or API key required. The LLM already in the session uses schemas as
|
||||
context for note creation.
|
||||
|
||||
### Flow
|
||||
|
||||
1. User asks LLM to "write a note about Rich Hickey"
|
||||
2. LLM determines `type: Person` is appropriate
|
||||
3. LLM calls `search_notes("type:schema entity:Person")` → finds schema
|
||||
4. LLM reads schema fields: required `name`, optional `role`, `works_at`, `expertise`
|
||||
5. LLM calls `write_note` with observations and relations that satisfy the schema
|
||||
|
||||
The schema acts as a creation template. The LLM knows what a "complete" note looks like
|
||||
without any custom agent infrastructure.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> ValidationReport:
|
||||
"""Validate notes against their resolved schema.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
"""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> SuggestedSchema:
|
||||
"""Analyze existing notes and suggest a schema definition.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Validate a specific note
|
||||
bm schema validate people/ada-lovelace.md
|
||||
|
||||
# Validate all notes of a type
|
||||
bm schema validate Person
|
||||
|
||||
# Validate everything with a schema
|
||||
bm schema validate
|
||||
|
||||
# Infer schema from existing notes
|
||||
bm schema infer Person
|
||||
|
||||
# Show schema drift from current definition
|
||||
bm schema diff Person
|
||||
|
||||
# List all schema notes
|
||||
bm search "type:schema"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Person Workflow
|
||||
|
||||
**Schema:**
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
**Valid note:**
|
||||
```yaml
|
||||
# people/paul-graham.md
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
**Note with warnings:**
|
||||
```yaml
|
||||
# people/ada-lovelace.md
|
||||
---
|
||||
title: Ada Lovelace
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Ada Lovelace
|
||||
|
||||
## Observations
|
||||
- [fact] Wrote the first computer program
|
||||
- [born] 1815
|
||||
|
||||
## Relations
|
||||
- collaborated_with [[Charles Babbage]]
|
||||
```
|
||||
|
||||
Validation: warns about missing required `[name]` observation. Everything else is optional
|
||||
or unmatched (which is fine).
|
||||
|
||||
## Future Considerations (Deferred)
|
||||
|
||||
These are interesting but out of scope for the initial implementation:
|
||||
|
||||
- **Multiple schema inheritance** — `schema: [Person, Author]`
|
||||
- **Hook integration** — Pre-write validation via the hooks system
|
||||
- **OWL/RDF export** — `bm schema export --format owl`
|
||||
- **SPARQL queries** — Schema-aware graph queries
|
||||
- **Built-in templates** — `bm schema use gtd`, `bm schema use zettelkasten`
|
||||
- **Schema versioning/migration** — Tracking breaking changes across versions
|
||||
@@ -0,0 +1,28 @@
|
||||
## Coverage policy (practical 100%)
|
||||
|
||||
Basic Memory’s test suite intentionally mixes:
|
||||
- unit tests (fast, deterministic)
|
||||
- integration tests (real filesystem + real DB via `test-int/`)
|
||||
|
||||
To keep the default CI signal **stable and meaningful**, the default `pytest` coverage report targets **core library logic** and **excludes** a small set of modules that are either:
|
||||
- highly environment-dependent (OS/DB tuning)
|
||||
- inherently interactive (CLI)
|
||||
- background-task orchestration (watchers/sync runners)
|
||||
|
||||
### What's excluded (and why)
|
||||
|
||||
Coverage excludes are configured in `pyproject.toml` under `[tool.coverage.report].omit`.
|
||||
|
||||
Current exclusions include:
|
||||
- `src/basic_memory/cli/**`: interactive wrappers; behavior is validated via higher-level tests and smoke tests.
|
||||
- `src/basic_memory/db.py`: platform/backend tuning paths (SQLite/Postgres/Windows), covered by integration tests and targeted runs.
|
||||
- `src/basic_memory/services/initialization.py`: startup orchestration/background tasks; covered indirectly by app/MCP entrypoints.
|
||||
- `src/basic_memory/sync/sync_service.py`: heavy filesystem↔DB integration; validated in integration suite (not enforced in unit coverage).
|
||||
|
||||
### Recommended additional runs
|
||||
|
||||
If you want extra confidence locally/CI:
|
||||
- **Postgres backend**: run tests with `BASIC_MEMORY_TEST_POSTGRES=1`.
|
||||
- **Strict backend-complete coverage**: run coverage on SQLite + Postgres and combine the results (recommended).
|
||||
|
||||
|
||||
@@ -2,33 +2,182 @@
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
uv sync
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
# Run unit tests in parallel
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v -n auto
|
||||
# ==============================================================================
|
||||
# DATABASE BACKEND TESTING
|
||||
# ==============================================================================
|
||||
# Basic Memory supports dual database backends (SQLite and Postgres).
|
||||
# By default, tests run against SQLite (fast, no dependencies).
|
||||
# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers).
|
||||
#
|
||||
# Quick Start:
|
||||
# just test # Run all tests against SQLite (default)
|
||||
# just test-sqlite # Run all tests against SQLite
|
||||
# just test-postgres # Run all tests against Postgres (testcontainers)
|
||||
# just test-unit-sqlite # Run unit tests against SQLite
|
||||
# just test-unit-postgres # Run unit tests against Postgres
|
||||
# just test-int-sqlite # Run integration tests against SQLite
|
||||
# just test-int-postgres # Run integration tests against Postgres
|
||||
#
|
||||
# CI runs both in parallel for faster feedback.
|
||||
# ==============================================================================
|
||||
|
||||
# Run integration tests in parallel
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto test-int
|
||||
# Run all tests against SQLite and Postgres
|
||||
test: test-sqlite test-postgres
|
||||
|
||||
# Run all tests
|
||||
test: test-unit test-int
|
||||
# Run all tests against SQLite
|
||||
test-sqlite: test-unit-sqlite test-int-sqlite
|
||||
|
||||
# Run all tests against Postgres (uses testcontainers)
|
||||
test-postgres: test-unit-postgres test-int-postgres
|
||||
|
||||
# Run unit tests against SQLite
|
||||
test-unit-sqlite:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run unit tests against Postgres
|
||||
test-unit-postgres:
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests against SQLite (excludes semantic benchmarks — use just test-semantic)
|
||||
test-int-sqlite:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
|
||||
# Run integration tests against Postgres
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
# See: https://github.com/jlowin/fastmcp/issues/1311
|
||||
test-int-postgres:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Use gtimeout (macOS/Homebrew) or timeout (Linux)
|
||||
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
|
||||
if [[ -n "$TIMEOUT_CMD" ]]; then
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int' || test $? -eq 137
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int
|
||||
fi
|
||||
|
||||
# Run tests impacted by recent changes (requires pytest-testmon)
|
||||
testmon *args:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon --testmon-forceselect {{args}}
|
||||
|
||||
# Run MCP smoke test (fast end-to-end loop)
|
||||
test-smoke:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
|
||||
|
||||
# Fast local loop: lint, format, typecheck, impacted tests
|
||||
fast-check:
|
||||
just fix
|
||||
just format
|
||||
just typecheck
|
||||
just testmon
|
||||
just test-smoke
|
||||
|
||||
# Reset Postgres test database (drops and recreates schema)
|
||||
# Useful when Alembic migration state gets out of sync during development
|
||||
# Uses credentials from docker-compose-postgres.yml
|
||||
postgres-reset:
|
||||
docker exec basic-memory-postgres psql -U ${POSTGRES_USER:-basic_memory_user} -d ${POSTGRES_TEST_DB:-basic_memory_test} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
@echo "✅ Postgres test database reset"
|
||||
|
||||
# Run Alembic migrations manually against Postgres test database
|
||||
# Useful for debugging migration issues
|
||||
# Uses credentials from docker-compose-postgres.yml (can override with env vars)
|
||||
postgres-migrate:
|
||||
@cd src/basic_memory/alembic && \
|
||||
BASIC_MEMORY_DATABASE_BACKEND=postgres \
|
||||
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
|
||||
uv run alembic upgrade head
|
||||
@echo "✅ Migrations applied to Postgres test database"
|
||||
|
||||
# Run Windows-specific tests only (only works on Windows platform)
|
||||
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
|
||||
# Will be skipped automatically on non-Windows platforms
|
||||
test-windows:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
|
||||
# Run benchmark tests only (performance testing)
|
||||
# These are slow tests that measure sync performance with various file counts
|
||||
# Excluded from default test runs to keep CI fast
|
||||
test-benchmark:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
|
||||
# Run semantic search quality benchmarks (all combos)
|
||||
test-semantic:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic test-int/semantic/
|
||||
|
||||
# Run semantic benchmarks with JSON artifact output, then show report
|
||||
test-semantic-report:
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_BENCHMARK_OUTPUT=.benchmarks/semantic-quality.jsonl uv run pytest -p pytest_mock -v -s --no-cov -m semantic test-int/semantic/
|
||||
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl
|
||||
|
||||
# Run semantic benchmarks (Postgres combos only)
|
||||
test-semantic-postgres:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic -k postgres test-int/semantic/
|
||||
|
||||
# View semantic benchmark results (rich formatted table)
|
||||
# Usage: just semantic-report [--filter-combo sqlite] [--filter-suite paraphrase] [--sort-by avg_latency_ms]
|
||||
semantic-report *args:
|
||||
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl {{args}}
|
||||
|
||||
# Compare two search benchmark JSONL outputs
|
||||
# Usage:
|
||||
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl
|
||||
# just benchmark-compare .benchmarks/search-baseline.jsonl .benchmarks/search-candidate.jsonl --format markdown --show-missing
|
||||
benchmark-compare baseline candidate *args:
|
||||
uv run python test-int/compare_search_benchmarks.py "{{baseline}}" "{{candidate}}" --format table {{args}}
|
||||
|
||||
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
|
||||
# Use this before releasing to ensure everything works across all backends and platforms
|
||||
test-all:
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
uv run coverage erase
|
||||
|
||||
echo "🔎 Coverage (SQLite)..."
|
||||
BASIC_MEMORY_ENV=test uv run coverage run --source=basic_memory -m pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
echo "🔎 Coverage (Postgres via testcontainers)..."
|
||||
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
|
||||
# See: https://github.com/jlowin/fastmcp/issues/1311
|
||||
TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "")
|
||||
if [[ -n "$TIMEOUT_CMD" ]]; then
|
||||
$TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run coverage run --source=basic_memory -m pytest -p pytest_mock -v --no-cov -m postgres tests test-int' || test $? -eq 137
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run coverage run --source=basic_memory -m pytest -p pytest_mock -v --no-cov -m postgres tests test-int
|
||||
fi
|
||||
|
||||
echo "🧩 Combining coverage data..."
|
||||
uv run coverage combine
|
||||
uv run coverage report -m
|
||||
uv run coverage html
|
||||
echo "Coverage report generated in htmlcov/index.html"
|
||||
|
||||
# Lint and fix code (calls fix)
|
||||
lint: fix
|
||||
|
||||
# Lint and fix code
|
||||
fix:
|
||||
uv run ruff check --fix --unsafe-fixes src tests
|
||||
uv run ruff check --fix --unsafe-fixes src tests test-int
|
||||
|
||||
# Type check code
|
||||
# Type check code (pyright)
|
||||
typecheck:
|
||||
uv run pyright
|
||||
|
||||
# Type check code (ty)
|
||||
typecheck-ty:
|
||||
uv run ty check src/
|
||||
|
||||
# Clean build artifacts and cache files
|
||||
clean:
|
||||
find . -type f -name '*.pyc' -delete
|
||||
@@ -44,14 +193,63 @@ format:
|
||||
run-inspector:
|
||||
npx @modelcontextprotocol/inspector
|
||||
|
||||
# Build macOS installer
|
||||
installer-mac:
|
||||
cd installer && chmod +x make_icons.sh && ./make_icons.sh
|
||||
cd installer && uv run python setup.py bdist_mac
|
||||
# Run doctor checks in an isolated temp home/config
|
||||
doctor:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TMP_HOME=$(mktemp -d)
|
||||
TMP_CONFIG=$(mktemp -d)
|
||||
HOME="$TMP_HOME" \
|
||||
BASIC_MEMORY_ENV=test \
|
||||
BASIC_MEMORY_HOME="$TMP_HOME/basic-memory" \
|
||||
BASIC_MEMORY_CONFIG_DIR="$TMP_CONFIG" \
|
||||
./.venv/bin/python -m basic_memory.cli.main doctor --local
|
||||
|
||||
# Run an isolated Logfire smoke workflow for local trace inspection
|
||||
telemetry-smoke:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TMP_HOME=$(mktemp -d)
|
||||
TMP_CONFIG=$(mktemp -d)
|
||||
TMP_PROJECT=$(mktemp -d)
|
||||
export HOME="$TMP_HOME"
|
||||
export BASIC_MEMORY_ENV="${BASIC_MEMORY_ENV:-dev}"
|
||||
export BASIC_MEMORY_HOME="$TMP_PROJECT/home-root"
|
||||
export BASIC_MEMORY_CONFIG_DIR="$TMP_CONFIG"
|
||||
export BASIC_MEMORY_NO_PROMOS=1
|
||||
export BASIC_MEMORY_LOG_LEVEL="${BASIC_MEMORY_LOG_LEVEL:-INFO}"
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED="${BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED:-false}"
|
||||
export BASIC_MEMORY_LOGFIRE_ENABLED="${BASIC_MEMORY_LOGFIRE_ENABLED:-true}"
|
||||
export BASIC_MEMORY_LOGFIRE_ENVIRONMENT="${BASIC_MEMORY_LOGFIRE_ENVIRONMENT:-telemetry-smoke}"
|
||||
if [[ -z "${BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE:-}" ]]; then
|
||||
if [[ -n "${LOGFIRE_TOKEN:-}" ]]; then
|
||||
export BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=true
|
||||
else
|
||||
export BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=false
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$BASIC_MEMORY_HOME"
|
||||
echo "Telemetry smoke setup:"
|
||||
echo " logfire_enabled=$BASIC_MEMORY_LOGFIRE_ENABLED"
|
||||
echo " send_to_logfire=$BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE"
|
||||
echo " log_level=$BASIC_MEMORY_LOG_LEVEL"
|
||||
echo " semantic_search_enabled=$BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED"
|
||||
echo " logfire_environment=$BASIC_MEMORY_LOGFIRE_ENVIRONMENT"
|
||||
echo " project_path=$TMP_PROJECT"
|
||||
./.venv/bin/python -m basic_memory.cli.main project add telemetry-smoke "$TMP_PROJECT" --default --local
|
||||
./.venv/bin/python -m basic_memory.cli.main tool write-note --title "Telemetry Smoke" --folder notes --content "hello from smoke" --project telemetry-smoke --local
|
||||
./.venv/bin/python -m basic_memory.cli.main tool read-note notes/telemetry-smoke --project telemetry-smoke --local
|
||||
./.venv/bin/python -m basic_memory.cli.main tool edit-note notes/telemetry-smoke --operation append --content $'\n\nsmoke edit line' --project telemetry-smoke --local
|
||||
./.venv/bin/python -m basic_memory.cli.main tool build-context notes/telemetry-smoke --project telemetry-smoke --local --page-size 5 --max-related 5
|
||||
./.venv/bin/python -m basic_memory.cli.main tool search-notes telemetry --project telemetry-smoke --local
|
||||
./.venv/bin/python -m basic_memory.cli.main doctor --local
|
||||
echo ""
|
||||
echo "Telemetry smoke complete."
|
||||
echo "Search Logfire for:"
|
||||
echo " service_name: basic-memory-cli"
|
||||
echo " environment: $BASIC_MEMORY_LOGFIRE_ENVIRONMENT"
|
||||
echo " span names: mcp.tool.write_note, mcp.tool.read_note, mcp.tool.edit_note, mcp.tool.build_context, mcp.tool.search_notes, sync.project.run"
|
||||
|
||||
# Build Windows installer
|
||||
installer-win:
|
||||
cd installer && uv run python setup.py bdist_win32
|
||||
|
||||
# Update all dependencies to latest versions
|
||||
update-deps:
|
||||
@@ -60,6 +258,9 @@ update-deps:
|
||||
# Run all code quality checks and tests
|
||||
check: lint format typecheck test
|
||||
|
||||
# Run all code quality checks and all test suites, including semantic benchmarks
|
||||
check-all: lint format typecheck test test-semantic
|
||||
|
||||
# Generate Alembic migration with descriptive message
|
||||
migration message:
|
||||
cd src/basic_memory/alembic && alembic revision --autogenerate -m "{{message}}"
|
||||
@@ -99,16 +300,22 @@ release version:
|
||||
fi
|
||||
|
||||
# Run quality checks
|
||||
echo "🔍 Running quality checks..."
|
||||
just check
|
||||
echo "🔍 Running lint checks..."
|
||||
just lint
|
||||
just typecheck
|
||||
|
||||
# Update version in __init__.py
|
||||
echo "📝 Updating version in __init__.py..."
|
||||
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
|
||||
rm -f src/basic_memory/__init__.py.bak
|
||||
|
||||
|
||||
# Update version in server.json (MCP registry metadata)
|
||||
echo "📝 Updating version in server.json..."
|
||||
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
|
||||
rm -f server.json.bak
|
||||
|
||||
# Commit version update
|
||||
git add src/basic_memory/__init__.py
|
||||
git add src/basic_memory/__init__.py server.json
|
||||
git commit -m "chore: update version to $VERSION_NUM for {{version}} release"
|
||||
|
||||
# Create and push tag
|
||||
@@ -122,6 +329,12 @@ release version:
|
||||
echo "✅ Release {{version}} created successfully!"
|
||||
echo "📦 GitHub Actions will build and publish to PyPI"
|
||||
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
|
||||
echo ""
|
||||
echo "📝 REMINDER: Post-release tasks:"
|
||||
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
|
||||
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
|
||||
echo " 3. MCP Registry - Run: mcp-publisher publish"
|
||||
echo " See: .claude/commands/release/release.md for detailed instructions"
|
||||
|
||||
# Create a beta release (e.g., just beta v0.13.2b1)
|
||||
beta version:
|
||||
@@ -158,16 +371,22 @@ beta version:
|
||||
fi
|
||||
|
||||
# Run quality checks
|
||||
echo "🔍 Running quality checks..."
|
||||
just check
|
||||
echo "🔍 Running lint checks..."
|
||||
just lint
|
||||
just typecheck
|
||||
|
||||
# Update version in __init__.py
|
||||
echo "📝 Updating version in __init__.py..."
|
||||
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
|
||||
rm -f src/basic_memory/__init__.py.bak
|
||||
|
||||
|
||||
# Update version in server.json (MCP registry metadata)
|
||||
echo "📝 Updating version in server.json..."
|
||||
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
|
||||
rm -f server.json.bak
|
||||
|
||||
# Commit version update
|
||||
git add src/basic_memory/__init__.py
|
||||
git add src/basic_memory/__init__.py server.json
|
||||
git commit -m "chore: update version to $VERSION_NUM for {{version}} beta release"
|
||||
|
||||
# Create and push tag
|
||||
@@ -182,6 +401,11 @@ beta version:
|
||||
echo "📦 GitHub Actions will build and publish to PyPI as pre-release"
|
||||
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
|
||||
echo "📥 Install with: uv tool install basic-memory --pre"
|
||||
echo ""
|
||||
echo "📝 REMINDER: For stable releases, update documentation sites:"
|
||||
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
|
||||
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
|
||||
echo " See: .claude/commands/release/release.md for detailed instructions"
|
||||
|
||||
# List all available recipes
|
||||
default:
|
||||
|
||||
+17
-1
@@ -54,6 +54,22 @@ Or for a one-time sync:
|
||||
basic-memory sync
|
||||
```
|
||||
|
||||
### 4. Updating Basic Memory
|
||||
|
||||
Basic Memory supports automatic updates by default for `uv tool` and Homebrew installs.
|
||||
|
||||
For manual checks and upgrades:
|
||||
|
||||
```bash
|
||||
# Check now and install if supported
|
||||
bm update
|
||||
|
||||
# Check only, do not install
|
||||
bm update --check
|
||||
```
|
||||
|
||||
To disable automatic updates, set `"auto_update": false` in `~/.basic-memory/config.json`.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Custom Directory
|
||||
@@ -125,4 +141,4 @@ If you encounter issues:
|
||||
cat ~/.basic-memory/basic-memory.log
|
||||
```
|
||||
|
||||
For more detailed information, refer to the [full documentation](https://memory.basicmachines.co/).
|
||||
For more detailed information, refer to the [full documentation](https://docs.basicmemory.com/).
|
||||
|
||||
+48
-16
@@ -3,7 +3,7 @@ name = "basic-memory"
|
||||
dynamic = ["version"]
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
authors = [
|
||||
{ name = "Basic Machines", email = "hello@basic-machines.co" }
|
||||
@@ -14,9 +14,8 @@ dependencies = [
|
||||
"typer>=0.9.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.1.1",
|
||||
"pydantic[email,timezone]>=2.10.3",
|
||||
"icecream>=2.1.3",
|
||||
"mcp>=1.2.0",
|
||||
"pydantic[email,timezone]>=2.12.0",
|
||||
"mcp>=1.23.1",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"loguru>=0.7.3",
|
||||
"pyright>=1.1.390",
|
||||
@@ -30,14 +29,26 @@ dependencies = [
|
||||
"alembic>=1.14.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=2.10.2",
|
||||
"fastmcp>=3.0.1,<4",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
"aiofiles>=24.1.0", # Optional observability (disabled by default via config)
|
||||
"asyncpg>=0.30.0",
|
||||
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"psycopg==3.3.1",
|
||||
"mdformat>=0.7.22",
|
||||
"mdformat-gfm>=0.3.7",
|
||||
"mdformat-frontmatter>=2.0.8",
|
||||
"sniffio>=1.3.1",
|
||||
"anyio>=4.10.0",
|
||||
"httpx>=0.28.0",
|
||||
"fastembed>=0.7.4",
|
||||
"sqlite-vec>=0.1.6",
|
||||
"openai>=1.100.2",
|
||||
]
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/basicmachines-co/basic-memory"
|
||||
Repository = "https://github.com/basicmachines-co/basic-memory"
|
||||
@@ -47,6 +58,9 @@ Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
|
||||
basic-memory = "basic_memory.cli.main:app"
|
||||
bm = "basic_memory.cli.main:app"
|
||||
|
||||
[project.optional-dependencies]
|
||||
telemetry = ["logfire>=4.19.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -54,16 +68,25 @@ build-backend = "hatchling.build"
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src", "tests"]
|
||||
addopts = "--cov=basic_memory --cov-report term-missing"
|
||||
testpaths = ["tests"]
|
||||
testpaths = ["tests", "test-int"]
|
||||
asyncio_mode = "strict"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
markers = [
|
||||
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
|
||||
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
|
||||
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
|
||||
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
|
||||
"smoke: Fast end-to-end smoke tests for MCP flows",
|
||||
"semantic: Tests requiring semantic dependencies (fastembed, sqlite-vec, openai)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"logfire>=4.19.0",
|
||||
"gevent>=24.11.1",
|
||||
"icecream>=2.1.3",
|
||||
"pytest>=8.3.4",
|
||||
@@ -72,6 +95,14 @@ dev-dependencies = [
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"ruff>=0.1.6",
|
||||
"freezegun>=1.5.5",
|
||||
"testcontainers[postgres]>=4.0.0",
|
||||
"psycopg>=3.2.0",
|
||||
"pyright>=1.1.408",
|
||||
"pytest-testmon>=2.2.0",
|
||||
"ty>=0.0.18",
|
||||
"cst-lsp>=0.1.3",
|
||||
"libcst>=1.8.6",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
@@ -90,12 +121,15 @@ ignore = ["test/"]
|
||||
defineConstant = { DEBUG = true }
|
||||
reportMissingImports = "error"
|
||||
reportMissingTypeStubs = false
|
||||
reportUnusedImport = "none"
|
||||
pythonVersion = "3.12"
|
||||
|
||||
|
||||
|
||||
[tool.coverage.run]
|
||||
concurrency = ["thread", "gevent"]
|
||||
parallel = true
|
||||
source = ["basic_memory"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
@@ -117,11 +151,9 @@ omit = [
|
||||
"*/supabase_auth_provider.py", # External HTTP calls to Supabase APIs
|
||||
"*/watch_service.py", # File system watching - complex integration testing
|
||||
"*/background_sync.py", # Background processes
|
||||
"*/cli/main.py", # CLI entry point
|
||||
"*/mcp/tools/project_management.py", # Covered by integration tests
|
||||
"*/mcp/tools/sync_status.py", # Covered by integration tests
|
||||
"*/cli/**", # CLI is an interactive wrapper; core logic is covered via API/MCP/service tests
|
||||
"*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
|
||||
"*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
|
||||
"*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
|
||||
"*/services/migration_service.py", # Complex migration scenarios
|
||||
]
|
||||
|
||||
[tool.logfire]
|
||||
ignore_no_config = true
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.basicmachines-co/basic-memory",
|
||||
"description": "Local-first knowledge management with bi-directional LLM sync via Markdown files.",
|
||||
"repository": {
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.20.3",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.20.3",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
{"type": "positional", "value": "mcp"}
|
||||
],
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-1: Specification-Driven Development Process'
|
||||
type: spec
|
||||
permalink: specs/spec-1-specification-driven-development-process
|
||||
tags:
|
||||
- process
|
||||
- specification
|
||||
- development
|
||||
- meta
|
||||
---
|
||||
|
||||
# SPEC-1: Specification-Driven Development Process
|
||||
|
||||
## Why
|
||||
We're implementing specification-driven development to solve the complexity and circular refactoring issues in our web development process.
|
||||
Instead of getting lost in framework details and type gymnastics, we start with clear specifications that drive implementation.
|
||||
|
||||
The default approach of adhoc development with AI agents tends to result in:
|
||||
- Circular refactoring cycles
|
||||
- Fighting framework complexity
|
||||
- Lost context between sessions
|
||||
- Unclear requirements and scope
|
||||
|
||||
## What
|
||||
This spec defines our process for using basic-memory as the specification engine to build basic-memory-cloud.
|
||||
We're creating a recursive development pattern where basic-memory manages the specs that drive the development of basic-memory-cloud.
|
||||
|
||||
**Affected Areas:**
|
||||
- All future component development
|
||||
- Architecture decisions
|
||||
- Agent collaboration workflows
|
||||
- Knowledge management and context preservation
|
||||
|
||||
## How (High Level)
|
||||
|
||||
### Specification Structure
|
||||
|
||||
Name: Spec names should be numbered sequentially, followed by a description eg. `SPEC-X - Simple Description.md`.
|
||||
See: [[Spec-2: Slash Commands Reference]]
|
||||
|
||||
Every spec is a complete thought containing:
|
||||
- **Why**: The reasoning and problem being solved
|
||||
- **What**: What is affected or changed
|
||||
- **How**: High-level approach to implementation
|
||||
- **How to Evaluate**: Testing/validation procedure
|
||||
- Additional context as needed
|
||||
|
||||
### Living Specification Format
|
||||
|
||||
Specifications are **living documents** that evolve throughout implementation:
|
||||
|
||||
**Progress Tracking:**
|
||||
- **Completed items**: Use ✅ checkmark emoji for implemented features
|
||||
- **Pending items**: Use `- [ ]` GitHub-style checkboxes for remaining tasks
|
||||
- **In-progress items**: Use `- [x]` when work is actively underway
|
||||
|
||||
**Status Philosophy:**
|
||||
- **Avoid static status headers** like "COMPLETE" or "IN PROGRESS" that become stale
|
||||
- **Use checklists within content** to show granular implementation progress
|
||||
- **Keep specs informative** while providing clear progress visibility
|
||||
- **Update continuously** as understanding and implementation evolve
|
||||
|
||||
**Example Format:**
|
||||
```markdown
|
||||
### ComponentName
|
||||
- ✅ Basic functionality implemented
|
||||
- ✅ Props and events defined
|
||||
- - [ ] Add sorting controls
|
||||
- - [ ] Improve accessibility
|
||||
- - [x] Currently implementing responsive design
|
||||
```
|
||||
|
||||
This creates **git-friendly progress tracking** where `[ ]` easily becomes `[x]` or ✅ when completed, and specs remain valuable throughout the development lifecycle.
|
||||
|
||||
|
||||
## Claude Code
|
||||
|
||||
We will leverage Claude Code capabilities to make the process semi-automated.
|
||||
|
||||
- Slash commands: define repeatable steps in the process (create spec, implement, review, etc)
|
||||
- Agents: define roles to carry out instructions (front end developer, baskend developer, etc)
|
||||
- MCP tools: enable agents to implement specs via actions (write code, test, etc)
|
||||
|
||||
### Workflow
|
||||
1. **Create**: Write spec as complete thought in `/specs` folder
|
||||
2. **Discuss**: Iterate and refine through agent collaboration
|
||||
3. **Implement**: Hand spec to appropriate specialist agent
|
||||
4. **Validate**: Review implementation against spec criteria
|
||||
5. **Document**: Update spec with learnings and decisions
|
||||
|
||||
### Slash Commands
|
||||
|
||||
Claude slash commands are used to manage the flow.
|
||||
These are simple instructions to help make the process uniform.
|
||||
They can be updated and refined as needed.
|
||||
|
||||
- `/spec create [name]` - Create new specification
|
||||
- `/spec status` - Show current spec states
|
||||
- `/spec implement [name]` - Hand to appropriate agent
|
||||
- `/spec review [name]` - Validate implementation
|
||||
|
||||
### Agent Orchestration
|
||||
|
||||
Agents are defined with clear roles, for instance:
|
||||
|
||||
- **system-architect**: Creates high-level specs, ADRs, architectural decisions
|
||||
- **vue-developer**: Component specs, UI patterns, frontend architecture
|
||||
- **python-developer**: Implementation specs, technical details, backend logic
|
||||
-
|
||||
- Each agent reads/updates specs through basic-memory tools.
|
||||
|
||||
## How to Evaluate
|
||||
|
||||
### Success Criteria
|
||||
- Specs provide clear, actionable guidance for implementation
|
||||
- Reduced circular refactoring and scope creep
|
||||
- Persistent context across development sessions
|
||||
- Clean separation between "what/why" and implementation details
|
||||
- Specs record a history of what happened and why for historical context
|
||||
|
||||
### Testing Procedure
|
||||
1. Create a spec for an existing problematic component
|
||||
2. Have an agent implement following only the spec
|
||||
3. Compare result quality and development speed vs. ad-hoc approach
|
||||
4. Measure context preservation across sessions
|
||||
5. Evaluate spec clarity and completeness
|
||||
|
||||
### Metrics
|
||||
- Time from spec to working implementation
|
||||
- Number of refactoring cycles required
|
||||
- Agent understanding of requirements
|
||||
- Spec reusability for similar components
|
||||
|
||||
## Notes
|
||||
- Start simple: specs are just complete thoughts, not heavy processes
|
||||
- Use basic-memory's knowledge graph to link specs, decisions, components
|
||||
- Let the process evolve naturally based on what works
|
||||
- Focus on solving the actual problem: Manage complexity in development
|
||||
|
||||
## Observations
|
||||
|
||||
- [problem] Web development without clear goals and documentation circular refactoring cycles #complexity
|
||||
- [solution] Specification-driven development reduces scope creep and context loss #process-improvement
|
||||
- [pattern] basic-memory as specification engine creates recursive development loop #meta-development
|
||||
- [workflow] Five-step process: Create → Discuss → Implement → Validate → Document #methodology
|
||||
- [tool] Slash commands provide uniform process automation #automation
|
||||
- [agent-pattern] Three specialized agents handle different implementation domains #specialization
|
||||
- [success-metric] Time from spec to working implementation measures process efficiency #measurement
|
||||
- [learning] Process should evolve naturally based on what works in practice #adaptation
|
||||
- [format] Living specifications use checklists for progress tracking instead of static status headers #documentation
|
||||
- [evolution] Specs evolve throughout implementation maintaining value as working documents #continuous-improvement
|
||||
|
||||
## Relations
|
||||
|
||||
- spec [[Spec-2: Slash Commands Reference]]
|
||||
- spec [[Spec-3: Agent Definitions]]
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-2: Slash Commands Reference'
|
||||
type: spec
|
||||
permalink: specs/spec-2-slash-commands-reference
|
||||
tags:
|
||||
- commands
|
||||
- process
|
||||
- reference
|
||||
---
|
||||
|
||||
# SPEC-2: Slash Commands Reference
|
||||
|
||||
This document defines the slash commands used in our specification-driven development process.
|
||||
|
||||
## /spec create [name]
|
||||
|
||||
**Purpose**: Create a new specification document
|
||||
|
||||
**Usage**: `/spec create notes-decomposition`
|
||||
|
||||
**Process**:
|
||||
1. Create new spec document in `/specs` folder
|
||||
2. Use SPEC-XXX numbering format (auto-increment)
|
||||
3. Include standard spec template:
|
||||
- Why (reasoning/problem)
|
||||
- What (affected areas)
|
||||
- How (high-level approach)
|
||||
- How to Evaluate (testing/validation)
|
||||
4. Tag appropriately for knowledge graph
|
||||
5. Link to related specs/components
|
||||
|
||||
**Template**:
|
||||
```markdown
|
||||
# SPEC-XXX: [Title]
|
||||
|
||||
## Why
|
||||
[Problem statement and reasoning]
|
||||
|
||||
## What
|
||||
[What is affected or changed]
|
||||
|
||||
## How (High Level)
|
||||
[Approach to implementation]
|
||||
|
||||
## How to Evaluate
|
||||
[Testing/validation procedure]
|
||||
|
||||
## Notes
|
||||
[Additional context as needed]
|
||||
```
|
||||
|
||||
## /spec status
|
||||
|
||||
**Purpose**: Show current status of all specifications
|
||||
|
||||
**Usage**: `/spec status`
|
||||
|
||||
**Process**:
|
||||
1. Search all specs in `/specs` folder
|
||||
2. Display table showing:
|
||||
- Spec number and title
|
||||
- Status (draft, approved, implementing, complete)
|
||||
- Assigned agent (if any)
|
||||
- Last updated
|
||||
- Dependencies
|
||||
|
||||
## /spec implement [name]
|
||||
|
||||
**Purpose**: Hand specification to appropriate agent for implementation
|
||||
|
||||
**Usage**: `/spec implement SPEC-002`
|
||||
|
||||
**Process**:
|
||||
1. Read the specified spec
|
||||
2. Analyze requirements to determine appropriate agent:
|
||||
- Frontend components → vue-developer
|
||||
- Architecture/system design → system-architect
|
||||
- Backend/API → python-developer
|
||||
3. Launch agent with spec context
|
||||
4. Agent creates implementation plan
|
||||
5. Update spec with implementation status
|
||||
|
||||
## /spec review [name]
|
||||
|
||||
**Purpose**: Review implementation against specification criteria
|
||||
|
||||
**Usage**: `/spec review SPEC-002`
|
||||
|
||||
**Process**:
|
||||
1. Read original spec and "How to Evaluate" section
|
||||
2. Examine current implementation
|
||||
3. Test against success criteria
|
||||
4. Document gaps or issues
|
||||
5. Update spec with review results
|
||||
6. Recommend next actions (complete, revise, iterate)
|
||||
|
||||
## Command Extensions
|
||||
|
||||
As the process evolves, we may add:
|
||||
- `/spec link [spec1] [spec2]` - Create dependency links
|
||||
- `/spec archive [name]` - Archive completed specs
|
||||
- `/spec template [type]` - Create spec from template
|
||||
- `/spec search [query]` - Search spec content
|
||||
|
||||
## References
|
||||
|
||||
- Claude Slash commands: https://docs.anthropic.com/en/docs/claude-code/slash-commands
|
||||
|
||||
## Creating a command
|
||||
|
||||
Commands are implemented as Claude slash commands:
|
||||
|
||||
Location in repo: .claude/commands/
|
||||
|
||||
In the following example, we create the /optimize command:
|
||||
```bash
|
||||
# Create a project command
|
||||
mkdir -p .claude/commands
|
||||
echo "Analyze this code for performance issues and suggest optimizations:" > .claude/commands/optimize.md
|
||||
```
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-3: Agent Definitions'
|
||||
type: spec
|
||||
permalink: specs/spec-3-agent-definitions
|
||||
tags:
|
||||
- agents
|
||||
- roles
|
||||
- process
|
||||
---
|
||||
|
||||
# SPEC-3: Agent Definitions
|
||||
|
||||
This document defines the specialist agents used in our specification-driven development process.
|
||||
|
||||
## system-architect
|
||||
|
||||
**Role**: High-level system design and architectural decisions
|
||||
|
||||
**Responsibilities**:
|
||||
- Create architectural specifications and ADRs
|
||||
- Analyze system-wide impacts and trade-offs
|
||||
- Design component interfaces and data flow
|
||||
- Evaluate technical approaches and patterns
|
||||
- Document architectural decisions and rationale
|
||||
|
||||
**Expertise Areas**:
|
||||
- System architecture and design patterns
|
||||
- Technology evaluation and selection
|
||||
- Scalability and performance considerations
|
||||
- Integration patterns and API design
|
||||
- Technical debt and refactoring strategies
|
||||
|
||||
**Typical Specs**:
|
||||
- System architecture overviews
|
||||
- Component decomposition strategies
|
||||
- Data flow and state management
|
||||
- Integration and deployment patterns
|
||||
|
||||
## vue-developer
|
||||
|
||||
**Role**: Frontend component development and UI implementation
|
||||
|
||||
**Responsibilities**:
|
||||
- Create Vue.js component specifications
|
||||
- Implement responsive UI components
|
||||
- Design component APIs and interfaces
|
||||
- Optimize for performance and accessibility
|
||||
- Document component usage and patterns
|
||||
|
||||
**Expertise Areas**:
|
||||
- Vue.js 3 Composition API
|
||||
- Nuxt 3 framework patterns
|
||||
- shadcn-vue component library
|
||||
- Responsive design and CSS
|
||||
- TypeScript integration
|
||||
- State management with Pinia
|
||||
|
||||
**Typical Specs**:
|
||||
- Individual component specifications
|
||||
- UI pattern libraries
|
||||
- Responsive design approaches
|
||||
- Component interaction flows
|
||||
|
||||
## python-developer
|
||||
|
||||
**Role**: Backend development and API implementation
|
||||
|
||||
**Responsibilities**:
|
||||
- Create backend service specifications
|
||||
- Implement APIs and data processing
|
||||
- Design database schemas and queries
|
||||
- Optimize performance and reliability
|
||||
- Document service interfaces and behavior
|
||||
|
||||
**Expertise Areas**:
|
||||
- FastAPI and Python web frameworks
|
||||
- Database design and operations
|
||||
- API design and documentation
|
||||
- Authentication and security
|
||||
- Performance optimization
|
||||
- Testing and validation
|
||||
|
||||
**Typical Specs**:
|
||||
- API endpoint specifications
|
||||
- Database schema designs
|
||||
- Service integration patterns
|
||||
- Performance optimization strategies
|
||||
|
||||
## Agent Collaboration Patterns
|
||||
|
||||
### Handoff Protocol
|
||||
1. Agent receives spec through `/spec implement [name]`
|
||||
2. Agent reviews spec and creates implementation plan
|
||||
3. Agent documents progress and decisions in spec
|
||||
4. Agent hands off to another agent if cross-domain work needed
|
||||
5. Final agent updates spec with completion status
|
||||
|
||||
### Communication Standards
|
||||
- All agents update specs through basic-memory MCP tools
|
||||
- Document decisions and trade-offs in spec notes
|
||||
- Link related specs and components
|
||||
- Preserve context for future reference
|
||||
|
||||
### Quality Standards
|
||||
- Follow existing codebase patterns and conventions
|
||||
- Write tests that validate spec requirements
|
||||
- Document implementation choices
|
||||
- Consider maintainability and extensibility
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-5: CLI Cloud Upload via WebDAV'
|
||||
type: spec
|
||||
permalink: specs/spec-5-cli-cloud-upload-via-webdav
|
||||
tags:
|
||||
- cli
|
||||
- webdav
|
||||
- upload
|
||||
- migration
|
||||
- poc
|
||||
---
|
||||
|
||||
# SPEC-5: CLI Cloud Upload via WebDAV
|
||||
|
||||
## Why
|
||||
|
||||
Existing basic-memory users need a simple migration path to basic-memory-cloud. The web UI drag-and-drop approach outlined in GitHub issue #59, while user-friendly, introduces significant complexity for a proof-of-concept:
|
||||
|
||||
- Complex web UI components for file upload and progress tracking
|
||||
- Browser file handling limitations and CORS complexity
|
||||
- Proxy routing overhead for large file transfers
|
||||
- Authentication integration across multiple services
|
||||
|
||||
A CLI-first approach solves these issues by:
|
||||
|
||||
- **Leveraging existing infrastructure**: Both cloud CLI and tenant API already exist with WorkOS JWT authentication
|
||||
- **Familiar user experience**: Basic-memory users are CLI-comfortable and expect command-line tools
|
||||
- **Direct connection efficiency**: Bypassing the MCP gateway/proxy for bulk file transfers
|
||||
- **Rapid implementation**: Building on existing `CLIAuth` and FastAPI foundations
|
||||
|
||||
The fundamental problem is migration friction - users have local basic-memory projects but no path to cloud tenants. A simple CLI upload command removes this barrier immediately.
|
||||
|
||||
## What
|
||||
|
||||
This spec defines a CLI-based project upload system using WebDAV for direct tenant connections.
|
||||
|
||||
**Affected Areas:**
|
||||
- `apps/cloud/src/basic_memory_cloud/cli/main.py` - Add upload command to existing CLI
|
||||
- `apps/api/src/basic_memory_cloud_api/main.py` - Add WebDAV endpoints to tenant FastAPI
|
||||
- Authentication flow - Reuse existing WorkOS JWT validation
|
||||
- File transfer protocol - WebDAV for cross-platform compatibility
|
||||
|
||||
**Core Components:**
|
||||
|
||||
### CLI Upload Command
|
||||
```bash
|
||||
basic-memory-cloud upload <project-path> --tenant-url https://basic-memory-{tenant}.fly.dev
|
||||
```
|
||||
|
||||
### WebDAV Server Endpoints
|
||||
- `GET/PUT/DELETE /webdav/*` - Standard WebDAV operations on tenant file system
|
||||
- Authentication via existing JWT validation
|
||||
- File operations preserve timestamps and directory structure
|
||||
|
||||
### Authentication Flow
|
||||
```
|
||||
1. User runs `basic-memory-cloud login` (existing)
|
||||
2. CLI stores WorkOS JWT token (existing)
|
||||
3. Upload command reads JWT from storage
|
||||
4. WebDAV requests include JWT in Authorization header
|
||||
5. Tenant API validates JWT using existing middleware
|
||||
```
|
||||
|
||||
## How (High Level)
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
**Phase 1: CLI Command**
|
||||
- Add `upload` command to existing Typer app
|
||||
- Reuse `CLIAuth` class for token management
|
||||
- Implement WebDAV client using `webdavclient3` or similar
|
||||
- Rich progress bars for transfer feedback
|
||||
|
||||
**Phase 2: WebDAV Server**
|
||||
- Add WebDAV endpoints to existing tenant FastAPI app
|
||||
- Leverage existing `get_current_user` dependency for authentication
|
||||
- Map WebDAV operations to tenant file system
|
||||
- Preserve file modification times using `os.utime()`
|
||||
|
||||
**Phase 3: Integration**
|
||||
- Direct connection bypasses MCP gateway and proxy
|
||||
- Simple conflict resolution: overwrite existing files
|
||||
- Error handling: fail fast with clear error messages
|
||||
|
||||
### Technical Architecture
|
||||
|
||||
```
|
||||
basic-memory-cloud CLI → WorkOS JWT → Direct WebDAV → Tenant FastAPI
|
||||
↓
|
||||
Tenant File System
|
||||
```
|
||||
|
||||
**Key Libraries:**
|
||||
- CLI: `webdavclient3` for WebDAV client operations
|
||||
- API: `wsgidav` or FastAPI-compatible WebDAV server
|
||||
- Progress: `rich` library (already imported in CLI)
|
||||
- Auth: Existing WorkOS JWT infrastructure
|
||||
|
||||
### WebDAV Protocol Choice
|
||||
|
||||
WebDAV provides:
|
||||
- **Cross-platform clients**: Native support in most operating systems
|
||||
- **Standardized protocol**: Well-defined for file operations
|
||||
- **HTTP-based**: Works with existing FastAPI and JWT auth
|
||||
- **Library support**: Good Python libraries for both client and server
|
||||
|
||||
### POC Constraints
|
||||
|
||||
**Simplifications for rapid implementation:**
|
||||
- **Known tenant URLs**: Assume `https://basic-memory-{tenant}.fly.dev` format
|
||||
- **Upload only**: No download or bidirectional sync
|
||||
- **Overwrite conflicts**: No merge or conflict resolution prompting
|
||||
- **No fallbacks**: Fail fast if WebDAV connection issues occur
|
||||
- **Direct connection only**: No proxy fallback mechanism
|
||||
|
||||
## How to Evaluate
|
||||
|
||||
### Success Criteria
|
||||
|
||||
**Functional Requirements:**
|
||||
- [ ] Transfer complete basic-memory project (100+ files) in < 30 seconds
|
||||
- [ ] Preserve directory structure exactly as in source project
|
||||
- [ ] Preserve file modification timestamps for proper sync behavior
|
||||
- [ ] Rich progress bars show real-time transfer status (files/MB transferred)
|
||||
- [ ] WorkOS JWT authentication validates correctly on WebDAV endpoints
|
||||
- [ ] Direct tenant connection bypasses MCP gateway successfully
|
||||
|
||||
**Quality Requirements:**
|
||||
- [ ] Clear error messages for authentication failures
|
||||
- [ ] Graceful handling of network interruptions
|
||||
- [ ] CLI follows existing command patterns and help text standards
|
||||
- [ ] WebDAV endpoints integrate cleanly with existing FastAPI app
|
||||
|
||||
**Performance Requirements:**
|
||||
- [ ] File transfer speed > 1MB/s on typical connections
|
||||
- [ ] Memory usage remains reasonable for large projects
|
||||
- [ ] No timeout issues with 500+ file projects
|
||||
|
||||
### Testing Procedure
|
||||
|
||||
**Unit Testing:**
|
||||
1. CLI command parsing and argument validation
|
||||
2. WebDAV client connection and authentication
|
||||
3. File timestamp preservation during transfer
|
||||
4. JWT token validation on WebDAV endpoints
|
||||
|
||||
**Integration Testing:**
|
||||
1. End-to-end upload of test project
|
||||
2. Direct tenant connection without proxy
|
||||
3. File integrity verification after upload
|
||||
4. Progress tracking accuracy during transfer
|
||||
|
||||
**User Experience Testing:**
|
||||
1. Upload existing basic-memory project from local installation
|
||||
2. Verify uploaded files appear correctly in cloud tenant
|
||||
3. Confirm basic-memory database rebuilds properly with uploaded files
|
||||
4. Test CLI help text and error message clarity
|
||||
|
||||
### Validation Commands
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Login to WorkOS
|
||||
basic-memory-cloud login
|
||||
|
||||
# Upload project
|
||||
basic-memory-cloud upload ~/my-notes --tenant-url https://basic-memory-test.fly.dev
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
# Check tenant health and file count via API
|
||||
curl -H "Authorization: Bearer $JWT" https://basic-memory-test.fly.dev/health
|
||||
curl -H "Authorization: Bearer $JWT" https://basic-memory-test.fly.dev/notes/search
|
||||
```
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
**Target metrics for 100MB basic-memory project:**
|
||||
- Transfer time: < 30 seconds
|
||||
- Memory usage: < 100MB during transfer
|
||||
- Progress updates: Every 1MB or 10 files
|
||||
- Authentication time: < 2 seconds
|
||||
|
||||
## Observations
|
||||
|
||||
- [implementation-speed] CLI approach significantly faster than web UI for POC development #rapid-prototyping
|
||||
- [user-experience] Basic-memory users already comfortable with CLI tools #user-familiarity
|
||||
- [architecture-benefit] Direct connection eliminates proxy complexity and latency #performance
|
||||
- [auth-reuse] Existing WorkOS JWT infrastructure handles authentication cleanly #code-reuse
|
||||
- [webdav-choice] WebDAV protocol provides cross-platform compatibility and standard libraries #protocol-selection
|
||||
- [poc-scope] Simple conflict handling and error recovery sufficient for proof-of-concept #scope-management
|
||||
- [migration-value] Removes primary barrier for local users migrating to cloud platform #business-value
|
||||
|
||||
## Relations
|
||||
|
||||
- depends_on [[SPEC-1: Specification-Driven Development Process]]
|
||||
- enables [[GitHub Issue #59: Web UI Upload Feature]]
|
||||
- uses [[WorkOS Authentication Integration]]
|
||||
- builds_on [[Existing Cloud CLI Infrastructure]]
|
||||
- builds_on [[Existing Tenant API Architecture]]
|
||||
@@ -1,486 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-6: Explicit Project Parameter Architecture'
|
||||
type: spec
|
||||
permalink: specs/spec-6-explicit-project-parameter-architecture
|
||||
tags:
|
||||
- architecture
|
||||
- mcp
|
||||
- project-management
|
||||
- stateless
|
||||
---
|
||||
|
||||
# SPEC-6: Explicit Project Parameter Architecture
|
||||
|
||||
## Why
|
||||
|
||||
The current session-based project management system has critical reliability issues:
|
||||
|
||||
1. **Session State Fragility**: Claude iOS mobile client fails to maintain consistent session IDs across MCP tool calls, causing project switching to silently fail (Issue #74)
|
||||
2. **Scaling Limitations**: Redis-backed session state creates single-point-of-failure and prevents horizontal scaling
|
||||
3. **Client Compatibility**: Session tracking works inconsistently across different MCP clients (web, mobile, API)
|
||||
4. **Hidden Complexity**: Users cannot see or understand "current project" state, leading to confusion when operations execute in wrong projects
|
||||
5. **Silent Failures**: Operations appear successful but execute in unintended projects, risking data integrity
|
||||
|
||||
Evidence from production logs shows each MCP tool call from mobile client receives different session IDs:
|
||||
```
|
||||
create_memory_project: session_id=12cdfc24913b48f8b680ed4b2bfdb7ba
|
||||
switch_project: session_id=050a69275d98498cbdd227cdb74d9740
|
||||
list_directory: session_id=85f3483014af4136a5d435c76ded212f
|
||||
```
|
||||
|
||||
Related Github issue: https://github.com/basicmachines-co/basic-memory-cloud/issues/75
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status**: **Phase 1 Implementation Complete** ✅
|
||||
**Target**: Fix Claude iOS session ID consistency issues
|
||||
**Draft PR**: https://github.com/basicmachines-co/basic-memory/pull/298
|
||||
|
||||
### 🎉 **MAJOR MILESTONE ACHIEVED**
|
||||
|
||||
The complete stateless architecture has been successfully implemented for Basic Memory's MCP server! This represents a **fundamental architectural improvement** that solves the Claude iOS compatibility issue while making the entire system more robust and predictable.
|
||||
|
||||
#### Implementation Summary:
|
||||
- **16 files modified** with 582 additions and 550 deletions
|
||||
- **All 17 MCP tools** converted to stateless architecture
|
||||
- **147 tests updated** across 5 test files (100% passing)
|
||||
- **Complete session state removal** from core MCP tools
|
||||
- **Enhanced error handling** and security validations preserved
|
||||
|
||||
### Progress Summary
|
||||
|
||||
✅ **Complete Stateless Architecture Implementation (All 17 tools)**
|
||||
- Stateless `get_active_project()` function implemented and deployed
|
||||
- All session state dependencies removed across entire MCP server
|
||||
- All MCP tools require explicit `project` parameter as first argument
|
||||
|
||||
✅ **Content Management Tools Complete (6/6 tools)**
|
||||
- `write_note`, `read_note`, `delete_note`, `edit_note` ✅
|
||||
- `view_note`, `read_content` ✅
|
||||
|
||||
✅ **Knowledge Graph Navigation Tools Complete (3/3 tools)**
|
||||
- `build_context`, `recent_activity`, `list_directory` ✅
|
||||
|
||||
✅ **Search & Discovery Tools Complete (1/1 tools)**
|
||||
- `search_notes` ✅
|
||||
|
||||
✅ **Visualization Tools Complete (1/1 tools)**
|
||||
- `canvas` ✅
|
||||
|
||||
✅ **Project Management Cleanup Complete**
|
||||
- Removed `switch_project` and `get_current_project` tools ✅
|
||||
- Updated `set_default_project` to remove activate parameter ✅
|
||||
|
||||
✅ **Comprehensive Testing Complete (157 tests)**
|
||||
- All test suites updated to use stateless architecture (147 existing tests)
|
||||
- Single project constraint mode integration tests (10 new tests)
|
||||
- 100% test pass rate across all tool test files
|
||||
- Security validations preserved and working
|
||||
- Error handling comprehensive and user-friendly
|
||||
|
||||
✅ **Documentation & Examples Complete**
|
||||
- All tool docstrings updated with stateless examples
|
||||
- Project parameter usage clearly documented
|
||||
- Error handling and security behavior documented
|
||||
|
||||
✅ **Enhanced Discovery Mode Complete**
|
||||
- `recent_activity` tool supports dual-mode operation (discovery vs project-specific)
|
||||
- ProjectActivitySummary schema provides cross-project insights
|
||||
- Recent activity prompt updated to support both modes
|
||||
- Comprehensive project distribution statistics and most active project tracking
|
||||
|
||||
✅ **Single Project Constraint Mode Complete**
|
||||
- `--project` CLI parameter for MCP server constraint
|
||||
- Environment variable control (`BASIC_MEMORY_MCP_PROJECT`)
|
||||
- Automatic project override in `get_active_project()` function
|
||||
- Project management tools disabled in constrained mode with helpful CLI guidance
|
||||
- Comprehensive integration test suite (10 tests covering all constraint scenarios)
|
||||
|
||||
## What
|
||||
|
||||
Transform Basic Memory from stateful session-based to stateless explicit project parameter architecture:
|
||||
|
||||
### Core Changes
|
||||
1. **Mandatory Project Parameter**: All MCP tools require explicit `project` parameter
|
||||
2. **Remove Session State**: Eliminate Redis, session middleware, and `switch_project` tool
|
||||
3. **Stateless HTTP**: Enable `stateless_http=True` for horizontal scaling
|
||||
4. **Enhanced Context Discovery**: Improve `recent_activity` to show project distribution
|
||||
5. **Clear Response Format**: All tool responses display target project information
|
||||
|
||||
Implementation Approach
|
||||
|
||||
- Each tool will directly accept the project parameter
|
||||
- Remove all calls to context-based project retrieval
|
||||
- Validate project exists before operations
|
||||
- Clear error messages when project not found
|
||||
- Backward compatibility: Initially keep optional parameter, then make required
|
||||
|
||||
### Affected MCP Tools
|
||||
**Content Management** (require project parameter):
|
||||
- `write_note(project, title, content, folder)`
|
||||
- `read_note(project, identifier)`
|
||||
- `edit_note(project, identifier, operation, content)`
|
||||
- `delete_note(project, identifier)`
|
||||
- `view_note(project, identifier)`
|
||||
- `read_content(project, path)`
|
||||
|
||||
**Knowledge Graph Navigation** (require project parameter):
|
||||
- `build_context(project, url, timeframe, depth, max_related)`
|
||||
- `list_directory(project, dir_name, depth, file_name_glob)`
|
||||
- `search_notes(project, query, search_type, types, entity_types)`
|
||||
|
||||
**Search & Discovery** (use project parameter for specific project or none for discovery):
|
||||
- `recent_activity(project, timeframe, depth, max_related)`
|
||||
|
||||
**Visualization** (require project parameter):
|
||||
- `canvas(project, nodes, edges, title, folder)`
|
||||
|
||||
**Project Management** (unchanged - already stateless):
|
||||
- `list_memory_projects()`
|
||||
- `create_memory_project(project_name, project_path, set_default)`
|
||||
- `delete_project(project_name)`
|
||||
- `get_current_project()` - Remove this tool
|
||||
- `switch_project(project_name)` - Remove this tool
|
||||
- `set_default_project(project_name, activate)` - Remove activate parameter
|
||||
|
||||
## How (High Level)
|
||||
|
||||
### Phase 1: Basic Memory Core (basic-memory repository)
|
||||
|
||||
#### MCP Tool Updates
|
||||
|
||||
Phase 1: Core Changes
|
||||
|
||||
1. Update project_context.py
|
||||
|
||||
- [x] Make project parameter mandatory for get_active_project()
|
||||
- [x] Remove session state handling
|
||||
|
||||
2. Update Content Management Tools (6 tools)
|
||||
|
||||
- [x] write_note: Make project parameter required, not optional
|
||||
- [x] read_note: Make project parameter required
|
||||
- [x] edit_note: Add required project parameter
|
||||
- [x] delete_note: Add required project parameter
|
||||
- [x] view_note: Add required project parameter
|
||||
- [x] read_content: Add required project parameter
|
||||
|
||||
3. Update Knowledge Graph Navigation Tools (3 tools)
|
||||
|
||||
- [x] build_context: Add required project parameter
|
||||
- [x] recent_activity: Make project parameter required
|
||||
- [x] list_directory: Add required project parameter
|
||||
|
||||
4. Update Search & Visualization Tools (2 tools)
|
||||
|
||||
- [x] search_notes: Add required project parameter
|
||||
- [x] canvas: Add required project parameter
|
||||
|
||||
5. Update Project Management Tools
|
||||
|
||||
- [x] Remove switch_project tool completely
|
||||
- [x] Remove get_current_project tool completely
|
||||
- [x] Update set_default_project to remove activate parameter
|
||||
- [x] Keep list_memory_projects, create_memory_project, delete_project unchanged
|
||||
|
||||
6. Enhance recent_activity Response
|
||||
|
||||
- [x] Add project distribution info showing activity across all projects
|
||||
- [x] Include project usage stats in response
|
||||
- [x] Implement ProjectActivitySummary for discovery mode
|
||||
- [x] Add dual-mode functionality (discovery vs project-specific)
|
||||
|
||||
7. Update Tool Documentation
|
||||
|
||||
- [x] Update write_note docstring with stateless architecture examples
|
||||
- [x] Update read_note docstring with project parameter examples
|
||||
- [x] Update delete_note docstring with comprehensive usage guidance
|
||||
- [x] Update all remaining tool docstrings with project parameter examples
|
||||
|
||||
8. Update Tool Responses
|
||||
|
||||
- [x] Add clear project indicator to all tool responses across all tools
|
||||
- [x] Format: "project: {project_name}" in response metadata
|
||||
- [x] Add project metadata footer for LLM awareness
|
||||
- [x] Update all tool responses to include project indicators
|
||||
|
||||
9. Comprehensive Testing
|
||||
|
||||
- [x] Update all write_note tests to use stateless architecture (34 tests passing)
|
||||
- [x] Update all edit_note tests to use stateless architecture (17 tests passing)
|
||||
- [x] Update all view_note tests to use stateless architecture (12 tests passing)
|
||||
- [x] Update all search_notes tests to use stateless architecture (16 tests passing)
|
||||
- [x] Update all move_note tests to use stateless architecture (31 tests passing)
|
||||
- [x] Update all delete_note tests to use stateless architecture
|
||||
- [x] Verify direct function call compatibility (bypassing MCP layer)
|
||||
- [x] Test security validation with project parameters
|
||||
- [x] Validate error handling for non-existent projects
|
||||
- [x] **Total: 157 tests updated and passing (100% success rate)**
|
||||
- [x] **147 existing tests** updated for stateless architecture
|
||||
- [x] **10 new tests** for single project constraint mode
|
||||
|
||||
### Phase 1.5: Default Project Mode Enhancement
|
||||
|
||||
#### Problem
|
||||
While the stateless architecture solves reliability issues, it introduces UX friction for single-project users (estimated 80% of usage) who must specify the project parameter in every tool call.
|
||||
|
||||
#### Solution: Default Project Mode
|
||||
Add optional `default_project_mode` configuration that allows single-project users to have the simplicity of implicit project selection while maintaining the reliability of stateless architecture.
|
||||
|
||||
#### Configuration
|
||||
```json
|
||||
{
|
||||
"default_project": "main",
|
||||
"default_project_mode": true // NEW: Auto-use default_project when not specified
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
1. **Config Enhancement** (`src/basic_memory/config.py`)
|
||||
- Add `default_project_mode: bool = Field(default=False)`
|
||||
- Preserves backward compatibility (defaults to false)
|
||||
|
||||
2. **Project Resolution Logic** (`src/basic_memory/mcp/project_context.py`)
|
||||
Three-tier resolution hierarchy:
|
||||
- Priority 1: CLI `--project` constraint (BASIC_MEMORY_MCP_PROJECT env var)
|
||||
- Priority 2: Explicit project parameter in tool call
|
||||
- Priority 3: `default_project` if `default_project_mode=true` and no project specified
|
||||
|
||||
3. **Assistant Guide Updates** (`src/basic_memory/mcp/resources/ai_assistant_guide.md`)
|
||||
- Detect `default_project_mode` at runtime
|
||||
- Provide mode-specific instructions to LLMs
|
||||
- In default mode: "All operations use project 'main' automatically"
|
||||
- In regular mode: Current project discovery guidance
|
||||
|
||||
4. **Tool Parameter Handling** (all MCP tools)
|
||||
- Make project parameter Optional[str] = None
|
||||
- Add resolution logic: `project = project or get_default_project()`
|
||||
- Maintain explicit project override capability
|
||||
|
||||
#### Usage Modes Summary
|
||||
- **Regular Mode**: Multi-project users, assistant tracks project per conversation
|
||||
- **Default Project Mode**: Single-project users, automatic default project
|
||||
- **Constrained Mode**: CLI --project flag, locked to specific project
|
||||
|
||||
#### Testing Requirements
|
||||
- Integration test for default_project_mode=true with missing parameters
|
||||
- Test explicit project override in default_project_mode
|
||||
- Test mode=false requires explicit parameters
|
||||
- Test CLI constraint overrides default_project_mode
|
||||
|
||||
Phase 2: Testing & Validation
|
||||
|
||||
8. Update Tests
|
||||
|
||||
- [x] Modify all MCP tool tests to pass required project parameter
|
||||
- [x] Remove tests for deleted tools (switch_project, get_current_project)
|
||||
- [x] Add tests for project parameter validation
|
||||
- [x] **Complete: All 147 tests across 5 test files updated and passing**
|
||||
|
||||
#### Enhanced recent_activity Response
|
||||
```json
|
||||
{
|
||||
"recent_notes": [...],
|
||||
"project_activity": {
|
||||
"research-project": {
|
||||
"operations": 5,
|
||||
"last_used": "30 minutes ago",
|
||||
"recent_folders": ["experiments", "findings"]
|
||||
},
|
||||
"work-notes": {
|
||||
"operations": 2,
|
||||
"last_used": "2 hours ago",
|
||||
"recent_folders": ["meetings", "planning"]
|
||||
}
|
||||
},
|
||||
"total_projects": 3
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Format Updates
|
||||
```
|
||||
✓ Note created successfully
|
||||
|
||||
Project: research-project
|
||||
File: experiments/Neural Network Results.md
|
||||
Permalink: research-project/neural-network-results
|
||||
```
|
||||
|
||||
### Phase 2: Cloud Service Simplification (basic-memory-cloud repository)
|
||||
|
||||
#### Remove Session Infrastructure
|
||||
1. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_state.py`
|
||||
2. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_logging.py`
|
||||
3. Update `apps/mcp/src/basic_memory_cloud_mcp/main.py`:
|
||||
```python
|
||||
# Remove session middleware
|
||||
# server.add_middleware(SessionStateMiddleware)
|
||||
|
||||
# Enable stateless HTTP
|
||||
mcp = FastMCP(name="basic-memory-mcp", stateless_http=True)
|
||||
```
|
||||
|
||||
#### Deployment Simplification
|
||||
1. Remove Redis from `fly.toml`
|
||||
2. Remove Redis environment variables
|
||||
3. Update health checks to not depend on Redis
|
||||
|
||||
### Phase 3: Conversational Project Management
|
||||
|
||||
#### Claude Behavior Pattern
|
||||
1. **Project Discovery**:
|
||||
```
|
||||
Claude: Let me check your recent activity...
|
||||
[calls recent_activity() - no project needed for discovery]
|
||||
|
||||
I see you've been working in:
|
||||
- research-project (5 operations, 30 min ago)
|
||||
- work-notes (2 operations, 2 hours ago)
|
||||
|
||||
Which project should I use for this operation?
|
||||
```
|
||||
|
||||
2. **Context Maintenance**:
|
||||
```
|
||||
User: Use research-project
|
||||
Claude: Working in research-project.
|
||||
[All subsequent operations use project="research-project"]
|
||||
```
|
||||
|
||||
3. **Explicit Project Switching**:
|
||||
```
|
||||
User: Check work-notes for that meeting summary
|
||||
Claude: Let me search work-notes for the meeting summary.
|
||||
[Uses project="work-notes" for specific operation]
|
||||
```
|
||||
|
||||
## How to Evaluate
|
||||
|
||||
### Success Criteria
|
||||
|
||||
#### 1. Functional Completeness
|
||||
- [x] All MCP tools accept required `project` parameter
|
||||
- [x] All MCP tools validate project exists before execution
|
||||
- [x] `switch_project` and `get_current_project` tools removed
|
||||
- [x] All responses display target project clearly
|
||||
- [ ] No Redis dependencies in deployment (Phase 2: Cloud Service)
|
||||
- [x] `recent_activity` shows project distribution with ProjectActivitySummary
|
||||
|
||||
#### 2. Cross-Client Compatibility Testing
|
||||
Test identical operations across all clients:
|
||||
- [ ] **Claude Desktop**: All operations work with explicit projects
|
||||
- [ ] **Claude Code**: All operations work with explicit projects
|
||||
- [ ] **Claude Mobile iOS**: All operations work with explicit projects
|
||||
- [ ] **API clients**: All operations work with explicit projects
|
||||
- [ ] **CLI tools**: All operations work with explicit projects
|
||||
|
||||
#### 3. Session Independence Verification
|
||||
- [ ] Operations work identically with/without session tracking
|
||||
- [ ] No behavioral differences between clients
|
||||
- [ ] Mobile client session ID changes do not affect operations
|
||||
- [ ] Redis can be completely removed without functional impact
|
||||
|
||||
#### 4. Performance & Scaling
|
||||
- [ ] `stateless_http=True` enabled successfully
|
||||
- [ ] No Redis memory usage
|
||||
- [ ] Horizontal scaling possible (multiple MCP instances)
|
||||
- [ ] Response times unchanged or improved
|
||||
|
||||
#### 5. User Experience Testing
|
||||
**Project Discovery Flow**:
|
||||
- [x] `recent_activity()` provides useful project context
|
||||
- [x] Claude can intelligently suggest projects based on activity
|
||||
- [x] Project switching is explicit and clear in conversation
|
||||
|
||||
**Error Handling**:
|
||||
- [x] Clear error messages for non-existent projects
|
||||
- [x] Helpful suggestions when project parameter missing
|
||||
- [x] No silent failures or wrong-project operations
|
||||
|
||||
**Response Clarity**:
|
||||
- [x] Every operation clearly shows target project
|
||||
- [x] Users always know which project is being operated on
|
||||
- [x] No confusion about "current project" state
|
||||
|
||||
#### 6. Migration Safety
|
||||
- [ ] Backward compatibility period with optional project parameter
|
||||
- [ ] Clear migration documentation for existing users
|
||||
- [ ] Data integrity maintained during transition
|
||||
- [ ] No data loss during migration
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
#### Core Functionality Test
|
||||
```bash
|
||||
# Test all tools work with explicit project
|
||||
write_note(project="test-proj", title="Test", content="Content", folder="docs")
|
||||
read_note(project="test-proj", identifier="Test")
|
||||
edit_note(project="test-proj", identifier="Test", operation="append", content="More")
|
||||
search_notes(project="test-proj", query="Content")
|
||||
list_directory(project="test-proj", dir_name="docs")
|
||||
delete_note(project="test-proj", identifier="Test")
|
||||
```
|
||||
|
||||
#### Cross-Client Consistency Test
|
||||
Run identical test sequence on:
|
||||
1. Claude Desktop
|
||||
2. Claude Code
|
||||
3. Claude Mobile iOS
|
||||
4. API client
|
||||
5. CLI tools
|
||||
|
||||
Verify all clients:
|
||||
- Accept explicit project parameters
|
||||
- Return identical responses
|
||||
- Show same project information
|
||||
- Have no session dependencies
|
||||
|
||||
#### Session Independence Test
|
||||
1. Monitor session IDs during operations
|
||||
2. Verify operations work with changing session IDs
|
||||
3. Confirm Redis removal doesn't affect functionality
|
||||
4. Test with multiple concurrent clients
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
**Must Have**:
|
||||
- All MCP tools require and use explicit project parameter
|
||||
- No session state dependencies remain
|
||||
- Universal client compatibility achieved
|
||||
- Clear project information in all responses
|
||||
|
||||
**Should Have**:
|
||||
- Enhanced `recent_activity` with project distribution
|
||||
- Smooth migration path for existing users
|
||||
- Improved performance with stateless architecture
|
||||
|
||||
**Could Have**:
|
||||
- Smart project suggestions based on content/context
|
||||
- Project shortcuts for common operations
|
||||
- Advanced project analytics in responses
|
||||
|
||||
## Notes
|
||||
|
||||
### Breaking Changes
|
||||
This is a **breaking change** that requires:
|
||||
- All MCP clients to pass project parameter
|
||||
- Migration of existing workflows
|
||||
- Update of all documentation and examples
|
||||
|
||||
### Implementation Order
|
||||
1. **basic-memory core** - Update MCP tools to accept project parameter (optional initially)
|
||||
2. **Testing** - Verify all clients work with explicit projects
|
||||
3. **Cloud service** - Remove session infrastructure
|
||||
4. **Migration** - Make project parameter mandatory
|
||||
5. **Cleanup** - Remove deprecated tools and middleware
|
||||
|
||||
### Related Issues
|
||||
- Fixes #74 (Claude iOS session state bug)
|
||||
- Implements #75 (Mandatory project parameter architecture)
|
||||
- Enables future horizontal scaling
|
||||
- Simplifies multi-tenant architecture
|
||||
|
||||
### Dependencies
|
||||
- Requires coordination between basic-memory and basic-memory-cloud repositories
|
||||
- Needs client-side updates for smooth transition
|
||||
- Documentation updates across all materials
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
title: 'SPEC-7: POC to spike Tigris/Turso for local access to cloud data'
|
||||
type: spec
|
||||
permalink: specs/spec-7-poc-tigris-turso-local-access-cloud-data
|
||||
tags:
|
||||
- poc
|
||||
- tigris
|
||||
- turso
|
||||
- cloud-storage
|
||||
- architecture
|
||||
- proof-of-concept
|
||||
---
|
||||
|
||||
# SPEC-7: POC to spike Tigris/Turso for local access to cloud data
|
||||
|
||||
## Why
|
||||
|
||||
Current basic-memory-cloud architecture uses Fly volumes for tenant file storage, which creates several limitations:
|
||||
|
||||
1. **Storage Scalability**: Fly volumes require pre-provisioning and don't auto-scale with usage
|
||||
2. **Cost Model**: Volume pricing vs object storage pricing may be less favorable at scale
|
||||
3. **Local Development**: No way for users to mount their cloud tenant files locally for real-time editing
|
||||
4. **Multi-Region**: Volumes are region-locked, limiting global deployment flexibility
|
||||
5. **Backup/Disaster Recovery**: Object storage provides better durability and replication options
|
||||
|
||||
The core insight is that Basic Memory requires POSIX filesystem semantics but could benefit from object storage durability and accessibility. By combining:
|
||||
- **Tigris object storage** for file persistence (via rclone mount)
|
||||
- **Turso/libSQL** for SQLite indexing (replacing local .db files)
|
||||
|
||||
We could enable a revolutionary user experience: **local editing of cloud-stored files** while maintaining Basic Memory's existing filesystem assumptions.
|
||||
|
||||
## What
|
||||
|
||||
This specification defines a proof-of-concept to validate the technical feasibility of the Tigris/Turso architecture for basic-memory-cloud tenants.
|
||||
|
||||
**Affected Areas:**
|
||||
- **Storage Architecture**: Replace Fly volumes with Tigris object storage
|
||||
- **Database Architecture**: Replace local SQLite with Turso remote database
|
||||
- **Container Setup**: Add rclone mounting in tenant containers
|
||||
- **Local Development**: Enable local mounting of cloud tenant data
|
||||
- **Basic Memory Core**: Validate unchanged operation over mounted filesystems
|
||||
|
||||
**Key Components:**
|
||||
- **Tigris Storage**: S3-compatible object storage via Fly.io integration
|
||||
- **rclone NFS Mount**: Native NFS mounting without FUSE dependencies
|
||||
- **Turso Database**: Hosted libSQL for SQLite replacement
|
||||
- **Single-Tenant Model**: One bucket + one database per tenant (simplified isolation)
|
||||
|
||||
## How (High Level)
|
||||
|
||||
### Phase 1: Local POC Validation
|
||||
- [ ] Set up Tigris bucket with test data
|
||||
- [ ] Configure rclone NFS mount locally
|
||||
- [ ] Test Basic Memory operations over mounted filesystem
|
||||
- [ ] Measure performance characteristics and identify issues
|
||||
- [ ] Validate file watching, sync operations, and concurrent access patterns
|
||||
|
||||
### Phase 2: Database Migration
|
||||
- [ ] Set up Turso account and test database
|
||||
- [ ] Modify Basic Memory to accept external DATABASE_URL
|
||||
- [ ] Test all operations with remote SQLite via Turso
|
||||
- [ ] Validate performance and functionality parity
|
||||
|
||||
### Phase 3: Container Integration
|
||||
- [ ] Create container image with rclone + NFS support
|
||||
- [ ] Implement tenant-specific credential management
|
||||
- [ ] Test container startup with automatic mounting
|
||||
- [ ] Validate isolation between tenant containers
|
||||
|
||||
### Phase 4: Local Access Validation
|
||||
- [ ] Test local rclone mounting of tenant data
|
||||
- [ ] Validate real-time file editing experience
|
||||
- [ ] Test conflict resolution and sync behavior
|
||||
- [ ] Measure latency impact on user experience
|
||||
|
||||
### Architecture Overview
|
||||
```
|
||||
Local Development:
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Local rclone │───▶│ Tigris Bucket │◀───│ Tenant Container│
|
||||
│ NFS Mount │ │ (S3 storage) │ │ rclone mount │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Basic Memory │ │ Basic Memory │
|
||||
│ (local files) │ │ (mounted files) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Turso Database │◀───────────────────────────│ Turso Database │
|
||||
│ (shared index) │ │ (shared index) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## How to Evaluate
|
||||
|
||||
### Success Criteria
|
||||
- [ ] **Filesystem Compatibility**: Basic Memory operates without modification over rclone-mounted Tigris storage
|
||||
- [ ] **Performance Acceptable**: File operations complete within 2x local filesystem latency
|
||||
- [ ] **Database Functionality**: All Basic Memory features work with Turso remote SQLite
|
||||
- [ ] **Container Reliability**: Tenant containers start successfully with automatic mounting
|
||||
- [ ] **Local Access**: Users can mount and edit cloud files locally with real-time sync
|
||||
- [ ] **Data Isolation**: Tenant data remains properly isolated using bucket/database separation
|
||||
|
||||
### Testing Procedure
|
||||
1. **Local Filesystem Test**:
|
||||
```bash
|
||||
# Mount Tigris bucket locally
|
||||
rclone nfsmount tigris:test-bucket ~/tigris-test --vfs-cache-mode writes
|
||||
|
||||
# Run Basic Memory operations
|
||||
cd ~/tigris-test && basic-memory sync --watch
|
||||
# Test: create notes, search, file watching, bulk operations
|
||||
```
|
||||
|
||||
2. **Database Migration Test**:
|
||||
```bash
|
||||
# Configure Turso connection
|
||||
export DATABASE_URL="libsql://test-db.turso.io?authToken=..."
|
||||
|
||||
# Test all MCP tools with remote database
|
||||
basic-memory tools # Test each tool functionality
|
||||
```
|
||||
|
||||
3. **Container Integration Test**:
|
||||
```dockerfile
|
||||
# Test container with rclone mounting
|
||||
FROM python:3.12
|
||||
RUN apt-get update && apt-get install -y rclone nfs-common
|
||||
# ... test startup and mounting process
|
||||
```
|
||||
|
||||
4. **Performance Benchmarking**:
|
||||
- File creation/read/write operations (target: <2x local latency)
|
||||
- Search query performance (target: comparable to local SQLite)
|
||||
- File watching responsiveness (target: events within 1-2 seconds)
|
||||
- Concurrent operation handling
|
||||
|
||||
### Risk Assessment
|
||||
**High Risk Items**:
|
||||
- [ ] NFS-over-S3 performance may be insufficient for real-time operations
|
||||
- [ ] File watching (`inotify`) over NFS may be unreliable
|
||||
- [ ] Network interruptions could cause filesystem errors
|
||||
- [ ] Concurrent access patterns might hit S3 rate limits
|
||||
|
||||
**Mitigation Strategies**:
|
||||
- Comprehensive performance testing before committing to architecture
|
||||
- Fallback plan to S3-native storage backend if filesystem approach fails
|
||||
- Extensive error handling and retry logic for network issues
|
||||
|
||||
### Metrics to Track
|
||||
- **Latency**: File operation response times (read/write/watch)
|
||||
- **Reliability**: Success rate of file operations over time
|
||||
- **Throughput**: Concurrent file operations and search queries
|
||||
- **User Experience**: Perceived performance for local mounting use case
|
||||
|
||||
## Notes
|
||||
|
||||
### Key Architectural Decisions
|
||||
- **Single tenant per bucket/database**: Simplifies isolation and credential management
|
||||
- **Maintain POSIX compatibility**: Preserve Basic Memory's existing filesystem assumptions
|
||||
- **NFS over FUSE**: Better compatibility and performance characteristics
|
||||
- **Turso for SQLite**: Leverages specialized remote SQLite expertise
|
||||
|
||||
### Alternative Approaches Considered
|
||||
- **S3-native storage backend**: Would require Basic Memory architecture changes
|
||||
- **Hybrid approach**: Local files + cloud sync (adds complexity)
|
||||
- **FUSE mounting**: More platform dependencies and kernel requirements
|
||||
|
||||
### Integration Points
|
||||
- [ ] Fly.io Tigris integration for bucket provisioning
|
||||
- [ ] Turso account setup and database provisioning
|
||||
- [ ] Container image modifications for rclone support
|
||||
- [ ] Credential management for tenant isolation
|
||||
|
||||
## Observations
|
||||
|
||||
- [architecture] Tigris/Turso split cleanly separates file storage from indexing concerns #storage-separation
|
||||
- [user-experience] Local mounting of cloud files could be revolutionary for knowledge management #local-cloud-hybrid
|
||||
- [compatibility] Maintaining POSIX filesystem assumptions preserves Basic Memory's local/cloud compatibility #architecture-preservation
|
||||
- [simplification] Single tenant per bucket eliminates complex multi-tenancy in storage layer #tenant-isolation
|
||||
- [risk] NFS-over-S3 performance characteristics are unproven for real-time operations #performance-risk
|
||||
- [benefit] Object storage pricing model could be more favorable than volume pricing #cost-optimization
|
||||
- [innovation] Real-time local editing of cloud-stored files addresses major SaaS limitation #competitive-advantage
|
||||
|
||||
## Relations
|
||||
|
||||
- implements [[SPEC-6 Explicit Project Parameter Architecture]]
|
||||
- requires [[Fly.io Tigris Integration]]
|
||||
- enables [[Local Cloud File Access]]
|
||||
- alternative_to [[Fly Volume Storage]]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.14.4"
|
||||
__version__ = "0.20.3"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
+114
-24
@@ -1,17 +1,36 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
|
||||
# Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
try:
|
||||
import nest_asyncio
|
||||
|
||||
nest_asyncio.apply()
|
||||
except (ImportError, ValueError):
|
||||
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
|
||||
pass
|
||||
# For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from alembic import context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
# Trigger: only set test env when actually running under pytest
|
||||
# Why: alembic/env.py is imported during normal operations (MCP server startup, migrations)
|
||||
# but we only want test behavior during actual test runs
|
||||
# Outcome: prevents is_test_env from returning True in production, enabling watch service
|
||||
if os.getenv("PYTEST_CURRENT_TEST") is not None:
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
|
||||
# Import after setting environment variable # noqa: E402
|
||||
from basic_memory.models import Base # noqa: E402
|
||||
@@ -20,12 +39,22 @@ from basic_memory.models import Base # noqa: E402
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
|
||||
# due to Pydantic's env_prefix="BASIC_MEMORY_" setting
|
||||
app_config = ConfigManager().config
|
||||
# Set the SQLAlchemy URL from our app config
|
||||
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
|
||||
# Set the SQLAlchemy URL based on database backend configuration
|
||||
# If the URL is already set in config (e.g., from run_migrations), use that
|
||||
# Otherwise, get it from app config
|
||||
# Note: alembic.ini has a placeholder URL "driver://user:pass@localhost/dbname" that we need to override
|
||||
current_url = config.get_main_option("sqlalchemy.url")
|
||||
if not current_url or current_url == "driver://user:pass@localhost/dbname":
|
||||
from basic_memory.db import DatabaseType
|
||||
|
||||
sqlalchemy_url = DatabaseType.get_db_url(
|
||||
app_config.database_path, DatabaseType.FILESYSTEM, app_config
|
||||
)
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
@@ -69,28 +98,89 @@ def run_migrations_offline() -> None:
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
"""Execute migrations with the given connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations(connectable):
|
||||
"""Run migrations asynchronously with AsyncEngine."""
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
# Check if a connection/engine was provided (e.g., from run_migrations)
|
||||
connectable = context.config.attributes.get("connection", None)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
)
|
||||
if connectable is None:
|
||||
# No connection provided, create engine from config
|
||||
url = context.config.get_main_option("sqlalchemy.url")
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
|
||||
if url and ("+asyncpg" in url or "+aiosqlite" in url):
|
||||
# Create async engine for asyncpg or aiosqlite
|
||||
connectable = create_async_engine(
|
||||
url,
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
else:
|
||||
# Create sync engine for regular sqlite or postgresql
|
||||
connectable = engine_from_config(
|
||||
context.config.get_section(context.config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
# Handle async engines (PostgreSQL with asyncpg)
|
||||
if isinstance(connectable, AsyncEngine):
|
||||
# Try to run async migrations
|
||||
# nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
|
||||
try:
|
||||
asyncio.run(run_async_migrations(connectable))
|
||||
except RuntimeError as e:
|
||||
if "cannot be called from a running event loop" in str(e):
|
||||
# We're in a running event loop (likely uvloop) - need to use a different approach
|
||||
# Create a new thread to run the async migrations
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
"""Run async migrations in a new event loop in a separate thread."""
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
new_loop.run_until_complete(run_async_migrations(connectable))
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
future.result() # Wait for completion and re-raise any exceptions
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# Handle sync engines (SQLite) or sync connections
|
||||
if hasattr(connectable, "connect"):
|
||||
# It's an engine, get a connection
|
||||
with connectable.connect() as connection:
|
||||
do_run_migrations(connection)
|
||||
else:
|
||||
# It's already a connection
|
||||
do_run_migrations(connectable)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"""Add Postgres full-text search support with tsvector and GIN indexes
|
||||
|
||||
Revision ID: 314f1ea54dc4
|
||||
Revises: e7e1f4367280
|
||||
Create Date: 2025-11-15 18:05:01.025405
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "314f1ea54dc4"
|
||||
down_revision: Union[str, None] = "e7e1f4367280"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add PostgreSQL full-text search support.
|
||||
|
||||
This migration:
|
||||
1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
|
||||
2. Adds generated tsvector column for full-text search
|
||||
3. Creates GIN index on the tsvector column for fast text queries
|
||||
4. Creates GIN index on metadata JSONB column for fast containment queries
|
||||
|
||||
Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
|
||||
"""
|
||||
# Check if we're using Postgres
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Create search_index table for Postgres
|
||||
# For SQLite, this is a FTS5 virtual table created elsewhere
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
op.create_table(
|
||||
"search_index",
|
||||
sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
|
||||
sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("content_stems", sa.Text(), nullable=True),
|
||||
sa.Column("content_snippet", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
|
||||
sa.Column("file_path", sa.String(), nullable=True),
|
||||
sa.Column("type", sa.String(), nullable=True),
|
||||
sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("relation_type", sa.String(), nullable=True),
|
||||
sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
|
||||
sa.Column("category", sa.String(), nullable=True),
|
||||
sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", "type", "project_id"
|
||||
), # Composite key: id can repeat across types
|
||||
sa.ForeignKeyConstraint(
|
||||
["project_id"],
|
||||
["project.id"],
|
||||
name="fk_search_index_project_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# Create index on project_id for efficient multi-tenant queries
|
||||
op.create_index(
|
||||
"ix_search_index_project_id",
|
||||
"search_index",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Create unique partial index on permalink for markdown files
|
||||
# Non-markdown files don't have permalinks, so we use a partial index
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uix_search_index_permalink_project
|
||||
ON search_index (permalink, project_id)
|
||||
WHERE permalink IS NOT NULL
|
||||
""")
|
||||
|
||||
# Add tsvector column as a GENERATED ALWAYS column
|
||||
# This automatically updates when title or content_stems change
|
||||
op.execute("""
|
||||
ALTER TABLE search_index
|
||||
ADD COLUMN textsearchable_index_col tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('english',
|
||||
coalesce(title, '') || ' ' ||
|
||||
coalesce(content_stems, '')
|
||||
)
|
||||
) STORED
|
||||
""")
|
||||
|
||||
# Create GIN index on tsvector column for fast full-text search
|
||||
op.create_index(
|
||||
"idx_search_index_fts",
|
||||
"search_index",
|
||||
["textsearchable_index_col"],
|
||||
unique=False,
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
# Create GIN index on metadata JSONB for fast containment queries
|
||||
# Using jsonb_path_ops for smaller index size and better performance
|
||||
op.execute("""
|
||||
CREATE INDEX idx_search_index_metadata_gin
|
||||
ON search_index
|
||||
USING GIN (metadata jsonb_path_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove PostgreSQL full-text search support."""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Drop indexes first
|
||||
op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
|
||||
op.drop_index("idx_search_index_fts", table_name="search_index")
|
||||
op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
|
||||
op.drop_index("ix_search_index_project_id", table_name="search_index")
|
||||
|
||||
# Drop the generated column
|
||||
op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
|
||||
|
||||
# Drop the search_index table
|
||||
op.drop_table("search_index")
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
|
||||
# SQLite FTS5 virtual table handling is SQLite-specific
|
||||
# For Postgres, search_index is a regular table managed by ORM
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
op.create_table(
|
||||
"project",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
@@ -55,7 +61,9 @@ def upgrade() -> None:
|
||||
batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
|
||||
batch_op.drop_index(
|
||||
"uix_entity_permalink",
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.drop_index("ix_entity_file_path")
|
||||
batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
|
||||
@@ -67,12 +75,16 @@ def upgrade() -> None:
|
||||
"uix_entity_permalink_project",
|
||||
["permalink", "project_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
|
||||
|
||||
# drop the search index table. it will be recreated
|
||||
op.drop_table("search_index")
|
||||
# Only drop for SQLite - Postgres uses regular table managed by ORM
|
||||
if is_sqlite:
|
||||
op.drop_table("search_index")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -25,43 +25,51 @@ def upgrade() -> None:
|
||||
The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
|
||||
which breaks project creation when the service sets is_default=False.
|
||||
|
||||
Since SQLite doesn't support dropping specific constraints easily, we'll
|
||||
recreate the table without the problematic constraint.
|
||||
SQLite: Recreate the table without the constraint (no ALTER TABLE support)
|
||||
Postgres: Use ALTER TABLE to drop the constraint directly
|
||||
"""
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
if is_sqlite:
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
else:
|
||||
# For Postgres, we can simply drop the constraint
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("project_is_default_key", type_="unique")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Merge multiple heads
|
||||
|
||||
Revision ID: 6830751f5fb6
|
||||
Revises: a2b3c4d5e6f7, g9a0b3c4d5e6
|
||||
Create Date: 2025-12-29 12:46:46.476268
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6830751f5fb6"
|
||||
down_revision: Union[str, Sequence[str], None] = ("a2b3c4d5e6f7", "g9a0b3c4d5e6")
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Add mtime and size columns to Entity for sync optimization
|
||||
|
||||
Revision ID: 9d9c1cb7d8f5
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-10-20 05:07:55.173849
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9d9c1cb7d8f5"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("entity", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("mtime", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("size", sa.Integer(), nullable=True))
|
||||
batch_op.drop_constraint(batch_op.f("fk_entity_project_id"), type_="foreignkey")
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_entity_project_id"), "project", ["project_id"], ["id"]
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("entity", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f("fk_entity_project_id"), type_="foreignkey")
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_entity_project_id"),
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
batch_op.drop_column("size")
|
||||
batch_op.drop_column("mtime")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Add cascade delete FK from search_index to entity
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: f8a9b2c3d4e5
|
||||
Create Date: 2025-12-02 07:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2b3c4d5e6f7"
|
||||
down_revision: Union[str, None] = "f8a9b2c3d4e5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add FK with CASCADE delete from search_index.entity_id to entity.id.
|
||||
|
||||
This migration is Postgres-only because:
|
||||
- SQLite uses FTS5 virtual tables which don't support foreign keys
|
||||
- The FK enables automatic cleanup of search_index entries when entities are deleted
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# First, clean up any orphaned search_index entries where entity no longer exists
|
||||
op.execute("""
|
||||
DELETE FROM search_index
|
||||
WHERE entity_id IS NOT NULL
|
||||
AND entity_id NOT IN (SELECT id FROM entity)
|
||||
""")
|
||||
|
||||
# Add FK with CASCADE - nullable FK allows search_index entries without entity_id
|
||||
op.create_foreign_key(
|
||||
"fk_search_index_entity_id",
|
||||
"search_index",
|
||||
"entity",
|
||||
["entity_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove the FK constraint."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.drop_constraint("fk_search_index_entity_id", "search_index", type_="foreignkey")
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Upgrade database schema to use new search index with content_stems and content_snippet."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is created via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# First, drop the existing search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
@@ -59,6 +65,13 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade database schema to use old search index."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is managed via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# Drop the updated search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Add structured metadata indexes for entity frontmatter
|
||||
|
||||
Revision ID: d7e8f9a0b1c2
|
||||
Revises: g9a0b3c4d5e6
|
||||
Create Date: 2026-01-31 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d7e8f9a0b1c2"
|
||||
down_revision: Union[str, None] = "6830751f5fb6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add JSONB/GiN indexes for Postgres and generated columns for SQLite."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Ensure JSONB for efficient indexing
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'entity' AND column_name = 'entity_metadata'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] != "jsonb":
|
||||
op.execute(
|
||||
"ALTER TABLE entity ALTER COLUMN entity_metadata "
|
||||
"TYPE jsonb USING entity_metadata::jsonb"
|
||||
)
|
||||
|
||||
# General JSONB GIN index
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_metadata_gin "
|
||||
"ON entity USING GIN (entity_metadata jsonb_path_ops)"
|
||||
)
|
||||
|
||||
# Common field indexes
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_tags_json "
|
||||
"ON entity USING GIN ((entity_metadata -> 'tags'))"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_type "
|
||||
"ON entity ((entity_metadata ->> 'type'))"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_status "
|
||||
"ON entity ((entity_metadata ->> 'status'))"
|
||||
)
|
||||
return
|
||||
|
||||
# SQLite: add generated columns for common frontmatter fields
|
||||
# Constraint: SQLite ALTER TABLE ADD COLUMN only supports VIRTUAL generated columns,
|
||||
# not STORED. json_extract is deterministic so VIRTUAL columns can still be indexed.
|
||||
if not column_exists(connection, "entity", "tags_json"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"tags_json",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.tags')", persisted=False),
|
||||
),
|
||||
)
|
||||
if not column_exists(connection, "entity", "frontmatter_status"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"frontmatter_status",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.status')", persisted=False),
|
||||
),
|
||||
)
|
||||
if not column_exists(connection, "entity", "frontmatter_type"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"frontmatter_type",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.type')", persisted=False),
|
||||
),
|
||||
)
|
||||
|
||||
# Index generated columns
|
||||
if not index_exists(connection, "idx_entity_tags_json"):
|
||||
op.create_index("idx_entity_tags_json", "entity", ["tags_json"])
|
||||
if not index_exists(connection, "idx_entity_frontmatter_status"):
|
||||
op.create_index("idx_entity_frontmatter_status", "entity", ["frontmatter_status"])
|
||||
if not index_exists(connection, "idx_entity_frontmatter_type"):
|
||||
op.create_index("idx_entity_frontmatter_type", "entity", ["frontmatter_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Best-effort downgrade (drop indexes, revert JSONB on Postgres)."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_metadata_gin")
|
||||
op.execute(
|
||||
"ALTER TABLE entity ALTER COLUMN entity_metadata TYPE json USING entity_metadata::json"
|
||||
)
|
||||
return
|
||||
|
||||
# SQLite: drop indexes (dropping generated columns requires table rebuild)
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Add scan watermark tracking to Project
|
||||
|
||||
Revision ID: e7e1f4367280
|
||||
Revises: 9d9c1cb7d8f5
|
||||
Create Date: 2025-10-20 16:42:46.625075
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e7e1f4367280"
|
||||
down_revision: Union[str, None] = "9d9c1cb7d8f5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("last_scan_timestamp", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("last_file_count", sa.Integer(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_file_count")
|
||||
batch_op.drop_column("last_scan_timestamp")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"""Add project_id to relation/observation and pg_trgm for fuzzy link resolution
|
||||
|
||||
Revision ID: f8a9b2c3d4e5
|
||||
Revises: 314f1ea54dc4
|
||||
Create Date: 2025-12-01 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f8a9b2c3d4e5"
|
||||
down_revision: Union[str, None] = "314f1ea54dc4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add project_id to relation and observation tables, plus pg_trgm indexes.
|
||||
|
||||
This migration:
|
||||
1. Adds project_id column to relation and observation tables (denormalization)
|
||||
2. Backfills project_id from the associated entity
|
||||
3. Enables pg_trgm extension for trigram-based fuzzy matching (Postgres only)
|
||||
4. Creates GIN indexes on entity title and permalink for fast similarity searches
|
||||
5. Creates partial index on unresolved relations for efficient bulk resolution
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add project_id to relation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "relation", "project_id"):
|
||||
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via from_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE relation.from_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE relation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = relation.from_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("relation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"relation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_relation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on relation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_relation_project_id"):
|
||||
op.create_index("ix_relation_project_id", "relation", ["project_id"])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add project_id to observation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first (idempotent)
|
||||
if not column_exists(connection, "observation", "project_id"):
|
||||
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
|
||||
# Step 2: Backfill project_id from entity.project_id via entity_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = entity.project_id
|
||||
FROM entity
|
||||
WHERE observation.entity_id = entity.id
|
||||
""")
|
||||
else:
|
||||
# SQLite syntax
|
||||
op.execute("""
|
||||
UPDATE observation
|
||||
SET project_id = (
|
||||
SELECT entity.project_id
|
||||
FROM entity
|
||||
WHERE entity.id = observation.entity_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 3: Make project_id NOT NULL and add foreign key
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("observation", "project_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"observation",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.alter_column("project_id", nullable=False)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_observation_project_id",
|
||||
"project",
|
||||
["project_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Step 4: Create index on observation.project_id (idempotent)
|
||||
if not index_exists(connection, "ix_observation_project_id"):
|
||||
op.create_index("ix_observation_project_id", "observation", ["project_id"])
|
||||
|
||||
# Postgres-specific: pg_trgm and GIN indexes
|
||||
if dialect == "postgresql":
|
||||
# Enable pg_trgm extension for fuzzy string matching
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
|
||||
# Create trigram indexes on entity table for fuzzy matching
|
||||
# GIN indexes with gin_trgm_ops support similarity searches
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_title_trgm
|
||||
ON entity USING gin (title gin_trgm_ops)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_permalink_trgm
|
||||
ON entity USING gin (permalink gin_trgm_ops)
|
||||
""")
|
||||
|
||||
# Create partial index on unresolved relations for efficient bulk resolution
|
||||
# This makes "WHERE to_id IS NULL AND project_id = X" queries very fast
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_unresolved
|
||||
ON relation (project_id, to_name)
|
||||
WHERE to_id IS NULL
|
||||
""")
|
||||
|
||||
# Create index on relation.to_name for join performance in bulk resolution
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_to_name
|
||||
ON relation (to_name)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove project_id from relation/observation and pg_trgm indexes."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Drop Postgres-specific indexes
|
||||
op.execute("DROP INDEX IF EXISTS idx_relation_to_name")
|
||||
op.execute("DROP INDEX IF EXISTS idx_relation_unresolved")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_permalink_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_title_trgm")
|
||||
# Note: We don't drop the pg_trgm extension as other code may depend on it
|
||||
|
||||
# Drop project_id from observation
|
||||
op.drop_index("ix_observation_project_id", table_name="observation")
|
||||
op.drop_constraint("fk_observation_project_id", "observation", type_="foreignkey")
|
||||
op.drop_column("observation", "project_id")
|
||||
|
||||
# Drop project_id from relation
|
||||
op.drop_index("ix_relation_project_id", table_name="relation")
|
||||
op.drop_constraint("fk_relation_project_id", "relation", type_="foreignkey")
|
||||
op.drop_column("relation", "project_id")
|
||||
else:
|
||||
# SQLite requires batch operations
|
||||
op.drop_index("ix_observation_project_id", table_name="observation")
|
||||
with op.batch_alter_table("observation") as batch_op:
|
||||
batch_op.drop_constraint("fk_observation_project_id", type_="foreignkey")
|
||||
batch_op.drop_column("project_id")
|
||||
|
||||
op.drop_index("ix_relation_project_id", table_name="relation")
|
||||
with op.batch_alter_table("relation") as batch_op:
|
||||
batch_op.drop_constraint("fk_relation_project_id", type_="foreignkey")
|
||||
batch_op.drop_column("project_id")
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
"""Add external_id UUID column to project and entity tables
|
||||
|
||||
Revision ID: g9a0b3c4d5e6
|
||||
Revises: f8a9b2c3d4e5
|
||||
Create Date: 2025-12-29 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "g9a0b3c4d5e6"
|
||||
down_revision: Union[str, None] = "f8a9b2c3d4e5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add external_id UUID column to project and entity tables.
|
||||
|
||||
This migration:
|
||||
1. Adds external_id column to project table
|
||||
2. Adds external_id column to entity table
|
||||
3. Generates UUIDs for existing rows
|
||||
4. Creates unique indexes on both columns
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add external_id to project table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
if not column_exists(connection, "project", "external_id"):
|
||||
# Step 1: Add external_id column as nullable first
|
||||
op.add_column("project", sa.Column("external_id", sa.String(), nullable=True))
|
||||
|
||||
# Step 2: Generate UUIDs for existing rows
|
||||
if dialect == "postgresql":
|
||||
# Postgres has gen_random_uuid() function
|
||||
op.execute("""
|
||||
UPDATE project
|
||||
SET external_id = gen_random_uuid()::text
|
||||
WHERE external_id IS NULL
|
||||
""")
|
||||
else:
|
||||
# SQLite: need to generate UUIDs in Python
|
||||
result = connection.execute(text("SELECT id FROM project WHERE external_id IS NULL"))
|
||||
for row in result:
|
||||
new_uuid = str(uuid.uuid4())
|
||||
connection.execute(
|
||||
text("UPDATE project SET external_id = :uuid WHERE id = :id"),
|
||||
{"uuid": new_uuid, "id": row[0]},
|
||||
)
|
||||
|
||||
# Step 3: Make external_id NOT NULL
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("project", "external_id", nullable=False)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("project") as batch_op:
|
||||
batch_op.alter_column("external_id", nullable=False)
|
||||
|
||||
# Step 4: Create unique index on project.external_id (idempotent)
|
||||
if not index_exists(connection, "ix_project_external_id"):
|
||||
op.create_index("ix_project_external_id", "project", ["external_id"], unique=True)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Add external_id to entity table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
if not column_exists(connection, "entity", "external_id"):
|
||||
# Step 1: Add external_id column as nullable first
|
||||
op.add_column("entity", sa.Column("external_id", sa.String(), nullable=True))
|
||||
|
||||
# Step 2: Generate UUIDs for existing rows
|
||||
if dialect == "postgresql":
|
||||
# Postgres has gen_random_uuid() function
|
||||
op.execute("""
|
||||
UPDATE entity
|
||||
SET external_id = gen_random_uuid()::text
|
||||
WHERE external_id IS NULL
|
||||
""")
|
||||
else:
|
||||
# SQLite: need to generate UUIDs in Python
|
||||
result = connection.execute(text("SELECT id FROM entity WHERE external_id IS NULL"))
|
||||
for row in result:
|
||||
new_uuid = str(uuid.uuid4())
|
||||
connection.execute(
|
||||
text("UPDATE entity SET external_id = :uuid WHERE id = :id"),
|
||||
{"uuid": new_uuid, "id": row[0]},
|
||||
)
|
||||
|
||||
# Step 3: Make external_id NOT NULL
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("entity", "external_id", nullable=False)
|
||||
else:
|
||||
# SQLite requires batch operations for ALTER COLUMN
|
||||
with op.batch_alter_table("entity") as batch_op:
|
||||
batch_op.alter_column("external_id", nullable=False)
|
||||
|
||||
# Step 4: Create unique index on entity.external_id (idempotent)
|
||||
if not index_exists(connection, "ix_entity_external_id"):
|
||||
op.create_index("ix_entity_external_id", "entity", ["external_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove external_id columns from project and entity tables."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# Drop from entity table
|
||||
if index_exists(connection, "ix_entity_external_id"):
|
||||
op.drop_index("ix_entity_external_id", table_name="entity")
|
||||
|
||||
if column_exists(connection, "entity", "external_id"):
|
||||
if dialect == "postgresql":
|
||||
op.drop_column("entity", "external_id")
|
||||
else:
|
||||
with op.batch_alter_table("entity") as batch_op:
|
||||
batch_op.drop_column("external_id")
|
||||
|
||||
# Drop from project table
|
||||
if index_exists(connection, "ix_project_external_id"):
|
||||
op.drop_index("ix_project_external_id", table_name="project")
|
||||
|
||||
if column_exists(connection, "project", "external_id"):
|
||||
if dialect == "postgresql":
|
||||
op.drop_column("project", "external_id")
|
||||
else:
|
||||
with op.batch_alter_table("project") as batch_op:
|
||||
batch_op.drop_column("external_id")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Add Postgres semantic vector search tables (pgvector-aware, optional)
|
||||
|
||||
Revision ID: h1b2c3d4e5f6
|
||||
Revises: d7e8f9a0b1c2
|
||||
Create Date: 2026-02-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "h1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "d7e8f9a0b1c2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create Postgres vector chunk metadata table.
|
||||
|
||||
Trigger: database backend is PostgreSQL.
|
||||
Why: search_vector_chunks stores text metadata with no vector-dimension
|
||||
dependency, so it's safe in a migration. search_vector_embeddings (which
|
||||
requires pgvector and a provider-specific dimension) is created at runtime
|
||||
by PostgresSearchRepository._ensure_vector_tables(), mirroring the SQLite
|
||||
pattern where vector tables are created dynamically.
|
||||
Outcome: creates the dimension-independent chunks table. The embeddings
|
||||
table + HNSW index are deferred to runtime.
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS search_vector_chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
chunk_key TEXT NOT NULL,
|
||||
chunk_text TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, entity_id, chunk_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_search_vector_chunks_project_entity
|
||||
ON search_vector_chunks (project_id, entity_id)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove Postgres vector chunk/embedding tables.
|
||||
|
||||
Does not drop pgvector extension because other schema objects may depend on it.
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute("DROP TABLE IF EXISTS search_vector_embeddings")
|
||||
op.execute("DROP TABLE IF EXISTS search_vector_chunks")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Trigger automatic semantic embedding backfill during migration.
|
||||
|
||||
Revision ID: i2c3d4e5f6g7
|
||||
Revises: h1b2c3d4e5f6
|
||||
Create Date: 2026-02-19 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "i2c3d4e5f6g7"
|
||||
down_revision: Union[str, None] = "h1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""No schema change.
|
||||
|
||||
Trigger: this revision is newly applied.
|
||||
Why: db.run_migrations() detects this revision transition and runs the existing
|
||||
sync_entity_vectors() pipeline to backfill semantic embeddings automatically.
|
||||
Outcome: users no longer need to run `bm reindex --embeddings` after upgrading.
|
||||
"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op downgrade."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Rename entity_type column to note_type
|
||||
|
||||
Revision ID: j3d4e5f6g7h8
|
||||
Revises: i2c3d4e5f6g7
|
||||
Create Date: 2026-02-22 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "j3d4e5f6g7h8"
|
||||
down_revision: Union[str, None] = "i2c3d4e5f6g7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(connection, table_name: str) -> bool:
|
||||
"""Check if a table exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM information_schema.tables WHERE table_name = :table_name"),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='table' AND name = :table_name"),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Rename entity_type → note_type on the entity table."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# Skip if already migrated (idempotent)
|
||||
if column_exists(connection, "entity", "note_type"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Postgres supports direct column rename
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
|
||||
|
||||
# Recreate the index with new name
|
||||
op.execute("DROP INDEX IF EXISTS ix_entity_type")
|
||||
op.execute("CREATE INDEX ix_note_type ON entity (note_type)")
|
||||
else:
|
||||
# SQLite 3.25.0+ supports ALTER TABLE RENAME COLUMN directly.
|
||||
# Avoids batch_alter_table which fails on tables with generated columns
|
||||
# (duplicate column name error when recreating the table).
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
|
||||
|
||||
# Recreate the index with new name
|
||||
if index_exists(connection, "ix_entity_type"):
|
||||
op.drop_index("ix_entity_type", table_name="entity")
|
||||
op.create_index("ix_note_type", "entity", ["note_type"])
|
||||
|
||||
# Update search index metadata: rename entity_type → note_type in JSON
|
||||
# This updates the stored metadata so search results use the new field name
|
||||
# Guard: search_index may not exist on a fresh DB (created by an earlier migration)
|
||||
if not table_exists(connection, "search_index"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = metadata - 'entity_type' || jsonb_build_object('note_type', metadata->'entity_type')
|
||||
WHERE metadata ? 'entity_type'
|
||||
""")
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = json_set(
|
||||
json_remove(metadata, '$.entity_type'),
|
||||
'$.note_type',
|
||||
json_extract(metadata, '$.entity_type')
|
||||
)
|
||||
WHERE json_extract(metadata, '$.entity_type') IS NOT NULL
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Rename note_type → entity_type on the entity table."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
|
||||
op.execute("DROP INDEX IF EXISTS ix_note_type")
|
||||
op.execute("CREATE INDEX ix_entity_type ON entity (entity_type)")
|
||||
else:
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
|
||||
|
||||
if index_exists(connection, "ix_note_type"):
|
||||
op.drop_index("ix_note_type", table_name="entity")
|
||||
op.create_index("ix_entity_type", "entity", ["entity_type"])
|
||||
|
||||
# Revert search index metadata
|
||||
if not table_exists(connection, "search_index"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = metadata - 'note_type' || jsonb_build_object('entity_type', metadata->'note_type')
|
||||
WHERE metadata ? 'note_type'
|
||||
""")
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = json_set(
|
||||
json_remove(metadata, '$.note_type'),
|
||||
'$.entity_type',
|
||||
json_extract(metadata, '$.note_type')
|
||||
)
|
||||
WHERE json_extract(metadata, '$.note_type') IS NOT NULL
|
||||
""")
|
||||
)
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""Add created_by and last_updated_by columns to entity table.
|
||||
|
||||
Revision ID: k4e5f6g7h8i9
|
||||
Revises: j3d4e5f6g7h8
|
||||
Create Date: 2026-02-23 00:00:00.000000
|
||||
|
||||
These columns track which cloud user created and last modified each entity.
|
||||
Both are nullable — NULL for local/CLI usage and existing entities.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "k4e5f6g7h8i9"
|
||||
down_revision: Union[str, None] = "j3d4e5f6g7h8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
else:
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add created_by and last_updated_by columns to entity table.
|
||||
|
||||
Both columns are nullable strings that store cloud user_profile_id UUIDs.
|
||||
No data backfill — existing rows get NULL.
|
||||
"""
|
||||
connection = op.get_bind()
|
||||
|
||||
if not column_exists(connection, "entity", "created_by"):
|
||||
op.add_column("entity", sa.Column("created_by", sa.String(), nullable=True))
|
||||
|
||||
if not column_exists(connection, "entity", "last_updated_by"):
|
||||
op.add_column("entity", sa.Column("last_updated_by", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove created_by and last_updated_by columns from entity table."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if column_exists(connection, "entity", "last_updated_by"):
|
||||
if dialect == "postgresql":
|
||||
op.drop_column("entity", "last_updated_by")
|
||||
else:
|
||||
with op.batch_alter_table("entity") as batch_op:
|
||||
batch_op.drop_column("last_updated_by")
|
||||
|
||||
if column_exists(connection, "entity", "created_by"):
|
||||
if dialect == "postgresql":
|
||||
op.drop_column("entity", "created_by")
|
||||
else:
|
||||
with op.batch_alter_table("entity") as batch_op:
|
||||
batch_op.drop_column("created_by")
|
||||
+104
-49
@@ -1,56 +1,82 @@
|
||||
"""FastAPI application for basic-memory knowledge graph API."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import __version__ as version
|
||||
from basic_memory import db
|
||||
from basic_memory.api.routers import (
|
||||
directory_router,
|
||||
importer_router,
|
||||
knowledge,
|
||||
management,
|
||||
memory,
|
||||
project,
|
||||
resource,
|
||||
search,
|
||||
prompt_router,
|
||||
webdav,
|
||||
from basic_memory.api.container import ApiContainer, set_container
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router as v2_knowledge,
|
||||
project_router as v2_project,
|
||||
memory_router as v2_memory,
|
||||
search_router as v2_search,
|
||||
resource_router as v2_resource,
|
||||
directory_router as v2_directory,
|
||||
prompt_router as v2_prompt,
|
||||
importer_router as v2_importer,
|
||||
schema_router as v2_schema,
|
||||
)
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app, initialize_file_sync
|
||||
from basic_memory.api.v2.routers.project_router import (
|
||||
add_project,
|
||||
list_projects,
|
||||
synchronize_projects,
|
||||
)
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.config import init_api_logging
|
||||
from basic_memory.services.exceptions import EntityAlreadyExistsError
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
"""Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
|
||||
|
||||
app_config = ConfigManager().config
|
||||
# Initialize app and database
|
||||
logger.info("Starting Basic Memory API")
|
||||
print(f"fastapi {app_config.projects}")
|
||||
await initialize_app(app_config)
|
||||
# Initialize logging for API (stdout in cloud mode, file otherwise)
|
||||
init_api_logging()
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# start file sync task in background
|
||||
app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
# --- Composition Root ---
|
||||
# Create container and read config (single point of config access)
|
||||
container = ApiContainer.create()
|
||||
set_container(container)
|
||||
app.state.container = container
|
||||
|
||||
# proceed with startup
|
||||
with telemetry.operation(
|
||||
"api.lifecycle.startup",
|
||||
entrypoint="api",
|
||||
mode=container.mode.name.lower(),
|
||||
):
|
||||
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
|
||||
|
||||
await initialize_app(container.config)
|
||||
|
||||
# Cache database connections in app state for performance
|
||||
logger.info("Initializing database and caching connections...")
|
||||
engine, session_maker = await container.init_database()
|
||||
app.state.engine = engine
|
||||
app.state.session_maker = session_maker
|
||||
logger.info("Database connections cached in app state")
|
||||
|
||||
# Create and start sync coordinator (lifecycle centralized in coordinator)
|
||||
sync_coordinator = container.create_sync_coordinator()
|
||||
await sync_coordinator.start()
|
||||
app.state.sync_coordinator = sync_coordinator
|
||||
|
||||
# Proceed with startup
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
if app.state.sync_task:
|
||||
logger.info("Stopping sync...")
|
||||
app.state.sync_task.cancel() # pyright: ignore
|
||||
|
||||
await db.shutdown_db()
|
||||
# Shutdown - coordinator handles clean task cancellation
|
||||
with telemetry.operation(
|
||||
"api.lifecycle.shutdown",
|
||||
entrypoint="api",
|
||||
mode=container.mode.name.lower(),
|
||||
):
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
await sync_coordinator.stop()
|
||||
await container.shutdown_database()
|
||||
|
||||
|
||||
# Initialize FastAPI app
|
||||
@@ -61,23 +87,52 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
|
||||
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Include routers
|
||||
app.include_router(knowledge.router, prefix="/{project}")
|
||||
app.include_router(memory.router, prefix="/{project}")
|
||||
app.include_router(resource.router, prefix="/{project}")
|
||||
app.include_router(search.router, prefix="/{project}")
|
||||
app.include_router(project.project_router, prefix="/{project}")
|
||||
app.include_router(directory_router.router, prefix="/{project}")
|
||||
app.include_router(prompt_router.router, prefix="/{project}")
|
||||
app.include_router(importer_router.router, prefix="/{project}")
|
||||
app.include_router(webdav.router, prefix="/{project}")
|
||||
# Legacy web app proxy paths (compat with /proxy/projects/projects)
|
||||
app.include_router(v2_project, prefix="/proxy/projects")
|
||||
|
||||
# Project resource router works accross projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
# Legacy v1 compat: older CLI versions (v0.18.0 and earlier) call /projects/...
|
||||
# Using router mount causes 307 redirect which proxy doesn't follow, so add explicit routes
|
||||
legacy_router = APIRouter(tags=["legacy"])
|
||||
legacy_router.add_api_route("/projects/projects", list_projects, methods=["GET"])
|
||||
legacy_router.add_api_route("/projects/projects", add_project, methods=["POST"])
|
||||
legacy_router.add_api_route("/projects/config/sync", synchronize_projects, methods=["POST"])
|
||||
app.include_router(legacy_router)
|
||||
|
||||
# Auth routes are handled by FastMCP automatically when auth is enabled
|
||||
# V2 routers are the only public API surface
|
||||
|
||||
|
||||
@app.exception_handler(EntityAlreadyExistsError)
|
||||
async def entity_already_exists_error_handler(request: Request, exc: EntityAlreadyExistsError):
|
||||
"""Handle entity creation conflicts (e.g., file already exists).
|
||||
|
||||
This is expected behavior when users try to create notes that exist,
|
||||
so log at INFO level instead of ERROR.
|
||||
"""
|
||||
logger.info(
|
||||
"Entity already exists",
|
||||
url=str(request.url),
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
error=str(exc),
|
||||
)
|
||||
return await http_exception_handler(
|
||||
request,
|
||||
HTTPException(
|
||||
status_code=409,
|
||||
detail="Note already exists. Use edit_note to modify it, or delete it first.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""API composition root for Basic Memory.
|
||||
|
||||
This container owns reading ConfigManager and environment variables for the
|
||||
API entrypoint. Downstream modules receive config/dependencies explicitly
|
||||
rather than reading globals.
|
||||
|
||||
Design principles:
|
||||
- Only this module reads ConfigManager directly
|
||||
- Runtime mode (cloud/local/test) is resolved here
|
||||
- Factories for services are provided, not singletons
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiContainer:
|
||||
"""Composition root for the API entrypoint.
|
||||
|
||||
Holds resolved configuration and runtime context.
|
||||
Created once at app startup, then used to wire dependencies.
|
||||
"""
|
||||
|
||||
config: BasicMemoryConfig
|
||||
mode: RuntimeMode
|
||||
|
||||
# --- Database ---
|
||||
# Cached database connections (set during lifespan startup)
|
||||
engine: AsyncEngine | None = None
|
||||
session_maker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> "ApiContainer": # pragma: no cover
|
||||
"""Create container by reading ConfigManager.
|
||||
|
||||
This is the single point where API reads global config.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
# --- Runtime Mode Properties ---
|
||||
|
||||
@property
|
||||
def should_sync_files(self) -> bool:
|
||||
"""Whether file sync should be started.
|
||||
|
||||
Sync is enabled when:
|
||||
- sync_changes is True in config
|
||||
- Not in test mode (tests manage their own sync)
|
||||
"""
|
||||
return self.config.sync_changes and not self.mode.is_test
|
||||
|
||||
@property
|
||||
def sync_skip_reason(self) -> str | None: # pragma: no cover
|
||||
"""Reason why sync is skipped, or None if sync should run.
|
||||
|
||||
Useful for logging why sync was disabled.
|
||||
"""
|
||||
if self.mode.is_test:
|
||||
return "Test environment detected"
|
||||
if not self.config.sync_changes:
|
||||
return "Sync changes disabled"
|
||||
return None
|
||||
|
||||
def create_sync_coordinator(self) -> "SyncCoordinator": # pragma: no cover
|
||||
"""Create a SyncCoordinator with this container's settings.
|
||||
|
||||
Returns:
|
||||
SyncCoordinator configured for this runtime environment
|
||||
"""
|
||||
# Deferred import to avoid circular dependency
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
return SyncCoordinator(
|
||||
config=self.config,
|
||||
should_sync=self.should_sync_files,
|
||||
skip_reason=self.sync_skip_reason,
|
||||
)
|
||||
|
||||
# --- Database Factory ---
|
||||
|
||||
async def init_database( # pragma: no cover
|
||||
self,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Initialize and cache database connections.
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, session_maker)
|
||||
"""
|
||||
engine, session_maker = await db.get_or_create_db(self.config.database_path)
|
||||
self.engine = engine
|
||||
self.session_maker = session_maker
|
||||
return engine, session_maker
|
||||
|
||||
async def shutdown_database(self) -> None: # pragma: no cover
|
||||
"""Clean up database connections."""
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
# Module-level container instance (set by lifespan)
|
||||
# This allows deps.py to access the container without reading ConfigManager
|
||||
_container: ApiContainer | None = None
|
||||
|
||||
|
||||
def get_container() -> ApiContainer:
|
||||
"""Get the current API container.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If container hasn't been initialized
|
||||
"""
|
||||
if _container is None:
|
||||
raise RuntimeError("API container not initialized. Call set_container() first.")
|
||||
return _container
|
||||
|
||||
|
||||
def set_container(container: ApiContainer) -> None:
|
||||
"""Set the API container (called by lifespan)."""
|
||||
global _container
|
||||
_container = container
|
||||
@@ -1,12 +0,0 @@
|
||||
"""API routers."""
|
||||
|
||||
from . import knowledge_router as knowledge
|
||||
from . import management_router as management
|
||||
from . import memory_router as memory
|
||||
from . import project_router as project
|
||||
from . import resource_router as resource
|
||||
from . import search_router as search
|
||||
from . import prompt_router as prompt
|
||||
from . import webdav_router as webdav
|
||||
|
||||
__all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt", "webdav"]
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Router for directory tree operations."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode])
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Router for knowledge graph operations."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Query, Response
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityServiceDep,
|
||||
get_search_service,
|
||||
SearchServiceDep,
|
||||
LinkResolverDep,
|
||||
ProjectPathDep,
|
||||
FileServiceDep,
|
||||
ProjectConfigDep,
|
||||
AppConfigDep,
|
||||
SyncServiceDep,
|
||||
)
|
||||
from basic_memory.schemas import (
|
||||
EntityListResponse,
|
||||
EntityResponse,
|
||||
DeleteEntitiesResponse,
|
||||
DeleteEntitiesRequest,
|
||||
)
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
|
||||
## Create endpoints
|
||||
|
||||
|
||||
@router.post("/entities", response_model=EntityResponse)
|
||||
async def create_entity(
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create an entity."""
|
||||
logger.info(
|
||||
"API request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(data)
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
|
||||
async def create_or_update_entity(
|
||||
project: ProjectPathDep,
|
||||
permalink: Permalink,
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
sync_service: SyncServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
|
||||
logger.info(
|
||||
f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
|
||||
)
|
||||
|
||||
# Validate permalink matches
|
||||
if data.permalink != permalink:
|
||||
logger.warning(
|
||||
f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
|
||||
)
|
||||
|
||||
# Try create_or_update operation
|
||||
entity, created = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Attempt immediate relation resolution when creating new entities
|
||||
# This helps resolve forward references when related entities are created in the same session
|
||||
if created:
|
||||
try:
|
||||
await sync_service.resolve_relations()
|
||||
logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
|
||||
except Exception as e: # pragma: no cover
|
||||
# Don't fail the entire request if relation resolution fails
|
||||
logger.warning(f"Failed to resolve relations after entity creation: {e}")
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def edit_entity(
|
||||
identifier: str,
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
|
||||
|
||||
This endpoint allows for targeted edits without requiring the full entity content.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Edit the entity using the service
|
||||
entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex the updated entity
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Return the updated entity response
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="edit_entity",
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
permalink=result.permalink,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_entity(
|
||||
data: MoveEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Move an entity to a new file location with project consistency.
|
||||
|
||||
This endpoint moves a note to a different path while maintaining project
|
||||
consistency and optionally updating permalinks based on configuration.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the entity using the service
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=data.identifier,
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Get the moved entity to reindex it
|
||||
entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="move_entity",
|
||||
identifier=data.identifier,
|
||||
destination=data.destination_path,
|
||||
status_code=200,
|
||||
)
|
||||
result = EntityResponse.model_validate(moved_entity)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def get_entity(
|
||||
entity_service: EntityServiceDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
identifier: str,
|
||||
) -> EntityResponse:
|
||||
"""Get a specific entity by file path or permalink..
|
||||
|
||||
Args:
|
||||
identifier: Entity file path or permalink
|
||||
:param entity_service: EntityService
|
||||
:param link_resolver: LinkResolver
|
||||
"""
|
||||
logger.info(f"request: get_entity with identifier={identifier}")
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/entities", response_model=EntityListResponse)
|
||||
async def get_entities(
|
||||
entity_service: EntityServiceDep,
|
||||
permalink: Annotated[list[str] | None, Query()] = None,
|
||||
) -> EntityListResponse:
|
||||
"""Open specific entities"""
|
||||
logger.info(f"request: get_entities with permalinks={permalink}")
|
||||
|
||||
entities = await entity_service.get_entities_by_permalinks(permalink) if permalink else []
|
||||
result = EntityListResponse(
|
||||
entities=[EntityResponse.model_validate(entity) for entity in entities]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
## Delete endpoints
|
||||
|
||||
|
||||
@router.delete("/entities/{identifier:path}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity(
|
||||
identifier: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service=Depends(get_search_service),
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete a single entity and remove from search index."""
|
||||
logger.info(f"request: delete_entity with identifier={identifier}")
|
||||
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if entity is None:
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
|
||||
|
||||
# Remove from search index (entity, observations, and relations)
|
||||
background_tasks.add_task(search_service.handle_delete, entity)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/entities/delete", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entities(
|
||||
data: DeleteEntitiesRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service=Depends(get_search_service),
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete entities and remove from search index."""
|
||||
logger.info(f"request: delete_entities with data={data}")
|
||||
deleted = False
|
||||
|
||||
# Remove each deleted entity from search index
|
||||
for permalink in data.permalinks:
|
||||
deleted = await entity_service.delete_entity(permalink)
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Management router for basic-memory API."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
|
||||
|
||||
router = APIRouter(prefix="/management", tags=["management"])
|
||||
|
||||
|
||||
class WatchStatusResponse(BaseModel):
|
||||
"""Response model for watch status."""
|
||||
|
||||
running: bool
|
||||
"""Whether the watch service is currently running."""
|
||||
|
||||
|
||||
@router.get("/watch/status", response_model=WatchStatusResponse)
|
||||
async def get_watch_status(request: Request) -> WatchStatusResponse:
|
||||
"""Get the current status of the watch service."""
|
||||
return WatchStatusResponse(
|
||||
running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/watch/start", response_model=WatchStatusResponse)
|
||||
async def start_watch_service(
|
||||
request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
|
||||
) -> WatchStatusResponse:
|
||||
"""Start the watch service if it's not already running."""
|
||||
|
||||
# needed because of circular imports from sync -> app
|
||||
from basic_memory.sync import WatchService
|
||||
from basic_memory.sync.background_sync import create_background_sync_task
|
||||
|
||||
if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
|
||||
# Watch service is already running
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
# Create and start a new watch service
|
||||
logger.info("Starting watch service via management API")
|
||||
|
||||
# Get services needed for the watch task
|
||||
watch_service = WatchService(
|
||||
app_config=app_config,
|
||||
project_repository=project_repository,
|
||||
)
|
||||
|
||||
# Create and store the task
|
||||
watch_task = create_background_sync_task(sync_service, watch_service)
|
||||
request.app.state.watch_task = watch_task
|
||||
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
|
||||
@router.post("/watch/stop", response_model=WatchStatusResponse)
|
||||
async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
|
||||
"""Stop the watch service if it's running."""
|
||||
if request.app.state.watch_task is None or request.app.state.watch_task.done():
|
||||
# Watch service is not running
|
||||
return WatchStatusResponse(running=False)
|
||||
|
||||
# Cancel the running task
|
||||
logger.info("Stopping watch service via management API")
|
||||
request.app.state.watch_task.cancel()
|
||||
|
||||
# Wait for it to be properly cancelled
|
||||
try:
|
||||
await request.app.state.watch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
request.app.state.watch_task = None
|
||||
return WatchStatusResponse(running=False)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Routes for memory:// URI operations."""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
@router.get("/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not type
|
||||
else type
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
recent_context = await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
logger.debug(f"Recent context: {recent_context.model_dump_json()}")
|
||||
return recent_context
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
|
||||
@router.get("/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Router for project management."""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Path, Body
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.deps import ProjectServiceDep, ProjectPathDep
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectList,
|
||||
ProjectItem,
|
||||
ProjectInfoRequest,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
|
||||
# Router for resources in a specific project
|
||||
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
|
||||
project_router = APIRouter(prefix="/project", tags=["project"])
|
||||
|
||||
# Router for managing project resources
|
||||
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
|
||||
|
||||
|
||||
@project_router.get("/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info(
|
||||
project_service: ProjectServiceDep,
|
||||
project: ProjectPathDep,
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the specified Basic Memory project."""
|
||||
return await project_service.get_project_info(project)
|
||||
|
||||
|
||||
@project_router.get("/item", response_model=ProjectItem)
|
||||
async def get_project(
|
||||
project_service: ProjectServiceDep,
|
||||
project: ProjectPathDep,
|
||||
) -> ProjectItem:
|
||||
"""Get bassic info about the specified Basic Memory project."""
|
||||
found_project = await project_service.get_project(project)
|
||||
if not found_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{project}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
return ProjectItem(
|
||||
name=found_project.name,
|
||||
path=found_project.path,
|
||||
is_default=found_project.is_default or False,
|
||||
)
|
||||
|
||||
|
||||
# Update a project
|
||||
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
|
||||
async def update_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to update"),
|
||||
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information in configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to update
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
"""
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project_info = ProjectItem(
|
||||
name=name,
|
||||
path=project_service.projects.get(name, ""),
|
||||
)
|
||||
|
||||
if path:
|
||||
await project_service.move_project(name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_path = path if path else project_service.projects.get(name, "")
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' updated successfully",
|
||||
status="success",
|
||||
default=(name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(name=name, path=updated_path),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# List all available projects
|
||||
@project_resource_router.get("/projects", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectList:
|
||||
"""List all configured projects.
|
||||
|
||||
Returns:
|
||||
A list of all projects with metadata
|
||||
"""
|
||||
projects = await project_service.list_projects()
|
||||
default_project = project_service.default_project
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return ProjectList(
|
||||
projects=project_items,
|
||||
default_project=default_project,
|
||||
)
|
||||
|
||||
|
||||
# Add a new project
|
||||
@project_resource_router.post("/projects", response_model=ProjectStatusResponse)
|
||||
async def add_project(
|
||||
project_data: ProjectInfoRequest,
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Add a new project to configuration and database.
|
||||
|
||||
Args:
|
||||
project_data: The project name and path, with option to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was added
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
await project_service.add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
name=project_data.name, path=project_data.path, is_default=project_data.set_default
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Remove a project
|
||||
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
|
||||
async def remove_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to remove"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Remove a project from configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to remove
|
||||
|
||||
Returns:
|
||||
Response confirming the project was removed
|
||||
"""
|
||||
try:
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{name}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
await project_service.remove_project(name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(name=old_project.name, path=old_project.path),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Set a project as default
|
||||
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to set as default"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project.
|
||||
|
||||
Args:
|
||||
name: The name of the project to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
"""
|
||||
try:
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
# get the new project
|
||||
new_default_project = await project_service.get_project(name)
|
||||
if not new_default_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{name}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
await project_service.set_default_project(name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(name=default_name, path=default_project.path),
|
||||
new_project=ProjectItem(
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Get the default project
|
||||
@project_resource_router.get("/default", response_model=ProjectItem)
|
||||
async def get_default_project(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectItem:
|
||||
"""Get the default project.
|
||||
|
||||
Returns:
|
||||
Response with project default information
|
||||
"""
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
|
||||
|
||||
|
||||
# Synchronize projects between config and database
|
||||
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
|
||||
async def synchronize_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Synchronize projects between configuration file and database.
|
||||
|
||||
Ensures that all projects in the configuration file exist in the database
|
||||
and vice versa.
|
||||
|
||||
Returns:
|
||||
Response confirming synchronization was completed
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message="Projects synchronized successfully between configuration and database",
|
||||
status="success",
|
||||
default=False,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Routes for getting entity content."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigDep,
|
||||
LinkResolverDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
FileServiceDep,
|
||||
EntityRepositoryDep,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import normalize_memory_url
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return {item.id}
|
||||
case SearchItemType.OBSERVATION:
|
||||
return {item.entity_id} # pyright: ignore [reportReturnType]
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = item.from_id
|
||||
to_entity = item.to_id # pyright: ignore [reportReturnType]
|
||||
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
|
||||
@router.get("/{identifier:path}")
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> FileResponse:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
|
||||
# Find single entity by permalink
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
results = [entity] if entity else []
|
||||
|
||||
# pagination for multiple results
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# search using the identifier as a permalink
|
||||
if not results:
|
||||
# if the identifier contains a wildcard, use GLOB search
|
||||
query = (
|
||||
SearchQuery(permalink_match=identifier)
|
||||
if "*" in identifier
|
||||
else SearchQuery(permalink=identifier)
|
||||
)
|
||||
search_results = await search_service.search(query, limit, offset)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the deduplicated entities related to the search results
|
||||
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
|
||||
results = await entity_service.get_entities_by_id(list(entity_ids))
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
entity = results[0]
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
|
||||
# for multiple files, initialize a temporary file for writing the results
|
||||
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
|
||||
temp_file_path = tmp_file.name
|
||||
|
||||
for result in results:
|
||||
# Read content for each entity
|
||||
content = await file_service.read_entity_content(result)
|
||||
memory_url = normalize_memory_url(result.permalink)
|
||||
modified_date = result.updated_at.isoformat()
|
||||
checksum = result.checksum[:8] if result.checksum else ""
|
||||
|
||||
# Prepare the delimited content
|
||||
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
|
||||
response_content += f"\n{content}\n"
|
||||
response_content += "\n"
|
||||
|
||||
# Write content directly to the temporary file in append mode
|
||||
tmp_file.write(response_content)
|
||||
|
||||
# Ensure all content is written to disk
|
||||
tmp_file.flush()
|
||||
|
||||
# Schedule the temporary file to be deleted after the response
|
||||
background_tasks.add_task(cleanup_temp_file, temp_file_path)
|
||||
|
||||
# Return the file response
|
||||
return FileResponse(path=temp_file_path)
|
||||
|
||||
|
||||
def cleanup_temp_file(file_path: str):
|
||||
"""Delete the temporary file."""
|
||||
try:
|
||||
Path(file_path).unlink() # Deletes the file
|
||||
logger.debug(f"Temporary file deleted: {file_path}")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error deleting temporary file {file_path}: {e}")
|
||||
|
||||
|
||||
@router.put("/{file_path:path}")
|
||||
async def write_resource(
|
||||
config: ProjectConfigDep,
|
||||
file_service: FileServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_path: str,
|
||||
content: Annotated[str, Body()],
|
||||
) -> JSONResponse:
|
||||
"""Write content to a file in the project.
|
||||
|
||||
This endpoint allows writing content directly to a file in the project.
|
||||
Also creates an entity record and indexes the file for search.
|
||||
|
||||
Args:
|
||||
file_path: Path to write to, relative to project root
|
||||
request: Contains the content to write
|
||||
|
||||
Returns:
|
||||
JSON response with file information
|
||||
"""
|
||||
try:
|
||||
# Get content from request body
|
||||
|
||||
# Ensure it's UTF-8 string content
|
||||
if isinstance(content, bytes): # pragma: no cover
|
||||
content_str = content.decode("utf-8")
|
||||
else:
|
||||
content_str = str(content)
|
||||
|
||||
# Get full file path
|
||||
full_path = Path(f"{config.home}/{file_path}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write content to file
|
||||
checksum = await file_service.write_file(full_path, content_str)
|
||||
|
||||
# Get file info
|
||||
file_stats = file_service.file_stats(full_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(file_path).name
|
||||
content_type = file_service.content_type(full_path)
|
||||
|
||||
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Check if entity already exists
|
||||
existing_entity = await entity_repository.get_by_file_path(file_path)
|
||||
|
||||
if existing_entity:
|
||||
# Update existing entity
|
||||
entity = await entity_repository.update(
|
||||
existing_entity.id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
"content_type": content_type,
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
},
|
||||
)
|
||||
status_code = 200
|
||||
else:
|
||||
# Create a new entity model
|
||||
entity = EntityModel(
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
file_path=file_path,
|
||||
checksum=checksum,
|
||||
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
||||
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
)
|
||||
entity = await entity_repository.add(entity)
|
||||
status_code = 201
|
||||
|
||||
# Index the file for search
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"size": file_stats.st_size,
|
||||
"created_at": file_stats.st_ctime,
|
||||
"modified_at": file_stats.st_mtime,
|
||||
},
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error writing resource {file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Router for search operations."""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceDep, EntityServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SearchResponse)
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
|
||||
"""Recreate and populate the search index."""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
@@ -1,130 +0,0 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import (
|
||||
EntitySummary,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
MemoryMetadata,
|
||||
GraphContext,
|
||||
ContextResult,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResult
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.services.context_service import (
|
||||
ContextResultRow,
|
||||
ContextResult as ServiceContextResult,
|
||||
)
|
||||
|
||||
|
||||
async def to_graph_context(
|
||||
context_result: ServiceContextResult,
|
||||
entity_repository: EntityRepository,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
# Helper function to convert items to summaries
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
file_path=item.file_path,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
return ObservationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
|
||||
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_entity.title if from_entity else None,
|
||||
to_entity=to_entity.title if to_entity else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
# Process the hierarchical results
|
||||
hierarchical_results = []
|
||||
for context_item in context_result.results:
|
||||
# Process primary result
|
||||
primary_result = await to_summary(context_item.primary_result)
|
||||
|
||||
# Process observations
|
||||
observations = []
|
||||
for obs in context_item.observations:
|
||||
observations.append(await to_summary(obs))
|
||||
|
||||
# Process related results
|
||||
related = []
|
||||
for rel in context_item.related_results:
|
||||
related.append(await to_summary(rel))
|
||||
|
||||
# Add to hierarchical results
|
||||
hierarchical_results.append(
|
||||
ContextResult(
|
||||
primary_result=primary_result,
|
||||
observations=observations,
|
||||
related_results=related,
|
||||
)
|
||||
)
|
||||
|
||||
# Create schema metadata from service metadata
|
||||
metadata = MemoryMetadata(
|
||||
uri=context_result.metadata.uri,
|
||||
types=context_result.metadata.types,
|
||||
depth=context_result.metadata.depth,
|
||||
timeframe=context_result.metadata.timeframe,
|
||||
generated_at=context_result.metadata.generated_at,
|
||||
primary_count=context_result.metadata.primary_count,
|
||||
related_count=context_result.metadata.related_count,
|
||||
total_results=context_result.metadata.primary_count + context_result.metadata.related_count,
|
||||
total_relations=context_result.metadata.total_relations,
|
||||
total_observations=context_result.metadata.total_observations,
|
||||
)
|
||||
|
||||
# Return new GraphContext with just hierarchical results
|
||||
return GraphContext(
|
||||
results=hierarchical_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def to_search_results(entity_service: EntityService, results: List[SearchIndexRow]):
|
||||
search_results = []
|
||||
for r in results:
|
||||
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=r.title, # pyright: ignore
|
||||
type=r.type, # pyright: ignore
|
||||
permalink=r.permalink,
|
||||
score=r.score, # pyright: ignore
|
||||
entity=entities[0].permalink if entities else None,
|
||||
content=r.content,
|
||||
file_path=r.file_path,
|
||||
metadata=r.metadata,
|
||||
category=r.category,
|
||||
from_entity=entities[0].permalink if entities else None,
|
||||
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
||||
relation_type=r.relation_type,
|
||||
)
|
||||
)
|
||||
return search_results
|
||||
@@ -1,353 +0,0 @@
|
||||
"""WebDAV router for basic-memory project uploads."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import aiofiles
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ProjectPathDep, ProjectServiceDep
|
||||
from fastapi import APIRouter, Request, Response, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/webdav",
|
||||
tags=["webdav"],
|
||||
)
|
||||
|
||||
|
||||
async def get_project_path_or_404(project_service: ProjectServiceDep, project: str) -> Path:
|
||||
found_project = await project_service.get_project(project)
|
||||
if not found_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project: '{project}' does not exist")
|
||||
return Path(found_project.path)
|
||||
|
||||
|
||||
async def get_project_file_path_or_404(project_path: Path, path: str) -> Path:
|
||||
file_path = Path(project_path / path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File: '{path}' does not exist")
|
||||
return file_path
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["OPTIONS"])
|
||||
async def webdav_options(
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV OPTIONS endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
file_path = await get_project_file_path_or_404(project_path, path)
|
||||
return await _webdav_options(file_path)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PROPFIND"])
|
||||
async def webdav_propfind(
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV PROPFIND endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
file_path = await get_project_file_path_or_404(project_path, path)
|
||||
return await _webdav_propfind(project, project_path, file_path)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET"])
|
||||
async def webdav_get(
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV GET endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
file_path = await get_project_file_path_or_404(project_path, path)
|
||||
return await _webdav_get(file_path)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PUT"])
|
||||
async def webdav_put(
|
||||
request: Request,
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV PUT endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
file_path = Path(project_path / path)
|
||||
return await _webdav_put(request, project, file_path)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["DELETE"])
|
||||
async def webdav_delete(
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV DELETE endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
file_path = await get_project_file_path_or_404(project_path, path)
|
||||
return await _webdav_delete(project, file_path)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MKCOL"])
|
||||
async def webdav_mkcol(
|
||||
path: str,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV MKCOL endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
dir_path = Path(project_path / path)
|
||||
return await _webdav_mkcol(project, dir_path)
|
||||
|
||||
|
||||
# Handle WebDAV root
|
||||
@router.api_route("/", methods=["OPTIONS", "PROPFIND"])
|
||||
async def webdav_root(
|
||||
request: Request,
|
||||
project: ProjectPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
):
|
||||
"""WebDAV root endpoint."""
|
||||
project_path = await get_project_path_or_404(project_service, project)
|
||||
method = request.method
|
||||
if method == "OPTIONS":
|
||||
return await _webdav_options(project_path)
|
||||
else:
|
||||
return await _webdav_propfind(project, project_path, project_path)
|
||||
|
||||
|
||||
async def _webdav_options(file_path: Path) -> Response:
|
||||
"""Handle WebDAV OPTIONS request."""
|
||||
file_size = file_path.stat().st_size
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={
|
||||
"DAV": "1,2",
|
||||
"MS-Author-Via": "DAV",
|
||||
"Allow": "OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,LOCK,UNLOCK,PUT",
|
||||
"Content-Length": f"{file_size}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _webdav_propfind(project: str, project_path: Path, file_path: Path) -> Response:
|
||||
"""Handle WebDAV PROPFIND request to list directory contents."""
|
||||
|
||||
# Calculate relative path from project root
|
||||
try:
|
||||
relative_path = file_path.relative_to(project_path)
|
||||
relative_path_str = str(relative_path).replace("\\", "/")
|
||||
if relative_path_str == ".":
|
||||
relative_path_str = ""
|
||||
except ValueError:
|
||||
# file_path is not under project_path
|
||||
relative_path_str = ""
|
||||
|
||||
# Build minimal PROPFIND response
|
||||
if file_path.is_dir():
|
||||
# Directory listing
|
||||
href_path = (
|
||||
f"/{project}/webdav/{relative_path_str}/"
|
||||
if relative_path_str
|
||||
else f"/{project}/webdav/"
|
||||
)
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>{href_path}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:displayname>{file_path.name if file_path.name else project}</D:displayname>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
"""
|
||||
|
||||
# Add child items
|
||||
for child in file_path.iterdir():
|
||||
# Calculate child relative path
|
||||
child_relative = child.relative_to(project_path)
|
||||
child_relative_str = str(child_relative).replace("\\", "/")
|
||||
|
||||
if child.is_dir():
|
||||
child_href = f"/{project}/webdav/{child_relative_str}/"
|
||||
xml_response += f"""<D:response>
|
||||
<D:href>{child_href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:displayname>{child.name}</D:displayname>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
"""
|
||||
else:
|
||||
child_href = f"/{project}/webdav/{child_relative_str}"
|
||||
file_size = child.stat().st_size
|
||||
xml_response += f"""<D:response>
|
||||
<D:href>{child_href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname>{child.name}</D:displayname>
|
||||
<D:getcontentlength>{file_size}</D:getcontentlength>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
"""
|
||||
|
||||
xml_response += "</D:multistatus>"
|
||||
else:
|
||||
# File properties
|
||||
href_path = f"/{project}/webdav/{relative_path_str}"
|
||||
file_size = file_path.stat().st_size
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>{href_path}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname>{file_path.name}</D:displayname>
|
||||
<D:getcontentlength>{file_size}</D:getcontentlength>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"""
|
||||
|
||||
return Response(content=xml_response, status_code=207, media_type="text/xml; charset=utf-8")
|
||||
|
||||
|
||||
async def _webdav_get(file_path: Path) -> Response:
|
||||
"""Handle WebDAV GET request to download file."""
|
||||
|
||||
async def file_generator():
|
||||
async with aiofiles.open(file_path, "rb") as file:
|
||||
while chunk := await file.read(8192):
|
||||
yield chunk
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
headers = {"Content-Length": str(file_size), "Content-Type": "application/octet-stream"}
|
||||
|
||||
return StreamingResponse(file_generator(), status_code=200, headers=headers)
|
||||
|
||||
|
||||
async def _webdav_put(request: Request, project: str, file_path: Path) -> Response:
|
||||
"""Handle WebDAV PUT request to upload file."""
|
||||
|
||||
# Check if file exists before writing (for correct HTTP status)
|
||||
file_existed = file_path.exists()
|
||||
|
||||
# Ensure parent directory exists
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file content
|
||||
try:
|
||||
async with aiofiles.open(file_path, "wb") as file:
|
||||
async for chunk in request.stream():
|
||||
await file.write(chunk)
|
||||
|
||||
# Preserve timestamps if provided in headers
|
||||
await _preserve_file_timestamps(request, file_path)
|
||||
|
||||
logger.info(f"WebDAV: Uploaded file {file_path} to project {project}.")
|
||||
|
||||
return Response(status_code=204 if file_existed else 201)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebDAV: Failed to upload file {file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload file: {e}") from e
|
||||
|
||||
|
||||
async def _preserve_file_timestamps(request: Request, file_path: Path) -> None:
|
||||
"""Preserve file timestamps from WebDAV headers if provided.
|
||||
|
||||
Supports multiple timestamp header formats:
|
||||
- X-OC-Mtime: Unix timestamp (ownCloud/Nextcloud format)
|
||||
- X-Timestamp: Unix timestamp
|
||||
- X-Mtime: Unix timestamp
|
||||
- Last-Modified: HTTP date format
|
||||
"""
|
||||
|
||||
# Try different header formats for modification time
|
||||
mtime_timestamp = None
|
||||
|
||||
# Check for custom timestamp headers (Unix timestamp)
|
||||
for header_name in ["X-OC-Mtime", "X-Timestamp", "X-Mtime"]:
|
||||
if header_name in request.headers:
|
||||
try:
|
||||
mtime_timestamp = float(request.headers[header_name])
|
||||
logger.debug(f"Using {header_name} timestamp: {mtime_timestamp}")
|
||||
break
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid timestamp in {header_name} header: {e}")
|
||||
continue
|
||||
|
||||
# Fall back to Last-Modified header if no custom timestamp found
|
||||
if mtime_timestamp is None and "Last-Modified" in request.headers:
|
||||
try:
|
||||
# Parse HTTP date format
|
||||
last_modified_str = request.headers["Last-Modified"]
|
||||
dt = datetime.strptime(last_modified_str, "%a, %d %b %Y %H:%M:%S GMT")
|
||||
# Replace with UTC timezone to ensure correct timestamp calculation
|
||||
from datetime import timezone
|
||||
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
mtime_timestamp = dt.timestamp()
|
||||
logger.debug(f"Using Last-Modified timestamp: {mtime_timestamp}")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid Last-Modified header format: {e}")
|
||||
|
||||
# Apply timestamp if we found one
|
||||
if mtime_timestamp is not None:
|
||||
try:
|
||||
# Use os.utime to set both access and modification times
|
||||
# Set access time to modification time to keep them consistent
|
||||
os.utime(file_path, (mtime_timestamp, mtime_timestamp))
|
||||
logger.debug(f"Set file timestamps for {file_path} to {mtime_timestamp}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to set timestamps for {file_path}: {e}")
|
||||
else:
|
||||
logger.debug(f"No timestamp headers found, using current time for {file_path}")
|
||||
|
||||
|
||||
async def _webdav_delete(project: str, file_path: Path) -> Response:
|
||||
"""Handle WebDAV DELETE request to delete file or directory."""
|
||||
|
||||
try:
|
||||
if file_path.is_dir():
|
||||
shutil.rmtree(file_path)
|
||||
else:
|
||||
file_path.unlink()
|
||||
|
||||
logger.info(f"WebDAV: Deleted {file_path} for project {project}")
|
||||
return Response(status_code=204)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebDAV: Failed to delete {file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") from e
|
||||
|
||||
|
||||
async def _webdav_mkcol(project: str, dir_path: Path) -> Response:
|
||||
"""Handle WebDAV MKCOL request to create directory."""
|
||||
|
||||
if dir_path.exists():
|
||||
raise HTTPException(status_code=405, detail="Directory already exists")
|
||||
|
||||
try:
|
||||
dir_path.mkdir(parents=True, exist_ok=False)
|
||||
logger.info(f"WebDAV: Created directory {dir_path} for project {project}")
|
||||
return Response(status_code=201)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebDAV: Failed to create directory {dir_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}") from e
|
||||
@@ -0,0 +1,35 @@
|
||||
"""API v2 module - ID-based entity references.
|
||||
|
||||
Version 2 of the Basic Memory API uses integer entity IDs as the primary
|
||||
identifier for improved performance and stability.
|
||||
|
||||
Key changes from v1:
|
||||
- Entity lookups use integer IDs instead of paths/permalinks
|
||||
- Direct database queries instead of cascading resolution
|
||||
- Stable references that don't change with file moves
|
||||
- Better caching support
|
||||
|
||||
All v2 routers are registered with the /v2 prefix.
|
||||
"""
|
||||
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router,
|
||||
memory_router,
|
||||
project_router,
|
||||
resource_router,
|
||||
search_router,
|
||||
directory_router,
|
||||
prompt_router,
|
||||
importer_router,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"memory_router",
|
||||
"project_router",
|
||||
"resource_router",
|
||||
"search_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""V2 API routers."""
|
||||
|
||||
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
|
||||
from basic_memory.api.v2.routers.project_router import router as project_router
|
||||
from basic_memory.api.v2.routers.memory_router import router as memory_router
|
||||
from basic_memory.api.v2.routers.search_router import router as search_router
|
||||
from basic_memory.api.v2.routers.resource_router import router as resource_router
|
||||
from basic_memory.api.v2.routers.directory_router import router as directory_router
|
||||
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
|
||||
from basic_memory.api.v2.routers.importer_router import router as importer_router
|
||||
from basic_memory.api.v2.routers.schema_router import router as schema_router
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"project_router",
|
||||
"memory_router",
|
||||
"search_router",
|
||||
"resource_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
"schema_router",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""V2 Directory Router - ID-based directory tree operations.
|
||||
|
||||
This router provides directory structure browsing for projects using
|
||||
external_id UUIDs instead of name-based identifiers.
|
||||
|
||||
Key improvements:
|
||||
- Direct project lookup via external_id UUIDs
|
||||
- Consistent with other v2 endpoints
|
||||
- Better performance through indexed queries
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Path
|
||||
|
||||
from basic_memory.deps import DirectoryServiceV2ExternalDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory-v2"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Project external UUID
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_structure(
|
||||
directory_service: DirectoryServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Get folder structure for navigation (no files).
|
||||
|
||||
Optimized endpoint for folder tree navigation. Returns only directory nodes
|
||||
without file metadata. For full tree with files, use /directory/tree.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Project external UUID
|
||||
|
||||
Returns:
|
||||
DirectoryNode tree containing only folders (type="directory")
|
||||
"""
|
||||
structure = await directory_service.get_directory_structure()
|
||||
return structure
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Project external UUID
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
+60
-31
@@ -1,15 +1,19 @@
|
||||
"""Import router for Basic Memory API."""
|
||||
"""V2 Import Router - ID-based data import operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project handling with external_id UUIDs.
|
||||
Import endpoints use project_id in the path for consistency with other v2 endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status, Path
|
||||
|
||||
from basic_memory.deps import (
|
||||
ChatGPTImporterDep,
|
||||
ClaudeConversationsImporterDep,
|
||||
ClaudeProjectsImporterDep,
|
||||
MemoryJsonImporterDep,
|
||||
ChatGPTImporterV2ExternalDep,
|
||||
ClaudeConversationsImporterV2ExternalDep,
|
||||
ClaudeProjectsImporterV2ExternalDep,
|
||||
MemoryJsonImporterV2ExternalDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
@@ -20,21 +24,23 @@ from basic_memory.schemas.importer import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/import", tags=["import"])
|
||||
router = APIRouter(prefix="/import", tags=["import-v2"])
|
||||
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
importer: ChatGPTImporterDep,
|
||||
importer: ChatGPTImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
directory: The directory to place the files in.
|
||||
importer: ChatGPT importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
@@ -42,21 +48,24 @@ async def import_chatgpt(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
importer: ClaudeConversationsImporterDep,
|
||||
importer: ClaudeConversationsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
directory: The directory to place the files in.
|
||||
importer: Claude conversations importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
@@ -64,21 +73,24 @@ async def import_claude_conversations(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
logger.info(f"V2 Importing Claude conversations for project {project_id}")
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
importer: ClaudeProjectsImporterDep,
|
||||
importer: ClaudeProjectsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("projects"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
directory: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude projects.json file.
|
||||
base_folder: The base folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
directory: The base directory to place the files in.
|
||||
importer: Claude projects importer instance.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult with import statistics.
|
||||
@@ -86,21 +98,24 @@ async def import_claude_projects(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
logger.info(f"V2 Importing Claude projects for project {project_id}")
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
importer: MemoryJsonImporterDep,
|
||||
importer: MemoryJsonImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The memory.json file.
|
||||
destination_folder: Optional destination folder within the project.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
directory: Optional destination directory within the project.
|
||||
importer: Memory JSON importer instance.
|
||||
|
||||
Returns:
|
||||
EntityImportResult with import statistics.
|
||||
@@ -108,6 +123,7 @@ async def import_memory_json(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing memory.json for project {project_id}")
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
@@ -116,14 +132,14 @@ async def import_memory_json(
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
result = await importer.import_data(file_data, directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
@@ -131,11 +147,24 @@ async def import_memory_json(
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
|
||||
"""Helper function to import a file using an importer instance.
|
||||
|
||||
Args:
|
||||
importer: The importer instance to use
|
||||
file: The file to import
|
||||
destination_directory: Destination directory for imported content
|
||||
|
||||
Returns:
|
||||
Import result from the importer
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails
|
||||
"""
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
result = await importer.import_data(json_data, destination_directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -145,7 +174,7 @@ async def import_file(importer: Importer, file: UploadFile, destination_folder:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
@@ -0,0 +1,891 @@
|
||||
"""V2 Knowledge Router - External ID-based entity operations.
|
||||
|
||||
This router provides external_id (UUID) based CRUD operations for entities,
|
||||
using stable string UUIDs that won't change with file moves or database migrations.
|
||||
|
||||
Key improvements:
|
||||
- Stable external UUIDs that won't change with file moves or renames
|
||||
- Better API ergonomics with consistent string identifiers
|
||||
- Direct database lookups via unique indexed column
|
||||
- Simplified caching strategies
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.deps import (
|
||||
EntityServiceV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
LinkResolverV2ExternalDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
AppConfigDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
RelationRepositoryV2ExternalDep,
|
||||
ProjectExternalIdPathDep,
|
||||
TaskSchedulerDep,
|
||||
FileServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.schemas.request import EditEntityRequest
|
||||
from basic_memory.schemas.v2 import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphResponse,
|
||||
MoveEntityRequestV2,
|
||||
MoveDirectoryRequestV2,
|
||||
DeleteDirectoryRequestV2,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
|
||||
def _schedule_vector_sync_if_enabled(
|
||||
*,
|
||||
task_scheduler,
|
||||
app_config,
|
||||
entity_id: int,
|
||||
project_id: int,
|
||||
) -> None:
|
||||
"""Schedule out-of-band vector sync only when semantic search is enabled."""
|
||||
if app_config.semantic_search_enabled:
|
||||
task_scheduler.schedule(
|
||||
"sync_entity_vectors",
|
||||
entity_id=entity_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
## Graph endpoint
|
||||
|
||||
|
||||
@router.get("/graph", response_model=GraphResponse)
|
||||
async def get_graph(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
relation_repository: RelationRepositoryV2ExternalDep,
|
||||
) -> GraphResponse:
|
||||
"""Return all entities and resolved relations for knowledge graph visualization.
|
||||
|
||||
Returns a flat node/edge structure optimized for rendering with graph libraries.
|
||||
Only includes resolved relations (where to_id is not null).
|
||||
"""
|
||||
logger.info("API v2 request: get_graph")
|
||||
|
||||
# Fetch all entities for this project
|
||||
entities = await entity_repository.find_all(use_load_options=False)
|
||||
nodes = [
|
||||
GraphNode(
|
||||
external_id=entity.external_id,
|
||||
title=entity.title,
|
||||
note_type=entity.note_type,
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
for entity in entities
|
||||
]
|
||||
|
||||
# Fetch all resolved relations (to_id is not null) with eager-loaded entities
|
||||
relations = await relation_repository.find_all()
|
||||
edges = [
|
||||
GraphEdge(
|
||||
from_id=relation.from_entity.external_id,
|
||||
to_id=relation.to_entity.external_id,
|
||||
relation_type=relation.relation_type,
|
||||
)
|
||||
for relation in relations
|
||||
if relation.to_entity is not None
|
||||
]
|
||||
|
||||
logger.info(f"API v2 response: graph with {len(nodes)} nodes and {len(edges)} edges")
|
||||
return GraphResponse(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
## Resolution endpoint
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=EntityResolveResponse)
|
||||
async def resolve_identifier(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
data: EntityResolveRequest,
|
||||
link_resolver: LinkResolverV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
) -> EntityResolveResponse:
|
||||
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
|
||||
|
||||
This endpoint provides a bridge between v1-style identifiers and v2 external_ids.
|
||||
Use this to convert existing references to the new UUID-based format.
|
||||
|
||||
Args:
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Entity external_id and metadata about how it was resolved
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if identifier cannot be resolved
|
||||
|
||||
Example:
|
||||
POST /v2/{project_id}/knowledge/resolve
|
||||
{"identifier": "specs/search"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"external_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"entity_id": 123,
|
||||
"permalink": "specs/search",
|
||||
"file_path": "specs/search.md",
|
||||
"title": "Search Specification",
|
||||
"resolution_method": "permalink"
|
||||
}
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.knowledge.resolve_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
):
|
||||
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.lookup_entity",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="lookup_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(data.identifier)
|
||||
resolution_method = "external_id" if entity else "search"
|
||||
|
||||
if not entity:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.resolve_link",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="resolve_link",
|
||||
):
|
||||
entity = await link_resolver.resolve_link(
|
||||
data.identifier, source_path=data.source_path, strict=data.strict
|
||||
)
|
||||
if entity:
|
||||
if entity.permalink == data.identifier:
|
||||
resolution_method = "permalink"
|
||||
elif entity.title == data.identifier:
|
||||
resolution_method = "title"
|
||||
elif entity.file_path == data.identifier:
|
||||
resolution_method = "path"
|
||||
else:
|
||||
resolution_method = "search"
|
||||
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.resolve_entity.shape_response",
|
||||
domain="knowledge",
|
||||
action="resolve_entity",
|
||||
phase="shape_response",
|
||||
):
|
||||
result = EntityResolveResponse(
|
||||
external_id=entity.external_id,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def get_entity_by_id(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Get an entity by its external ID (UUID).
|
||||
|
||||
This is the primary entity retrieval method in v2, using stable UUID
|
||||
identifiers that won't change with file moves.
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
|
||||
Returns:
|
||||
Complete entity with observations and relations
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.knowledge.get_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="get_entity",
|
||||
):
|
||||
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.get_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="get_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.get_entity.shape_response",
|
||||
domain="knowledge",
|
||||
action="get_entity",
|
||||
phase="shape_response",
|
||||
):
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
logger.info(f"API v2 response: external_id={entity_id}, title='{result.title}'")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Create endpoints
|
||||
|
||||
|
||||
@router.post("/entities", response_model=EntityResponseV2)
|
||||
async def create_entity(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
data: Entity data to create
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Created entity with generated external_id (UUID) and file content
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.knowledge.create_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", note_type=data.note_type, title=data.title
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data)
|
||||
written_content = None
|
||||
search_content = None
|
||||
else:
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.create_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="create_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
## Update endpoints
|
||||
|
||||
|
||||
@router.put("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def update_entity_by_id(
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by external ID.
|
||||
|
||||
If the entity doesn't exist, it will be created (upsert behavior).
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Updated entity data
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity with file content
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.knowledge.update_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
existing = await entity_repository.get_by_external_id(entity_id)
|
||||
created = existing is None
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data, external_id=entity_id)
|
||||
written_content = None
|
||||
search_content = None
|
||||
response.status_code = 200 if existing else 201
|
||||
else:
|
||||
if existing:
|
||||
write_result = await entity_service.update_entity_with_content(existing, data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
response.status_code = 200
|
||||
else:
|
||||
write_result = await entity_service.create_entity_with_content(data)
|
||||
entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
if entity.external_id != entity_id:
|
||||
entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{"external_id": entity_id},
|
||||
)
|
||||
# external_id fixup only changes the DB row. The file content is unchanged,
|
||||
# so the markdown captured during the write remains valid downstream.
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Entity with external_id '{entity_id}' not found",
|
||||
)
|
||||
response.status_code = 201
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
resolve_relations=created,
|
||||
)
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.update_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="update_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, created={created}, status_code={response.status_code}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def edit_entity_by_id(
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by external ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Edit operation details
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity with file content
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 if edit fails
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.knowledge.edit_entity",
|
||||
entrypoint="api",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
fast=fast,
|
||||
):
|
||||
logger.info(
|
||||
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.load_entity",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="load_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
try:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.write_entity",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="write_entity",
|
||||
fast=fast,
|
||||
):
|
||||
if fast:
|
||||
updated_entity = await entity_service.fast_edit_entity(
|
||||
entity=entity,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
written_content = None
|
||||
search_content = None
|
||||
else:
|
||||
identifier = entity.permalink or entity.file_path
|
||||
write_result = await entity_service.edit_entity_with_content(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
updated_entity = write_result.entity
|
||||
written_content = write_result.content
|
||||
search_content = write_result.search_content
|
||||
|
||||
if fast:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.enqueue_reindex",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="enqueue_reindex",
|
||||
fast=fast,
|
||||
):
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.search_index",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(updated_entity, content=search_content)
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.vector_sync",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="vector_sync",
|
||||
):
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
with telemetry.scope(
|
||||
"api.knowledge.edit_entity.read_content",
|
||||
domain="knowledge",
|
||||
action="edit_entity",
|
||||
phase="read_content",
|
||||
source="file" if fast else "memory",
|
||||
):
|
||||
if fast:
|
||||
content = await file_service.read_file_content(updated_entity.file_path)
|
||||
else:
|
||||
# Non-fast writes already captured the markdown in memory. Reuse it here
|
||||
# instead of re-reading the file; format_on_save is the one config that can
|
||||
# still make the persisted file diverge because write_file only returns a checksum.
|
||||
content = written_content
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity {entity_id}: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete endpoints
|
||||
|
||||
|
||||
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity_by_id(
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
search_service=Depends(lambda: None), # Optional for now
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete an entity by external ID.
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
|
||||
Returns:
|
||||
Deletion status
|
||||
|
||||
Note: Returns deleted=False if entity doesn't exist (idempotent)
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: external_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity using internal ID
|
||||
deleted = await entity_service.delete_entity(entity.id)
|
||||
|
||||
# Remove from search index if search service available
|
||||
if search_service:
|
||||
background_tasks.add_task(search_service.handle_delete, entity) # pragma: no cover
|
||||
|
||||
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
|
||||
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
|
||||
|
||||
## Move endpoint
|
||||
|
||||
|
||||
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
|
||||
async def move_entity(
|
||||
data: MoveEntityRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Move an entity to a new file location.
|
||||
|
||||
V2 API uses external_id (UUID) in the URL path for stable references.
|
||||
The external_id will remain stable after the move.
|
||||
|
||||
Args:
|
||||
project_id: Project external ID from URL path
|
||||
entity_id: Entity external ID from URL path (primary identifier)
|
||||
data: Move request with destination path only
|
||||
|
||||
Returns:
|
||||
Updated entity with new file path
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# First, get the entity by external_id to verify it exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
# Move the entity using its current file path as identifier
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=entity.file_path, # Use file path for resolution
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=reindexed_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
logger.info(f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'")
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException: # pragma: no cover
|
||||
raise # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Move directory endpoint
|
||||
|
||||
|
||||
@router.post("/move-directory", response_model=DirectoryMoveResult)
|
||||
async def move_directory(
|
||||
data: MoveDirectoryRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
V2 API uses project external_id in the URL path for stable references.
|
||||
Moves all files within a source directory to a destination directory,
|
||||
updating database records and optionally updating permalinks.
|
||||
|
||||
Args:
|
||||
project_id: Project external ID from URL path
|
||||
data: Move request with source and destination directories
|
||||
|
||||
Returns:
|
||||
DirectoryMoveResult with counts and details of moved files
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity)
|
||||
_schedule_vector_sync_if_enabled(
|
||||
task_scheduler=task_scheduler,
|
||||
app_config=app_config,
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: move_directory "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete directory endpoint
|
||||
|
||||
|
||||
@router.post("/delete-directory", response_model=DirectoryDeleteResult)
|
||||
async def delete_directory(
|
||||
data: DeleteDirectoryRequestV2,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
) -> DirectoryDeleteResult:
|
||||
"""Delete all entities in a directory.
|
||||
|
||||
V2 API uses project external_id in the URL path for stable references.
|
||||
Deletes all files within a directory, updating database records and
|
||||
removing files from the filesystem.
|
||||
|
||||
Args:
|
||||
project_id: Project external ID from URL path
|
||||
data: Delete request with directory path
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResult with counts and details of deleted files
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
|
||||
|
||||
try:
|
||||
# Delete the directory using the service
|
||||
result = await entity_service.delete_directory(
|
||||
directory=data.directory,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: delete_directory "
|
||||
f"total={result.total_files}, success={result.successful_deletes}, failed={result.failed_deletes}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,182 @@
|
||||
"""V2 routes for memory:// URI operations.
|
||||
|
||||
This router uses external_id UUIDs for stable, API-friendly routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.v2.utils import to_graph_context
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
|
||||
@router.get("/memory/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
context_service: ContextServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity context for a project.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
type: Types of items to include (entities, relations, observations)
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Time window for recent activity (e.g., "7d", "1 week")
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include per item
|
||||
|
||||
Returns:
|
||||
GraphContext with recent activity and related entities
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.memory.recent_activity",
|
||||
entrypoint="api",
|
||||
domain="memory",
|
||||
action="recent_activity",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not type
|
||||
else type
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with telemetry.scope(
|
||||
"api.memory.recent_activity.build_context",
|
||||
domain="memory",
|
||||
action="recent_activity",
|
||||
phase="build_context",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
context = await context_service.build_context(
|
||||
types=types,
|
||||
depth=depth,
|
||||
since=since,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
max_related=max_related,
|
||||
)
|
||||
with telemetry.scope(
|
||||
"api.memory.recent_activity.shape_response",
|
||||
domain="memory",
|
||||
action="recent_activity",
|
||||
phase="shape_response",
|
||||
result_count=len(context.results),
|
||||
):
|
||||
recent_context = await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
|
||||
return recent_context
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
|
||||
@router.get("/memory/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
context_service: ContextServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
uri: str,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
depth: int = 1,
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI.
|
||||
|
||||
V2 supports both legacy path-based URIs and new ID-based URIs:
|
||||
- Legacy: memory://path/to/note
|
||||
- ID-based: memory://id/123 or memory://123
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Optional time window for filtering related content
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include
|
||||
|
||||
Returns:
|
||||
GraphContext with the entity and its related context
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.memory.build_context",
|
||||
entrypoint="api",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
logger.debug(
|
||||
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with telemetry.scope(
|
||||
"api.memory.build_context.build_context",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
phase="build_context",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
context = await context_service.build_context(
|
||||
memory_url,
|
||||
depth=depth,
|
||||
since=since,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
max_related=max_related,
|
||||
)
|
||||
with telemetry.scope(
|
||||
"api.memory.build_context.shape_response",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
phase="shape_response",
|
||||
result_count=len(context.results),
|
||||
):
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
@@ -0,0 +1,550 @@
|
||||
"""V2 Project Router - External ID-based project management operations.
|
||||
|
||||
This router provides external_id (UUID) based CRUD operations for projects,
|
||||
using stable string UUIDs that never change (unlike integer IDs or names).
|
||||
|
||||
Key improvements:
|
||||
- Stable external UUIDs that won't change with renames or database migrations
|
||||
- Better API ergonomics with consistent string identifiers
|
||||
- Direct database lookups via unique indexed column
|
||||
- Consistent with v2 entity operations
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body, Query, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectServiceDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
SyncServiceV2ExternalDep,
|
||||
TaskSchedulerDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas import SyncReportResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectList,
|
||||
ProjectInfoRequest,
|
||||
ProjectInfoResponse,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
|
||||
from basic_memory.utils import normalize_project_path, generate_permalink
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectList:
|
||||
"""List all configured projects.
|
||||
|
||||
Returns:
|
||||
A list of all projects with metadata
|
||||
"""
|
||||
projects = await project_service.list_projects()
|
||||
default_project = await project_service.get_default_project_name()
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
id=project.id,
|
||||
external_id=project.external_id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return ProjectList(
|
||||
projects=project_items,
|
||||
default_project=default_project,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectStatusResponse, status_code=201)
|
||||
async def add_project(
|
||||
project_data: ProjectInfoRequest,
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Add a new project to configuration and database.
|
||||
|
||||
Args:
|
||||
project_data: The project name and path, with option to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was added
|
||||
"""
|
||||
# Check if project already exists before attempting to add
|
||||
existing_project = await project_service.get_project(project_data.name)
|
||||
if existing_project:
|
||||
# Project exists - check if paths match for true idempotency
|
||||
# Normalize paths for comparison (resolve symlinks, etc.)
|
||||
requested_path = os.path.abspath(os.path.expanduser(project_data.path))
|
||||
existing_path = os.path.abspath(os.path.expanduser(existing_project.path))
|
||||
|
||||
if requested_path == existing_path:
|
||||
# Same name, same path - return 200 OK (idempotent)
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' already exists",
|
||||
status="success",
|
||||
default=existing_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=existing_project.id,
|
||||
external_id=existing_project.external_id,
|
||||
name=existing_project.name,
|
||||
path=existing_project.path,
|
||||
is_default=existing_project.is_default or False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Same name, different path - this is an error
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Project '{project_data.name}' already exists with different path. "
|
||||
f"Existing: {existing_project.path}, Requested: {project_data.path}"
|
||||
),
|
||||
)
|
||||
|
||||
try: # pragma: no cover
|
||||
# The service layer handles cloud mode validation and path sanitization
|
||||
await project_service.add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
# Fetch the newly created project to get its ID
|
||||
new_project = await project_service.get_project(project_data.name)
|
||||
if not new_project:
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
id=new_project.id,
|
||||
external_id=new_project.external_id,
|
||||
name=new_project.name,
|
||||
path=new_project.path,
|
||||
is_default=new_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/config/sync", response_model=ProjectStatusResponse)
|
||||
async def synchronize_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Synchronize projects between configuration file and database."""
|
||||
try: # pragma: no cover
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message="Projects synchronized successfully between configuration and database",
|
||||
status="success",
|
||||
default=False,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/sync")
|
||||
async def sync_project(
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
project_internal_id: ProjectExternalIdPathDep,
|
||||
force_full: bool = Query(
|
||||
False, description="Force full scan, bypassing watermark optimization"
|
||||
),
|
||||
run_in_background: bool = Query(True, description="Run in background"),
|
||||
):
|
||||
"""Force project filesystem sync to database."""
|
||||
if run_in_background:
|
||||
task_scheduler.schedule(
|
||||
"sync_project",
|
||||
project_id=project_internal_id,
|
||||
force_full=force_full,
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "sync_started",
|
||||
"message": f"Filesystem sync initiated for project '{project_config.name}'",
|
||||
}
|
||||
|
||||
report = await sync_service.sync(
|
||||
project_config.home, project_config.name, force_full=force_full
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
return SyncReportResponse.from_sync_report(report)
|
||||
|
||||
|
||||
@router.post("/{project_id}/status", response_model=SyncReportResponse)
|
||||
async def get_project_status(
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
force_full: bool = Query(
|
||||
False, description="Force full scan, bypassing watermark optimization"
|
||||
),
|
||||
) -> SyncReportResponse:
|
||||
"""Get sync status of files vs database for a project."""
|
||||
logger.info(f"API v2 request: get_project_status for project_id={project_id}")
|
||||
report = await sync_service.scan(project_config.home, force_full=force_full)
|
||||
return SyncReportResponse.from_sync_report(report)
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=ProjectResolveResponse)
|
||||
async def resolve_project_identifier(
|
||||
data: ProjectResolveRequest,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectResolveResponse:
|
||||
"""Resolve a project identifier (name, permalink, or external_id) to project info.
|
||||
|
||||
This endpoint provides efficient lookup of projects by various identifiers
|
||||
without needing to fetch the entire project list. Supports:
|
||||
- External ID (UUID string) - preferred stable identifier
|
||||
- Permalink
|
||||
- Case-insensitive name matching
|
||||
|
||||
Args:
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Project information including the external_id (UUID)
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
POST /v2/projects/resolve
|
||||
{"identifier": "my-project"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"external_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"project_id": 1,
|
||||
"name": "my-project",
|
||||
"permalink": "my-project",
|
||||
"path": "/path/to/project",
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"resolution_method": "name"
|
||||
}
|
||||
"""
|
||||
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
|
||||
|
||||
# Generate permalink for comparison
|
||||
identifier_permalink = generate_permalink(data.identifier)
|
||||
|
||||
resolution_method = "name"
|
||||
project = None
|
||||
|
||||
# Try external_id first (UUID format)
|
||||
project = await project_repository.get_by_external_id(data.identifier)
|
||||
if project:
|
||||
resolution_method = "external_id"
|
||||
|
||||
# If not found by external_id, try by permalink (exact match)
|
||||
if not project:
|
||||
project = await project_repository.get_by_permalink(identifier_permalink)
|
||||
if project:
|
||||
resolution_method = "permalink"
|
||||
|
||||
# If not found by permalink, try case-insensitive name search
|
||||
if not project:
|
||||
project = await project_repository.get_by_name_case_insensitive(data.identifier)
|
||||
if project:
|
||||
resolution_method = "name" # pragma: no cover
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
||||
|
||||
return ProjectResolveResponse(
|
||||
external_id=project.external_id,
|
||||
project_id=project.id,
|
||||
name=project.name,
|
||||
permalink=generate_permalink(project.name),
|
||||
path=normalize_project_path(project.path),
|
||||
is_active=project.is_active if hasattr(project, "is_active") else True,
|
||||
is_default=project.is_default or False,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectItem)
|
||||
async def get_project_by_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
) -> ProjectItem:
|
||||
"""Get project by its external ID (UUID).
|
||||
|
||||
This is the primary project retrieval method in v2, using stable UUID
|
||||
identifiers that won't change with project renames.
|
||||
|
||||
Args:
|
||||
project_id: External ID (UUID string)
|
||||
|
||||
Returns:
|
||||
Project information including external_id
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
GET /v2/projects/550e8400-e29b-41d4-a716-446655440000
|
||||
"""
|
||||
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
|
||||
|
||||
project = await project_repository.get_by_external_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
|
||||
return ProjectItem(
|
||||
id=project.id,
|
||||
external_id=project.external_id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get detailed project information by external ID."""
|
||||
logger.info(f"API v2 request: get_project_info_by_id for project_id={project_id}")
|
||||
project = await project_repository.get_by_external_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
return await project_service.get_project_info(project.name)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def update_project_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information by external ID.
|
||||
|
||||
Args:
|
||||
project_id: External ID (UUID string)
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails, 404 if project not found
|
||||
|
||||
Example:
|
||||
PATCH /v2/projects/550e8400-e29b-41d4-a716-446655440000
|
||||
{"path": "/new/path"}
|
||||
"""
|
||||
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
|
||||
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_repository.get_by_external_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
id=old_project.id,
|
||||
external_id=old_project.external_id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
# Update using project name (service layer still uses names internally)
|
||||
if path:
|
||||
await project_service.move_project(old_project.name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(old_project.name, is_active=is_active)
|
||||
|
||||
# Get updated project info (use the same external_id)
|
||||
updated_project = await project_repository.get_by_external_id(project_id)
|
||||
if not updated_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Project with external_id '{project_id}' not found after update",
|
||||
)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{updated_project.name}' updated successfully",
|
||||
status="success",
|
||||
default=old_project.is_default or False,
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.id,
|
||||
external_id=updated_project.external_id,
|
||||
name=updated_project.name,
|
||||
path=updated_project.path,
|
||||
is_default=updated_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def delete_project_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
delete_notes: bool = Query(
|
||||
False, description="If True, delete project directory from filesystem"
|
||||
),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Delete a project by external ID.
|
||||
|
||||
Args:
|
||||
project_id: External ID (UUID string)
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
Response confirming the project was deleted
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if trying to delete default project, 404 if not found
|
||||
|
||||
Example:
|
||||
DELETE /v2/projects/550e8400-e29b-41d4-a716-446655440000?delete_notes=false
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
|
||||
)
|
||||
|
||||
try:
|
||||
old_project = await project_repository.get_by_external_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
|
||||
# Check if trying to delete the default project
|
||||
# Use is_default from database, not ConfigManager (which doesn't work in cloud mode)
|
||||
if old_project.is_default:
|
||||
available_projects = await project_service.list_projects()
|
||||
other_projects = [p.name for p in available_projects if p.external_id != project_id]
|
||||
detail = f"Cannot delete default project '{old_project.name}'. "
|
||||
if other_projects:
|
||||
detail += ( # pragma: no cover
|
||||
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
||||
)
|
||||
else:
|
||||
detail += "This is the only project in your configuration." # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
# Delete using project name (service layer still uses names internally)
|
||||
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{old_project.name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
external_id=old_project.external_id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
|
||||
|
||||
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project by external ID.
|
||||
|
||||
Args:
|
||||
project_id: External ID (UUID string) to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
PUT /v2/projects/550e8400-e29b-41d4-a716-446655440000/default
|
||||
"""
|
||||
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
|
||||
|
||||
try:
|
||||
# Get the old default project from database
|
||||
default_project = await project_repository.get_default_project()
|
||||
if not default_project:
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail="No default project is currently set"
|
||||
)
|
||||
|
||||
# Get the new default project by external_id
|
||||
new_default_project = await project_repository.get_by_external_id(project_id)
|
||||
if not new_default_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
|
||||
# Set as default using project name (service layer still uses names internally)
|
||||
await project_service.set_default_project(new_default_project.name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{new_default_project.name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
external_id=default_project.external_id,
|
||||
name=default_project.name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
external_id=new_default_project.external_id,
|
||||
name=new_default_project.name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
+27
-18
@@ -1,21 +1,22 @@
|
||||
"""Router for prompt-related operations.
|
||||
"""V2 Prompt Router - ID-based prompt generation operations.
|
||||
|
||||
This router is responsible for rendering various prompts using Handlebars templates.
|
||||
It centralizes all prompt formatting logic that was previously in the MCP prompts.
|
||||
This router uses v2 dependencies for consistent project handling with external_id UUIDs.
|
||||
Prompt endpoints are action-based (not resource-based), so they don't
|
||||
have entity IDs in URLs - they generate formatted prompts from queries.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, HTTPException, status, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.v2.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
ContextServiceDep,
|
||||
EntityRepositoryDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
ContextServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
@@ -25,16 +26,17 @@ from basic_memory.schemas.prompt import (
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
||||
|
||||
router = APIRouter(prefix="/prompt", tags=["prompt"])
|
||||
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
|
||||
|
||||
|
||||
@router.post("/continue-conversation", response_model=PromptResponse)
|
||||
async def continue_conversation(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
context_service: ContextServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
request: ContinueConversationRequest,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for continuing a conversation.
|
||||
|
||||
@@ -42,13 +44,15 @@ async def continue_conversation(
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
Formatted continuation prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
f"V2 Generating continue conversation prompt for project {project_id}, "
|
||||
f"topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
||||
@@ -192,9 +196,10 @@ async def continue_conversation(
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
request: SearchPromptRequest,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
@@ -204,6 +209,7 @@ async def search_prompt(
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
request: The search parameters
|
||||
page: The page number for pagination
|
||||
page_size: The number of results per page, defaults to 10
|
||||
@@ -211,7 +217,10 @@ async def search_prompt(
|
||||
Returns:
|
||||
Formatted search results prompt with context
|
||||
"""
|
||||
logger.info(f"Generating search prompt, query: {request.query}, timeframe: {request.timeframe}")
|
||||
logger.info(
|
||||
f"V2 Generating search prompt for project {project_id}, "
|
||||
f"query: {request.query}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
@@ -0,0 +1,351 @@
|
||||
"""V2 Resource Router - ID-based resource content operations.
|
||||
|
||||
This router uses entity external_ids (UUIDs) for all operations, with file paths
|
||||
in request bodies when needed. This is consistent with v2's external_id-first design.
|
||||
|
||||
Key differences from v1:
|
||||
- Uses UUID external_ids in URL paths instead of integer IDs or file paths
|
||||
- File paths are in request bodies for create/update operations
|
||||
- More RESTful: POST for create, PUT for update, GET for read
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path as PathLib
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigV2ExternalDep,
|
||||
FileServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
UpdateResourceRequest,
|
||||
ResourceResponse,
|
||||
)
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources-v2"])
|
||||
|
||||
|
||||
@router.get("/{entity_id}")
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_id: str = Path(..., description="Entity external UUID"),
|
||||
) -> Response:
|
||||
"""Get raw resource content by entity external_id.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
entity_id: Entity external UUID
|
||||
config: Project configuration
|
||||
entity_repository: Entity repository for fetching entity data
|
||||
file_service: File service for reading file content
|
||||
|
||||
Returns:
|
||||
Response with entity content
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity or file not found
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.resource.get_content",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
):
|
||||
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.get_content.load_entity",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
phase="load_entity",
|
||||
):
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.get_content.validate_path",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
phase="validate_path",
|
||||
):
|
||||
project_path = PathLib(config.home)
|
||||
if not validate_project_path(entity.file_path, project_path):
|
||||
logger.error( # pragma: no cover
|
||||
f"Invalid file path in entity {entity.id}: {entity.file_path}"
|
||||
)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=500,
|
||||
detail="Entity contains invalid file path",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.get_content.ensure_exists",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
phase="ensure_exists",
|
||||
):
|
||||
if not await file_service.exists(entity.file_path):
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404,
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.get_content.read_content",
|
||||
domain="resource",
|
||||
action="get_content",
|
||||
phase="read_content",
|
||||
):
|
||||
content = await file_service.read_file_bytes(entity.file_path)
|
||||
content_type = file_service.content_type(entity.file_path)
|
||||
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
@router.post("", response_model=ResourceResponse)
|
||||
async def create_resource(
|
||||
data: CreateResourceRequest,
|
||||
config: ProjectConfigV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
) -> ResourceResponse:
|
||||
"""Create a new resource file.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
data: Create resource request with file_path and content
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for creating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with file information including entity_id and external_id
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for invalid file paths, 409 if file already exists
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.resource.create",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
action="create",
|
||||
):
|
||||
try:
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = PathLib(config.home)
|
||||
if not validate_project_path(data.file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {data.file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {data.file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
existing_entity = await entity_repository.get_by_file_path(data.file_path)
|
||||
if existing_entity:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.external_id}. "
|
||||
f"Use PUT /resource/{existing_entity.external_id} to update it.",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.create.write_file",
|
||||
domain="resource",
|
||||
action="create",
|
||||
phase="write_file",
|
||||
):
|
||||
await file_service.ensure_directory(PathLib(data.file_path).parent)
|
||||
checksum = await file_service.write_file(data.file_path, data.content)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.create.read_metadata",
|
||||
domain="resource",
|
||||
action="create",
|
||||
phase="read_metadata",
|
||||
):
|
||||
file_metadata = await file_service.get_file_metadata(data.file_path)
|
||||
|
||||
file_name = PathLib(data.file_path).name
|
||||
content_type = file_service.content_type(data.file_path)
|
||||
note_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
entity = EntityModel(
|
||||
external_id=str(uuid.uuid4()),
|
||||
title=file_name,
|
||||
note_type=note_type,
|
||||
content_type=content_type,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
created_at=file_metadata.created_at,
|
||||
updated_at=file_metadata.modified_at,
|
||||
)
|
||||
with telemetry.scope(
|
||||
"api.resource.create.upsert_entity",
|
||||
domain="resource",
|
||||
action="create",
|
||||
phase="upsert_entity",
|
||||
):
|
||||
entity = await entity_repository.add(entity)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.create.search_index",
|
||||
domain="resource",
|
||||
action="create",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
external_id=entity.external_id,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
created_at=file_metadata.created_at.timestamp(),
|
||||
modified_at=file_metadata.modified_at.timestamp(),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error creating resource {data.file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{entity_id}", response_model=ResourceResponse)
|
||||
async def update_resource(
|
||||
data: UpdateResourceRequest,
|
||||
config: ProjectConfigV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_id: str = Path(..., description="Entity external UUID"),
|
||||
) -> ResourceResponse:
|
||||
"""Update an existing resource by entity external_id.
|
||||
|
||||
Can update content and optionally move the file to a new path.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
entity_id: Entity external UUID of the resource to update
|
||||
data: Update resource request with content and optional new file_path
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for updating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with updated file information
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 for invalid paths
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.resource.update",
|
||||
entrypoint="api",
|
||||
domain="resource",
|
||||
action="update",
|
||||
):
|
||||
try:
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
target_file_path = data.file_path if data.file_path else entity.file_path
|
||||
|
||||
project_path = PathLib(config.home)
|
||||
if not validate_project_path(target_file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {target_file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {target_file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.update.write_file",
|
||||
domain="resource",
|
||||
action="update",
|
||||
phase="write_file",
|
||||
):
|
||||
if data.file_path and data.file_path != entity.file_path:
|
||||
await file_service.ensure_directory(PathLib(target_file_path).parent)
|
||||
if await file_service.exists(entity.file_path):
|
||||
await file_service.delete_file(entity.file_path)
|
||||
else:
|
||||
await file_service.ensure_directory(PathLib(target_file_path).parent)
|
||||
|
||||
checksum = await file_service.write_file(target_file_path, data.content)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.update.read_metadata",
|
||||
domain="resource",
|
||||
action="update",
|
||||
phase="read_metadata",
|
||||
):
|
||||
file_metadata = await file_service.get_file_metadata(target_file_path)
|
||||
|
||||
file_name = PathLib(target_file_path).name
|
||||
content_type = file_service.content_type(target_file_path)
|
||||
note_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.update.update_entity",
|
||||
domain="resource",
|
||||
action="update",
|
||||
phase="update_entity",
|
||||
):
|
||||
updated_entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{
|
||||
"title": file_name,
|
||||
"note_type": note_type,
|
||||
"content_type": content_type,
|
||||
"file_path": target_file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": file_metadata.modified_at,
|
||||
},
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"api.resource.update.search_index",
|
||||
domain="resource",
|
||||
action="update",
|
||||
phase="search_index",
|
||||
):
|
||||
await search_service.index_entity(updated_entity) # pyright: ignore
|
||||
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
external_id=entity.external_id,
|
||||
file_path=target_file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
created_at=file_metadata.created_at.timestamp(),
|
||||
modified_at=file_metadata.modified_at.timestamp(),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error updating resource {entity_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
|
||||
@@ -0,0 +1,412 @@
|
||||
"""V2 router for schema operations.
|
||||
|
||||
Provides endpoints for schema validation, inference, and drift detection.
|
||||
The schema system validates notes against Picoschema definitions without
|
||||
introducing any new data model -- it works entirely with existing
|
||||
observations and relations.
|
||||
|
||||
Flow: Entity loaded with eager observations/relations -> convert to tuples -> core functions.
|
||||
"""
|
||||
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Path, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityRepositoryV2ExternalDep,
|
||||
FileServiceV2ExternalDep,
|
||||
LinkResolverV2ExternalDep,
|
||||
)
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.schemas.schema import (
|
||||
ValidationReport,
|
||||
InferenceReport,
|
||||
DriftReport,
|
||||
NoteValidationResponse,
|
||||
FieldResultResponse,
|
||||
FieldFrequencyResponse,
|
||||
DriftFieldResponse,
|
||||
)
|
||||
from basic_memory.schema.resolver import resolve_schema
|
||||
from basic_memory.schema.validator import validate_note
|
||||
from basic_memory.schema.inference import infer_schema, NoteData, ObservationData, RelationData
|
||||
from basic_memory.schema.diff import diff_schema
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
# Note: No prefix here -- it's added during registration as /v2/{project_id}/schema
|
||||
router = APIRouter(tags=["schema"])
|
||||
|
||||
|
||||
# --- ORM to core data conversion ---
|
||||
|
||||
|
||||
def _entity_observations(entity: Entity) -> list[ObservationData]:
|
||||
"""Extract ObservationData from an entity's observations."""
|
||||
return [ObservationData(obs.category, obs.content) for obs in entity.observations]
|
||||
|
||||
|
||||
def _entity_relations(entity: Entity) -> list[RelationData]:
|
||||
"""Extract RelationData from an entity's outgoing relations.
|
||||
|
||||
Carries the target entity's type on each relation so the inference engine
|
||||
can suggest correct types (e.g. works_at -> Organization, not the source type).
|
||||
"""
|
||||
return [
|
||||
RelationData(
|
||||
relation_type=rel.relation_type,
|
||||
target_name=rel.to_name,
|
||||
target_note_type=rel.to_entity.note_type if rel.to_entity else None,
|
||||
)
|
||||
for rel in entity.outgoing_relations
|
||||
]
|
||||
|
||||
|
||||
def _entity_to_note_data(entity: Entity) -> NoteData:
|
||||
"""Convert an ORM Entity to a NoteData for inference/diff analysis."""
|
||||
return NoteData(
|
||||
identifier=entity.permalink or entity.file_path,
|
||||
observations=_entity_observations(entity),
|
||||
relations=_entity_relations(entity),
|
||||
)
|
||||
|
||||
|
||||
def _entity_frontmatter(entity: Entity) -> dict:
|
||||
"""Build a frontmatter dict from an entity's database metadata.
|
||||
|
||||
Used for the notes being validated — their type and schema ref are
|
||||
unlikely to change between syncs.
|
||||
"""
|
||||
fm = dict(entity.entity_metadata) if entity.entity_metadata else {}
|
||||
if entity.note_type:
|
||||
fm.setdefault("type", entity.note_type)
|
||||
return fm
|
||||
|
||||
|
||||
async def _schema_frontmatter_from_file(
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
entity: Entity,
|
||||
) -> dict:
|
||||
"""Read a schema entity's frontmatter directly from its file.
|
||||
|
||||
Schema definitions (field declarations, validation mode) are the source
|
||||
of truth for validation. Reading from the file ensures schema-validate
|
||||
always uses the latest settings, even when the file watcher hasn't
|
||||
synced changes to entity_metadata in the database.
|
||||
"""
|
||||
try:
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
post = frontmatter.loads(content)
|
||||
metadata = dict(post.metadata)
|
||||
|
||||
# Trigger: file is mid-edit and missing required schema fields
|
||||
# Why: parse_schema_note() raises ValueError for missing entity/schema,
|
||||
# which would turn validation into a 500 response
|
||||
# Outcome: fall back to last-known-good database metadata
|
||||
if not metadata.get("entity") or not isinstance(metadata.get("schema"), dict):
|
||||
logger.warning(
|
||||
"Schema file has incomplete frontmatter, falling back to database metadata",
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
return _entity_frontmatter(entity)
|
||||
|
||||
return metadata
|
||||
except Exception:
|
||||
# Trigger: file is missing, unreadable, or has malformed frontmatter
|
||||
# Why: fall back to database metadata rather than failing validation entirely
|
||||
# Outcome: behaves like before this change — uses potentially stale data
|
||||
logger.warning(
|
||||
"Failed to read schema file, falling back to database metadata",
|
||||
file_path=entity.file_path,
|
||||
)
|
||||
return _entity_frontmatter(entity)
|
||||
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
|
||||
@router.post("/schema/validate", response_model=ValidationReport)
|
||||
async def validate_schema(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
link_resolver: LinkResolverV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
note_type: str | None = Query(None, description="Note type to validate"),
|
||||
identifier: str | None = Query(None, description="Specific note identifier"),
|
||||
):
|
||||
"""Validate notes against their resolved schemas.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
|
||||
Schema definitions are read directly from their files to ensure the
|
||||
latest settings (validation mode, field declarations) are always used,
|
||||
even when file changes haven't been synced to the database yet.
|
||||
"""
|
||||
results: list[NoteValidationResponse] = []
|
||||
|
||||
# --- Single note validation ---
|
||||
if identifier:
|
||||
# Resolve identifier flexibly (permalink, title, path, fuzzy)
|
||||
# to match how read_note and other tools resolve identifiers
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
return ValidationReport(note_type=note_type, total_notes=0, total_entities=0)
|
||||
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.title or entity.permalink or identifier,
|
||||
schema_def,
|
||||
_entity_observations(entity),
|
||||
_entity_relations(entity),
|
||||
frontmatter=frontmatter,
|
||||
)
|
||||
results.append(_to_note_validation_response(result))
|
||||
|
||||
return ValidationReport(
|
||||
note_type=note_type or entity.note_type,
|
||||
total_notes=len(results),
|
||||
total_entities=1,
|
||||
valid_count=1 if (results and results[0].passed) else 0,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
error_count=sum(len(r.errors) for r in results),
|
||||
results=results,
|
||||
)
|
||||
|
||||
# --- Batch validation by note type ---
|
||||
entities = await _find_by_note_type(entity_repository, note_type) if note_type else []
|
||||
|
||||
for entity in entities:
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(
|
||||
entity_repository,
|
||||
query,
|
||||
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
|
||||
)
|
||||
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
|
||||
|
||||
schema_def = await resolve_schema(frontmatter, search_fn)
|
||||
if schema_def:
|
||||
result = validate_note(
|
||||
entity.title or entity.permalink or entity.file_path,
|
||||
schema_def,
|
||||
_entity_observations(entity),
|
||||
_entity_relations(entity),
|
||||
frontmatter=frontmatter,
|
||||
)
|
||||
results.append(_to_note_validation_response(result))
|
||||
|
||||
valid = sum(1 for r in results if r.passed)
|
||||
return ValidationReport(
|
||||
note_type=note_type,
|
||||
total_notes=len(results),
|
||||
total_entities=len(entities),
|
||||
valid_count=valid,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
error_count=sum(len(r.errors) for r in results),
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
# --- Inference ---
|
||||
|
||||
|
||||
@router.post("/schema/infer", response_model=InferenceReport)
|
||||
async def infer_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
note_type: str = Query(..., description="Note type to analyze"),
|
||||
threshold: float = Query(0.25, description="Minimum frequency for optional fields"),
|
||||
):
|
||||
"""Infer a schema from existing notes of a given type.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
entities = await _find_by_note_type(entity_repository, note_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = infer_schema(note_type, notes_data, optional_threshold=threshold)
|
||||
|
||||
return InferenceReport(
|
||||
note_type=result.note_type,
|
||||
notes_analyzed=result.notes_analyzed,
|
||||
field_frequencies=[
|
||||
FieldFrequencyResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
sample_values=f.sample_values,
|
||||
is_array=f.is_array,
|
||||
target_type=f.target_type,
|
||||
)
|
||||
for f in result.field_frequencies
|
||||
],
|
||||
suggested_schema=result.suggested_schema,
|
||||
suggested_required=result.suggested_required,
|
||||
suggested_optional=result.suggested_optional,
|
||||
excluded=result.excluded,
|
||||
)
|
||||
|
||||
|
||||
# --- Drift Detection ---
|
||||
|
||||
|
||||
@router.get("/schema/diff/{note_type}", response_model=DriftReport)
|
||||
async def diff_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
note_type: str = Path(..., description="Note type to check for drift"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Show drift between a schema definition and actual note usage.
|
||||
|
||||
Compares the existing schema for an entity type against how notes
|
||||
of that type are actually structured. Identifies new fields, dropped
|
||||
fields, and cardinality changes.
|
||||
"""
|
||||
|
||||
async def search_fn(query: str) -> list[dict]:
|
||||
entities = await _find_schema_entities(entity_repository, query)
|
||||
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
|
||||
|
||||
# Resolve schema by note type
|
||||
schema_frontmatter = {"type": note_type}
|
||||
schema_def = await resolve_schema(schema_frontmatter, search_fn)
|
||||
|
||||
if not schema_def:
|
||||
return DriftReport(note_type=note_type, schema_found=False)
|
||||
|
||||
# Collect all notes of this type
|
||||
entities = await _find_by_note_type(entity_repository, note_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = diff_schema(schema_def, notes_data)
|
||||
|
||||
return DriftReport(
|
||||
note_type=note_type,
|
||||
new_fields=[
|
||||
DriftFieldResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
)
|
||||
for f in result.new_fields
|
||||
],
|
||||
dropped_fields=[
|
||||
DriftFieldResponse(
|
||||
name=f.name,
|
||||
source=f.source,
|
||||
count=f.count,
|
||||
total=f.total,
|
||||
percentage=f.percentage,
|
||||
)
|
||||
for f in result.dropped_fields
|
||||
],
|
||||
cardinality_changes=result.cardinality_changes,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
async def _find_by_note_type(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
note_type: str,
|
||||
) -> list[Entity]:
|
||||
"""Find all entities of a given type using the repository's select pattern."""
|
||||
query = entity_repository.select().where(Entity.note_type == note_type)
|
||||
result = await entity_repository.execute_query(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _find_schema_entities(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
target_note_type: str,
|
||||
*,
|
||||
allow_reference_match: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""Find schema entities for resolver lookups.
|
||||
|
||||
Resolution strategy:
|
||||
1) Always try exact entity_metadata['entity'] match (for implicit type lookup
|
||||
and explicit references that use entity names)
|
||||
2) Only when allow_reference_match=True and no entity match was found, try
|
||||
exact reference matching by title/permalink (explicit schema references)
|
||||
"""
|
||||
query = entity_repository.select().where(Entity.note_type == "schema")
|
||||
result = await entity_repository.execute_query(query)
|
||||
entities = list(result.scalars().all())
|
||||
|
||||
normalized_target = generate_permalink(target_note_type)
|
||||
|
||||
entity_matches = [
|
||||
e
|
||||
for e in entities
|
||||
if e.entity_metadata
|
||||
and isinstance(e.entity_metadata.get("entity"), str)
|
||||
and generate_permalink(e.entity_metadata["entity"]) == normalized_target
|
||||
]
|
||||
if entity_matches:
|
||||
return entity_matches
|
||||
|
||||
if not allow_reference_match:
|
||||
return []
|
||||
|
||||
reference_matches: list[Entity] = []
|
||||
for entity in entities:
|
||||
candidate_refs: list[str] = []
|
||||
if entity.title:
|
||||
candidate_refs.append(entity.title)
|
||||
if entity.permalink:
|
||||
candidate_refs.append(entity.permalink)
|
||||
candidate_refs.append(FilePath(entity.permalink).name)
|
||||
|
||||
if any(generate_permalink(ref) == normalized_target for ref in candidate_refs):
|
||||
reference_matches.append(entity)
|
||||
|
||||
return reference_matches
|
||||
|
||||
|
||||
def _to_note_validation_response(result) -> NoteValidationResponse:
|
||||
"""Convert a core ValidationResult to a Pydantic response model."""
|
||||
return NoteValidationResponse(
|
||||
note_identifier=result.note_identifier,
|
||||
schema_entity=result.schema_entity,
|
||||
passed=result.passed,
|
||||
field_results=[
|
||||
FieldResultResponse(
|
||||
field_name=fr.field.name,
|
||||
field_type=fr.field.type,
|
||||
required=fr.field.required,
|
||||
status=fr.status,
|
||||
values=fr.values,
|
||||
message=fr.message,
|
||||
)
|
||||
for fr in result.field_results
|
||||
],
|
||||
unmatched_observations=result.unmatched_observations,
|
||||
unmatched_relations=result.unmatched_relations,
|
||||
warnings=result.warnings,
|
||||
errors=result.errors,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""V2 router for search operations.
|
||||
|
||||
This router uses external_id UUIDs for stable, API-friendly routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.api.v2.utils import to_search_results
|
||||
from basic_memory.repository.semantic_errors import (
|
||||
SemanticDependenciesMissingError,
|
||||
SemanticSearchDisabledError,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
TaskSchedulerDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.post("/search/", response_model=SearchResponse)
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents in a project.
|
||||
|
||||
V2 uses external_id UUIDs for stable API references.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
query: Search query parameters (text, filters, etc.)
|
||||
search_service: Search service scoped to project
|
||||
entity_service: Entity service scoped to project
|
||||
page: Page number for pagination
|
||||
page_size: Number of results per page
|
||||
|
||||
Returns:
|
||||
SearchResponse with paginated search results
|
||||
"""
|
||||
with telemetry.operation(
|
||||
"api.request.search",
|
||||
entrypoint="api",
|
||||
domain="search",
|
||||
action="search",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
retrieval_mode=query.retrieval_mode.value,
|
||||
has_query=bool(
|
||||
(query.text and query.text.strip())
|
||||
or query.title
|
||||
or query.permalink
|
||||
or query.permalink_match
|
||||
),
|
||||
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
|
||||
):
|
||||
offset = (page - 1) * page_size
|
||||
fetch_limit = page_size + 1
|
||||
try:
|
||||
with telemetry.scope(
|
||||
"api.search.search.execute_query",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="execute_query",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
):
|
||||
results = await search_service.search(query, limit=fetch_limit, offset=offset)
|
||||
except SemanticSearchDisabledError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SemanticDependenciesMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
with telemetry.scope(
|
||||
"api.search.search.paginate_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="paginate_results",
|
||||
result_count=len(results),
|
||||
):
|
||||
has_more = len(results) > page_size
|
||||
if has_more:
|
||||
results = results[:page_size]
|
||||
|
||||
with telemetry.scope(
|
||||
"api.search.search.hydrate_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="hydrate_results",
|
||||
result_count=len(results),
|
||||
):
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
with telemetry.scope(
|
||||
"api.search.search.build_response",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="build_response",
|
||||
result_count=len(search_results),
|
||||
):
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search/reindex")
|
||||
async def reindex(
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
):
|
||||
"""Recreate and populate the search index for a project.
|
||||
|
||||
This is a background operation that rebuilds the search index
|
||||
from scratch. Useful after bulk updates or if the index becomes
|
||||
corrupted.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
task_scheduler: Task scheduler for background work
|
||||
|
||||
Returns:
|
||||
Status message indicating reindex has been initiated
|
||||
"""
|
||||
task_scheduler.schedule("reindex_project", project_id=project_id)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
@@ -0,0 +1,249 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from basic_memory import telemetry
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import (
|
||||
EntitySummary,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
MemoryMetadata,
|
||||
GraphContext,
|
||||
ContextResult,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResult
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.services.context_service import (
|
||||
ContextResultRow,
|
||||
ContextResult as ServiceContextResult,
|
||||
)
|
||||
|
||||
|
||||
async def to_graph_context(
|
||||
context_result: ServiceContextResult,
|
||||
entity_repository: EntityRepository,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
with telemetry.scope(
|
||||
"memory.hydrate_context",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
phase="hydrate_context",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
result_count=len(context_result.results),
|
||||
):
|
||||
# First pass: collect all entity IDs needed for external_id lookup
|
||||
# This includes: entity primary results, observation parent entities, relation from/to entities
|
||||
entity_ids_needed: set[int] = set()
|
||||
for context_item in context_result.results:
|
||||
for item in (
|
||||
[context_item.primary_result]
|
||||
+ context_item.observations
|
||||
+ context_item.related_results
|
||||
):
|
||||
if item.type == SearchItemType.ENTITY:
|
||||
# Entity's own ID for its external_id
|
||||
entity_ids_needed.add(item.id)
|
||||
elif item.type == SearchItemType.OBSERVATION:
|
||||
# Parent entity ID for entity_external_id
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.entity_id) # pyright: ignore
|
||||
elif item.type == SearchItemType.RELATION:
|
||||
# Source and target entity IDs for external_ids
|
||||
if item.from_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.from_id) # pyright: ignore
|
||||
if item.to_id:
|
||||
entity_ids_needed.add(item.to_id)
|
||||
|
||||
# Batch fetch all entities at once - get both title and external_id
|
||||
entity_title_lookup: dict[int, str] = {}
|
||||
entity_external_id_lookup: dict[int, str] = {}
|
||||
if entity_ids_needed:
|
||||
with telemetry.scope(
|
||||
"memory.hydrate_context.lookup_entities",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
phase="lookup_entities",
|
||||
result_count=len(entity_ids_needed),
|
||||
):
|
||||
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
|
||||
for e in entities:
|
||||
entity_title_lookup[e.id] = e.title
|
||||
entity_external_id_lookup[e.id] = e.external_id
|
||||
|
||||
# Helper function to convert items to summaries
|
||||
def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
external_id=entity_external_id_lookup.get(item.id, ""),
|
||||
entity_id=item.id,
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
file_path=item.file_path,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
entity_ext_id = None
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ext_id = entity_external_id_lookup.get(item.entity_id) # pyright: ignore
|
||||
return ObservationSummary(
|
||||
observation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
entity_external_id=entity_ext_id,
|
||||
title=entity_title_lookup.get(item.entity_id), # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_title = entity_title_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_title = entity_title_lookup.get(item.to_id) if item.to_id else None
|
||||
from_ext_id = (
|
||||
entity_external_id_lookup.get(item.from_id) if item.from_id else None
|
||||
) # pyright: ignore
|
||||
to_ext_id = entity_external_id_lookup.get(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
relation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_title,
|
||||
from_entity_id=item.from_id, # pyright: ignore
|
||||
from_entity_external_id=from_ext_id,
|
||||
to_entity=to_title,
|
||||
to_entity_id=item.to_id,
|
||||
to_entity_external_id=to_ext_id,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
with telemetry.scope(
|
||||
"memory.hydrate_context.shape_results",
|
||||
domain="memory",
|
||||
action="build_context",
|
||||
phase="shape_results",
|
||||
result_count=len(context_result.results),
|
||||
):
|
||||
hierarchical_results = []
|
||||
for context_item in context_result.results:
|
||||
primary_result = to_summary(context_item.primary_result)
|
||||
observations = [to_summary(obs) for obs in context_item.observations]
|
||||
related = [to_summary(rel) for rel in context_item.related_results]
|
||||
hierarchical_results.append(
|
||||
ContextResult(
|
||||
primary_result=primary_result,
|
||||
observations=observations, # pyright: ignore[reportArgumentType]
|
||||
related_results=related,
|
||||
)
|
||||
)
|
||||
|
||||
metadata = MemoryMetadata(
|
||||
uri=context_result.metadata.uri,
|
||||
types=context_result.metadata.types,
|
||||
depth=context_result.metadata.depth,
|
||||
timeframe=context_result.metadata.timeframe,
|
||||
generated_at=context_result.metadata.generated_at,
|
||||
primary_count=context_result.metadata.primary_count,
|
||||
related_count=context_result.metadata.related_count,
|
||||
total_results=context_result.metadata.primary_count
|
||||
+ context_result.metadata.related_count,
|
||||
total_relations=context_result.metadata.total_relations,
|
||||
total_observations=context_result.metadata.total_observations,
|
||||
)
|
||||
|
||||
return GraphContext(
|
||||
results=hierarchical_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
has_more=context_result.metadata.has_more,
|
||||
)
|
||||
|
||||
|
||||
async def to_search_results(entity_service: EntityService, results: List[SearchIndexRow]):
|
||||
with telemetry.scope(
|
||||
"search.hydrate_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="hydrate_results",
|
||||
result_count=len(results),
|
||||
):
|
||||
# Collect all unique entity IDs across all results in a single pass
|
||||
# This avoids N+1 queries — one batch fetch instead of one per result
|
||||
all_entity_ids: set[int] = set()
|
||||
for result in results:
|
||||
for eid in (result.entity_id, result.from_id, result.to_id):
|
||||
if eid is not None:
|
||||
all_entity_ids.add(eid)
|
||||
|
||||
# Single batch fetch for all entities
|
||||
entities_by_id: dict[int, EntityModel] = {}
|
||||
with telemetry.scope(
|
||||
"search.hydrate_results.fetch_entities",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="fetch_entities",
|
||||
result_count=len(all_entity_ids),
|
||||
):
|
||||
if all_entity_ids:
|
||||
entities = await entity_service.get_entities_by_id(list(all_entity_ids))
|
||||
entities_by_id = {e.id: e for e in entities}
|
||||
|
||||
search_results = []
|
||||
with telemetry.scope(
|
||||
"search.hydrate_results.shape_results",
|
||||
domain="search",
|
||||
action="search",
|
||||
phase="shape_results",
|
||||
result_count=len(results),
|
||||
):
|
||||
for result in results:
|
||||
entity_id = None
|
||||
observation_id = None
|
||||
relation_id = None
|
||||
|
||||
if result.type == SearchItemType.ENTITY:
|
||||
entity_id = result.id
|
||||
elif result.type == SearchItemType.OBSERVATION:
|
||||
observation_id = result.id
|
||||
entity_id = result.entity_id
|
||||
elif result.type == SearchItemType.RELATION:
|
||||
relation_id = result.id
|
||||
entity_id = result.entity_id
|
||||
|
||||
# Look up entities by their specific IDs
|
||||
parent_entity = entities_by_id.get(result.entity_id) if result.entity_id else None # pyright: ignore
|
||||
from_entity = entities_by_id.get(result.from_id) if result.from_id else None # pyright: ignore
|
||||
to_entity = entities_by_id.get(result.to_id) if result.to_id else None
|
||||
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=result.title, # pyright: ignore
|
||||
type=result.type, # pyright: ignore
|
||||
permalink=result.permalink,
|
||||
score=result.score, # pyright: ignore
|
||||
entity=parent_entity.permalink if parent_entity else None,
|
||||
content=result.content,
|
||||
matched_chunk=result.matched_chunk_text,
|
||||
file_path=result.file_path,
|
||||
metadata=result.metadata,
|
||||
entity_id=entity_id,
|
||||
observation_id=observation_id,
|
||||
relation_id=relation_id,
|
||||
category=result.category,
|
||||
from_entity=from_entity.permalink if from_entity else None,
|
||||
to_entity=to_entity.permalink if to_entity else None,
|
||||
relation_type=result.relation_type,
|
||||
)
|
||||
)
|
||||
return search_results
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Lightweight CLI analytics via Umami event collector.
|
||||
|
||||
Sends anonymous, non-blocking usage events to help understand how the
|
||||
CLI-to-cloud conversion funnel performs. No PII, no fingerprinting,
|
||||
no cookies. Respects the same opt-out mechanisms as promo messaging.
|
||||
|
||||
Events are fire-and-forget — analytics never blocks or breaks the CLI.
|
||||
|
||||
Defaults point to the Basic Memory Umami Cloud instance. Override via:
|
||||
BASIC_MEMORY_UMAMI_HOST — Custom Umami instance URL
|
||||
BASIC_MEMORY_UMAMI_SITE_ID — Custom Website ID
|
||||
Opt out entirely with BASIC_MEMORY_NO_PROMOS=1.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
import basic_memory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — defaults baked in, overridable via environment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_UMAMI_HOST = "https://api-gateway.umami.dev"
|
||||
_DEFAULT_UMAMI_SITE_ID = "f6479898-ebaf-4e60-bce2-6dc60a3f6c5c"
|
||||
|
||||
|
||||
def _umami_host() -> Optional[str]:
|
||||
return os.getenv("BASIC_MEMORY_UMAMI_HOST", "").strip() or _DEFAULT_UMAMI_HOST
|
||||
|
||||
|
||||
def _umami_site_id() -> Optional[str]:
|
||||
return os.getenv("BASIC_MEMORY_UMAMI_SITE_ID", "").strip() or _DEFAULT_UMAMI_SITE_ID
|
||||
|
||||
|
||||
def _analytics_disabled() -> bool:
|
||||
"""True when analytics should not fire."""
|
||||
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
|
||||
return value in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _is_configured() -> bool:
|
||||
"""True when both host and site ID are available."""
|
||||
return _umami_host() is not None and _umami_site_id() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Well-known event names for the promo/cloud funnel
|
||||
EVENT_PROMO_SHOWN = "cli-promo-shown"
|
||||
EVENT_PROMO_OPTED_OUT = "cli-promo-opted-out"
|
||||
EVENT_CLOUD_LOGIN_STARTED = "cli-cloud-login-started"
|
||||
EVENT_CLOUD_LOGIN_SUCCESS = "cli-cloud-login-success"
|
||||
EVENT_CLOUD_LOGIN_SUB_REQUIRED = "cli-cloud-login-sub-required"
|
||||
|
||||
|
||||
def track(event_name: str, data: Optional[dict] = None) -> None:
|
||||
"""Send an analytics event to Umami. Non-blocking, silent on failure.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_name:
|
||||
Short kebab-case name (e.g. "cli-promo-shown").
|
||||
data:
|
||||
Optional dict of event properties (all values should be strings/numbers).
|
||||
"""
|
||||
if _analytics_disabled() or not _is_configured():
|
||||
return
|
||||
|
||||
host = _umami_host()
|
||||
site_id = _umami_site_id()
|
||||
|
||||
# Umami v2 /api/send requires "type" at top level alongside "payload"
|
||||
payload = {
|
||||
"type": "event",
|
||||
"payload": {
|
||||
"hostname": "cli.basicmemory.com",
|
||||
"language": "en",
|
||||
"url": f"/cli/{event_name}",
|
||||
"website": site_id,
|
||||
"name": event_name,
|
||||
"data": {
|
||||
"version": basic_memory.__version__,
|
||||
**(data or {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _send():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{host}/api/send",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
# Umami's bot detection rejects non-browser User-Agents
|
||||
"User-Agent": "Mozilla/5.0 (compatible; BasicMemoryCLI/"
|
||||
f"{basic_memory.__version__})",
|
||||
},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
except Exception:
|
||||
pass # Never break the CLI for analytics
|
||||
|
||||
# Non-daemon so the process waits for the request to complete.
|
||||
# The 3s urllib timeout caps the worst-case exit delay.
|
||||
t = threading.Thread(target=_send)
|
||||
t.start()
|
||||
@@ -1,8 +1,18 @@
|
||||
from typing import Optional
|
||||
# This prevents DEBUG logs from appearing on stdout during module-level
|
||||
# initialization (e.g., template_loader.TemplateLoader() logs at DEBUG level).
|
||||
from loguru import logger
|
||||
|
||||
import typer
|
||||
logger.remove()
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from typing import Optional # noqa: E402
|
||||
|
||||
import typer # noqa: E402
|
||||
|
||||
from basic_memory.cli.auto_update import maybe_run_periodic_auto_update # noqa: E402
|
||||
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
|
||||
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
from basic_memory import telemetry # noqa: E402
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -31,12 +41,60 @@ def app_callback(
|
||||
) -> None:
|
||||
"""Basic Memory - Local-first personal knowledge management."""
|
||||
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
# Initialize logging for CLI (file only, no stdout)
|
||||
init_cli_logging()
|
||||
command_name = ctx.invoked_subcommand or "root"
|
||||
ctx.with_resource(
|
||||
telemetry.operation(
|
||||
f"cli.command.{command_name}",
|
||||
entrypoint="cli",
|
||||
command_name=command_name,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Composition Root ---
|
||||
# Create container and read config (single point of config access)
|
||||
container = CliContainer.create()
|
||||
set_container(container)
|
||||
|
||||
# Trigger: first-run init confirmation before command output.
|
||||
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
|
||||
# Outcome: one-time plain line printed before the subcommand runs.
|
||||
maybe_show_init_line(ctx.invoked_subcommand)
|
||||
|
||||
# Trigger: register post-command messaging callbacks.
|
||||
# Why: informational/promo/update output belongs below command results.
|
||||
# Outcome: command output remains primary, with optional follow-up notices afterwards.
|
||||
def _post_command_messages() -> None:
|
||||
maybe_show_cloud_promo(ctx.invoked_subcommand)
|
||||
maybe_run_periodic_auto_update(ctx.invoked_subcommand)
|
||||
|
||||
ctx.call_on_close(_post_command_messages)
|
||||
|
||||
# Run initialization for commands that don't use the API
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
# Skip for 'reset' command - it manages its own database lifecycle
|
||||
skip_init_commands = {
|
||||
"doctor",
|
||||
"mcp",
|
||||
"status",
|
||||
"sync",
|
||||
"project",
|
||||
"tool",
|
||||
"reset",
|
||||
"reindex",
|
||||
"update",
|
||||
"watch",
|
||||
}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
and ctx.invoked_subcommand not in skip_init_commands
|
||||
):
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
ensure_initialization(app_config)
|
||||
ensure_initialization(container.config)
|
||||
|
||||
|
||||
## import
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""WorkOS OAuth Device Authorization for CLI."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import webbrowser
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import AsyncContextManager
|
||||
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
@@ -16,21 +22,65 @@ console = Console()
|
||||
class CLIAuth:
|
||||
"""Handles WorkOS OAuth Device Authorization for CLI tools."""
|
||||
|
||||
def __init__(self, client_id: str, authkit_domain: str):
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
authkit_domain: str,
|
||||
http_client_factory: Callable[[], AsyncContextManager[httpx.AsyncClient]] | None = None,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.authkit_domain = authkit_domain
|
||||
app_config = ConfigManager().config
|
||||
# Store tokens in data dir
|
||||
self.token_file = app_config.data_dir_path / "basic-memory-cloud.json"
|
||||
# PKCE parameters
|
||||
self.code_verifier = None
|
||||
self.code_challenge = None
|
||||
self._http_client_factory = http_client_factory
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_http_client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""Create an AsyncClient, optionally via injected factory.
|
||||
|
||||
Why: enables reliable tests without monkeypatching httpx internals while
|
||||
still using real httpx request/response objects.
|
||||
"""
|
||||
if self._http_client_factory:
|
||||
async with self._http_client_factory() as client:
|
||||
yield client
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
yield client
|
||||
|
||||
def generate_pkce_pair(self) -> tuple[str, str]:
|
||||
"""Generate PKCE code verifier and challenge."""
|
||||
# Generate code verifier (43-128 characters)
|
||||
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8")
|
||||
code_verifier = code_verifier.rstrip("=")
|
||||
|
||||
# Generate code challenge (SHA256 hash of verifier)
|
||||
challenge_bytes = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8")
|
||||
code_challenge = code_challenge.rstrip("=")
|
||||
|
||||
return code_verifier, code_challenge
|
||||
|
||||
async def request_device_authorization(self) -> dict | None:
|
||||
"""Request device authorization from WorkOS."""
|
||||
"""Request device authorization from WorkOS with PKCE."""
|
||||
device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization"
|
||||
|
||||
data = {"client_id": self.client_id, "scope": "openid profile email offline_access"}
|
||||
# Generate PKCE pair
|
||||
self.code_verifier, self.code_challenge = self.generate_pkce_pair()
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"scope": "openid profile email offline_access",
|
||||
"code_challenge": self.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._get_http_client() as client:
|
||||
response = await client.post(device_auth_url, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
@@ -50,7 +100,7 @@ class CLIAuth:
|
||||
verification_uri = device_response["verification_uri"]
|
||||
verification_uri_complete = device_response.get("verification_uri_complete")
|
||||
|
||||
console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
|
||||
console.print("\n[bold blue]Authentication Required[/bold blue]")
|
||||
console.print("\nTo authenticate, please visit:")
|
||||
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
|
||||
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
|
||||
@@ -76,6 +126,7 @@ class CLIAuth:
|
||||
"client_id": self.client_id,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"code_verifier": self.code_verifier,
|
||||
}
|
||||
|
||||
max_attempts = 60 # 5 minutes with 5-second intervals
|
||||
@@ -83,7 +134,7 @@ class CLIAuth:
|
||||
|
||||
for _attempt in range(max_attempts):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._get_http_client() as client:
|
||||
response = await client.post(token_url, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
@@ -143,7 +194,7 @@ class CLIAuth:
|
||||
# Secure the token file
|
||||
os.chmod(self.token_file, 0o600)
|
||||
|
||||
console.print(f"[green]✓ Tokens saved to {self.token_file}[/green]")
|
||||
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
|
||||
|
||||
def load_tokens(self) -> dict | None:
|
||||
"""Load tokens from .bm-auth.json file."""
|
||||
@@ -173,7 +224,7 @@ class CLIAuth:
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._get_http_client() as client:
|
||||
response = await client.post(token_url, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
@@ -205,7 +256,7 @@ class CLIAuth:
|
||||
if new_tokens:
|
||||
# Save new tokens (may include rotated refresh token)
|
||||
self.save_tokens(new_tokens)
|
||||
console.print("[green]✓ Token refreshed successfully[/green]")
|
||||
console.print("[green]Token refreshed successfully[/green]")
|
||||
return new_tokens["access_token"]
|
||||
else:
|
||||
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
|
||||
@@ -216,7 +267,7 @@ class CLIAuth:
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""Perform OAuth Device Authorization login flow."""
|
||||
console.print("[blue]Initiating WorkOS authentication...[/blue]")
|
||||
console.print("[blue]Initiating authentication...[/blue]")
|
||||
|
||||
# Step 1: Request device authorization
|
||||
device_response = await self.request_device_authorization()
|
||||
@@ -237,13 +288,13 @@ class CLIAuth:
|
||||
# Step 4: Save tokens
|
||||
self.save_tokens(tokens)
|
||||
|
||||
console.print("\n[green]✅ Successfully authenticated with WorkOS![/green]")
|
||||
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
|
||||
return True
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Remove stored authentication tokens."""
|
||||
if self.token_file.exists():
|
||||
self.token_file.unlink()
|
||||
console.print("[green]✓ Logged out successfully[/green]")
|
||||
console.print("[green]Logged out successfully[/green]")
|
||||
else:
|
||||
console.print("[yellow]No stored authentication found[/yellow]")
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Automatic update checks and upgrades for the Basic Memory CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
from loguru import logger
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from rich.console import Console
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
PACKAGE_NAME = "basic-memory"
|
||||
PYPI_JSON_URL = "https://pypi.org/pypi/basic-memory/json"
|
||||
|
||||
PYPI_TIMEOUT_SECONDS = 5
|
||||
BREW_OUTDATED_TIMEOUT_SECONDS = 15
|
||||
UV_UPGRADE_TIMEOUT_SECONDS = 180
|
||||
BREW_UPGRADE_TIMEOUT_SECONDS = 600
|
||||
|
||||
|
||||
class InstallSource(str, Enum):
|
||||
"""How the running CLI appears to have been installed."""
|
||||
|
||||
HOMEBREW = "homebrew"
|
||||
UV_TOOL = "uv_tool"
|
||||
UVX = "uvx"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class AutoUpdateStatus(str, Enum):
|
||||
"""Result classification for update checks and installs."""
|
||||
|
||||
SKIPPED = "skipped"
|
||||
UP_TO_DATE = "up_to_date"
|
||||
UPDATE_AVAILABLE = "update_available"
|
||||
UPDATED = "updated"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoUpdateResult:
|
||||
"""Structured result for update checks/install attempts."""
|
||||
|
||||
status: AutoUpdateStatus
|
||||
source: InstallSource
|
||||
checked: bool
|
||||
update_available: bool
|
||||
updated: bool
|
||||
latest_version: str | None = None
|
||||
message: str | None = None
|
||||
error: str | None = None
|
||||
restart_recommended: bool = False
|
||||
|
||||
|
||||
def detect_install_source(executable: str | None = None) -> InstallSource:
|
||||
"""Infer installation source from the active interpreter path."""
|
||||
active_executable = executable or sys.executable
|
||||
normalized = active_executable.lower().replace("\\", "/")
|
||||
|
||||
if "cellar/basic-memory" in normalized:
|
||||
return InstallSource.HOMEBREW
|
||||
if "uv/tools/basic-memory" in normalized:
|
||||
return InstallSource.UV_TOOL
|
||||
if "/uv/archive-" in normalized:
|
||||
return InstallSource.UVX
|
||||
return InstallSource.UNKNOWN
|
||||
|
||||
|
||||
def _is_interactive_session() -> bool:
|
||||
"""Return whether stdin/stdout are interactive terminals."""
|
||||
try:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
except ValueError:
|
||||
# Trigger: stdin/stdout may be closed during transport teardown.
|
||||
# Why: isatty() raises ValueError on closed descriptors.
|
||||
# Outcome: treat as non-interactive and suppress periodic output.
|
||||
return False
|
||||
|
||||
|
||||
def _run_subprocess(
|
||||
command: list[str],
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
silent: bool,
|
||||
capture_output: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a subprocess with explicit stdio behavior for protocol safety."""
|
||||
# Trigger: silent operation (MCP/background) with no need for subprocess output.
|
||||
# Why: prevent protocol/terminal pollution from child process output.
|
||||
# Outcome: stdout/stderr are discarded unless explicit capture is requested.
|
||||
use_devnull = silent and not capture_output
|
||||
stdout_target = subprocess.DEVNULL if use_devnull else subprocess.PIPE
|
||||
stderr_target = subprocess.DEVNULL if use_devnull else subprocess.PIPE
|
||||
|
||||
return subprocess.run(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_target,
|
||||
stderr=stderr_target,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _version_from_pypi() -> str:
|
||||
"""Fetch the latest published package version from PyPI."""
|
||||
request = urllib.request.Request(
|
||||
PYPI_JSON_URL,
|
||||
headers={"User-Agent": f"basic-memory-cli/{basic_memory.__version__}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=PYPI_TIMEOUT_SECONDS) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
latest = payload.get("info", {}).get("version")
|
||||
if not latest:
|
||||
raise RuntimeError("PyPI JSON response did not include info.version")
|
||||
return str(latest)
|
||||
|
||||
|
||||
def _check_homebrew_update_available(silent: bool) -> tuple[bool, str | None]:
|
||||
"""Check whether Homebrew reports an outdated basic-memory formula."""
|
||||
result = _run_subprocess(
|
||||
["brew", "outdated", "--quiet", PACKAGE_NAME],
|
||||
timeout_seconds=BREW_OUTDATED_TIMEOUT_SECONDS,
|
||||
silent=silent,
|
||||
capture_output=True,
|
||||
)
|
||||
# Trigger: brew outdated exits 1 when the formula IS outdated (with name on stdout).
|
||||
# Why: non-zero exit here means "outdated", not "error".
|
||||
# Outcome: check stdout for the package name to determine outdated status.
|
||||
stdout = (result.stdout or "").strip()
|
||||
is_outdated = PACKAGE_NAME in stdout
|
||||
return is_outdated, None
|
||||
|
||||
|
||||
def _check_pypi_update_available() -> tuple[bool, str]:
|
||||
"""Compare installed package version with PyPI latest version."""
|
||||
latest = _version_from_pypi()
|
||||
try:
|
||||
current_version = Version(basic_memory.__version__)
|
||||
latest_version = Version(latest)
|
||||
except InvalidVersion as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not compare versions (current={basic_memory.__version__}, latest={latest})"
|
||||
) from exc
|
||||
|
||||
return latest_version > current_version, latest
|
||||
|
||||
|
||||
def _manual_update_hint(source: InstallSource) -> str:
|
||||
"""Return manager-appropriate manual update instructions."""
|
||||
if source == InstallSource.UV_TOOL:
|
||||
return "Run `uv tool upgrade basic-memory`."
|
||||
if source == InstallSource.HOMEBREW:
|
||||
return "Run `brew upgrade basic-memory`."
|
||||
return (
|
||||
"Automatic install is not supported for this environment. "
|
||||
"Update with your package manager (for pip: `python3 -m pip install -U basic-memory`)."
|
||||
)
|
||||
|
||||
|
||||
def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
|
||||
"""Persist the timestamp for the most recent attempted update check."""
|
||||
config = config_manager.load_config()
|
||||
config.auto_update_last_checked_at = checked_at
|
||||
config_manager.save_config(config)
|
||||
|
||||
|
||||
def run_auto_update(
|
||||
*,
|
||||
force: bool = False,
|
||||
check_only: bool = False,
|
||||
silent: bool = False,
|
||||
config_manager: ConfigManager | None = None,
|
||||
now: datetime | None = None,
|
||||
executable: str | None = None,
|
||||
) -> AutoUpdateResult:
|
||||
"""Run update check/install flow and return a structured result."""
|
||||
manager = config_manager or ConfigManager()
|
||||
config = manager.load_config()
|
||||
source = detect_install_source(executable)
|
||||
checked_at = now or datetime.now()
|
||||
|
||||
if source == InstallSource.UVX:
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.SKIPPED,
|
||||
source=source,
|
||||
checked=False,
|
||||
update_available=False,
|
||||
updated=False,
|
||||
message="uvx runtime detected; updates are managed by uvx cache resolution.",
|
||||
)
|
||||
|
||||
if not force and not config.auto_update:
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.SKIPPED,
|
||||
source=source,
|
||||
checked=False,
|
||||
update_available=False,
|
||||
updated=False,
|
||||
message="Auto-update is disabled in config.",
|
||||
)
|
||||
|
||||
if not force and config.auto_update_last_checked_at is not None:
|
||||
try:
|
||||
elapsed = checked_at - config.auto_update_last_checked_at
|
||||
except TypeError:
|
||||
# Trigger: mixed naive/aware datetimes from manual config edits.
|
||||
# Why: datetime subtraction fails for mixed tz-awareness.
|
||||
# Outcome: ignore the gate once and continue with a forced check path.
|
||||
logger.warning("Auto-update interval gate skipped due to incompatible timestamp format")
|
||||
else:
|
||||
if elapsed < timedelta(seconds=config.update_check_interval):
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.SKIPPED,
|
||||
source=source,
|
||||
checked=False,
|
||||
update_available=False,
|
||||
updated=False,
|
||||
message="Update check interval has not elapsed.",
|
||||
)
|
||||
|
||||
try:
|
||||
# --- Availability check ---
|
||||
latest_version: str | None = None
|
||||
if source == InstallSource.HOMEBREW:
|
||||
update_available, latest_version = _check_homebrew_update_available(silent=silent)
|
||||
else:
|
||||
update_available, latest_version = _check_pypi_update_available()
|
||||
|
||||
if not update_available:
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.UP_TO_DATE,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=False,
|
||||
updated=False,
|
||||
latest_version=latest_version,
|
||||
message=f"Basic Memory is up to date ({basic_memory.__version__}).",
|
||||
)
|
||||
|
||||
if check_only:
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.UPDATE_AVAILABLE,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=True,
|
||||
updated=False,
|
||||
latest_version=latest_version,
|
||||
message=(
|
||||
f"Update available (latest: {latest_version or 'unknown'}). "
|
||||
f"{_manual_update_hint(source)}"
|
||||
),
|
||||
)
|
||||
|
||||
if source == InstallSource.UNKNOWN:
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.UPDATE_AVAILABLE,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=True,
|
||||
updated=False,
|
||||
latest_version=latest_version,
|
||||
message=(
|
||||
f"Update available (latest: {latest_version or 'unknown'}). "
|
||||
f"{_manual_update_hint(source)}"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Automatic install ---
|
||||
command = (
|
||||
["uv", "tool", "upgrade", PACKAGE_NAME]
|
||||
if source == InstallSource.UV_TOOL
|
||||
else ["brew", "upgrade", PACKAGE_NAME]
|
||||
)
|
||||
timeout = (
|
||||
UV_UPGRADE_TIMEOUT_SECONDS
|
||||
if source == InstallSource.UV_TOOL
|
||||
else BREW_UPGRADE_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
install_result = _run_subprocess(
|
||||
command,
|
||||
timeout_seconds=timeout,
|
||||
silent=silent,
|
||||
capture_output=not silent,
|
||||
)
|
||||
if install_result.returncode != 0:
|
||||
stderr = (install_result.stderr or "").strip() if install_result.stderr else ""
|
||||
stdout = (install_result.stdout or "").strip() if install_result.stdout else ""
|
||||
detail = stderr or stdout or "update command failed"
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.FAILED,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=True,
|
||||
updated=False,
|
||||
latest_version=latest_version,
|
||||
message="Automatic update failed.",
|
||||
error=detail,
|
||||
)
|
||||
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.UPDATED,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=True,
|
||||
updated=True,
|
||||
latest_version=latest_version,
|
||||
message=(
|
||||
"Basic Memory was updated successfully. "
|
||||
"Restart running sessions to use the new version."
|
||||
),
|
||||
restart_recommended=True,
|
||||
)
|
||||
|
||||
except (
|
||||
RuntimeError,
|
||||
urllib.error.URLError,
|
||||
ValueError,
|
||||
TimeoutError,
|
||||
subprocess.SubprocessError,
|
||||
OSError,
|
||||
) as exc:
|
||||
logger.warning(f"Auto-update check failed: {exc}")
|
||||
return AutoUpdateResult(
|
||||
status=AutoUpdateStatus.FAILED,
|
||||
source=source,
|
||||
checked=True,
|
||||
update_available=False,
|
||||
updated=False,
|
||||
message="Automatic update check failed.",
|
||||
error=str(exc),
|
||||
)
|
||||
finally:
|
||||
# Trigger: we attempted a check path (including failures).
|
||||
# Why: repeated failing checks on every command create noise and unnecessary network load.
|
||||
# Outcome: next periodic check is gated by update_check_interval.
|
||||
try:
|
||||
_save_last_checked_timestamp(manager, checked_at)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning(f"Failed to persist auto-update timestamp: {exc}")
|
||||
|
||||
|
||||
def maybe_run_periodic_auto_update(
|
||||
invoked_subcommand: str | None,
|
||||
*,
|
||||
config_manager: ConfigManager | None = None,
|
||||
is_interactive: bool | None = None,
|
||||
console: Console | None = None,
|
||||
) -> AutoUpdateResult | None:
|
||||
"""Run a periodic auto-update check for interactive CLI sessions."""
|
||||
interactive = _is_interactive_session() if is_interactive is None else is_interactive
|
||||
if not interactive:
|
||||
return None
|
||||
if invoked_subcommand in {None, "mcp", "update"}:
|
||||
return None
|
||||
|
||||
result = run_auto_update(
|
||||
force=False,
|
||||
check_only=False,
|
||||
silent=False,
|
||||
config_manager=config_manager,
|
||||
)
|
||||
|
||||
if result.status in {
|
||||
AutoUpdateStatus.UPDATE_AVAILABLE,
|
||||
AutoUpdateStatus.UPDATED,
|
||||
AutoUpdateStatus.FAILED,
|
||||
}:
|
||||
out = console or Console()
|
||||
if result.status == AutoUpdateStatus.UPDATED:
|
||||
out.print(f"[green]{result.message}[/green]")
|
||||
elif result.status == AutoUpdateStatus.FAILED:
|
||||
error_detail = f" {result.error}" if result.error else ""
|
||||
out.print(f"[yellow]{result.message}{error_detail}[/yellow]")
|
||||
elif result.message:
|
||||
out.print(f"[cyan]{result.message}[/cyan]")
|
||||
|
||||
return result
|
||||
@@ -1,12 +1,20 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project
|
||||
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
|
||||
from . import (
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tool,
|
||||
project,
|
||||
format,
|
||||
schema,
|
||||
update,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
"sync",
|
||||
"db",
|
||||
"doctor",
|
||||
"import_memory_json",
|
||||
"mcp",
|
||||
"import_claude_conversations",
|
||||
@@ -14,5 +22,7 @@ __all__ = [
|
||||
"import_chatgpt",
|
||||
"tool",
|
||||
"project",
|
||||
"cloud",
|
||||
"format",
|
||||
"schema",
|
||||
"update",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
"""Cloud commands package."""
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
|
||||
# Import all commands to register them with typer
|
||||
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
|
||||
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.project_sync import * # noqa: F401,F403
|
||||
|
||||
# Register snapshot sub-command group
|
||||
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
|
||||
from basic_memory.cli.commands.cloud.workspace import workspace_app
|
||||
|
||||
cloud_app.add_typer(snapshot_app, name="snapshot")
|
||||
cloud_app.add_typer(workspace_app, name="workspace")
|
||||
|
||||
# Register restore command (directly on cloud_app via decorator)
|
||||
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Cloud API client utilities."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncContextManager, Callable
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
@@ -11,11 +14,26 @@ from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
HttpClientFactory = Callable[[], AsyncContextManager[httpx.AsyncClient]]
|
||||
|
||||
|
||||
class CloudAPIError(Exception):
|
||||
"""Exception raised for cloud API errors."""
|
||||
|
||||
pass
|
||||
def __init__(
|
||||
self, message: str, status_code: Optional[int] = None, detail: Optional[dict] = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.detail = detail or {}
|
||||
|
||||
|
||||
class SubscriptionRequiredError(CloudAPIError):
|
||||
"""Exception raised when user needs an active subscription."""
|
||||
|
||||
def __init__(self, message: str, subscribe_url: str):
|
||||
super().__init__(message, status_code=403, detail={"error": "subscription_required"})
|
||||
self.subscribe_url = subscribe_url
|
||||
|
||||
|
||||
def get_cloud_config() -> tuple[str, str, str]:
|
||||
@@ -25,53 +43,97 @@ def get_cloud_config() -> tuple[str, str, str]:
|
||||
return config.cloud_client_id, config.cloud_domain, config.cloud_host
|
||||
|
||||
|
||||
async def get_authenticated_headers() -> dict[str, str]:
|
||||
"""Get authentication headers with JWT token."""
|
||||
async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Get authentication headers for cloud API requests.
|
||||
|
||||
Credential priority mirrors async_client._resolve_cloud_token():
|
||||
1. API key (config.cloud_api_key) — fast, no refresh needed
|
||||
2. OAuth token via CLIAuth — handles JWT refresh automatically
|
||||
"""
|
||||
# --- API key (preferred) ---
|
||||
config_manager = ConfigManager()
|
||||
api_key = config_manager.config.cloud_api_key
|
||||
if api_key:
|
||||
return {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# --- OAuth fallback ---
|
||||
client_id, domain, _ = get_cloud_config()
|
||||
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
|
||||
token = await auth.get_valid_token()
|
||||
auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
|
||||
token = await auth_obj.get_valid_token()
|
||||
if not token:
|
||||
console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
|
||||
console.print(
|
||||
"[red]Not authenticated. Run 'bm cloud set-key <key>' or 'bm cloud login' first.[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _default_http_client(timeout: float) -> AsyncIterator[httpx.AsyncClient]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def make_api_request(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: Optional[dict] = None,
|
||||
json_data: Optional[dict] = None,
|
||||
timeout: float = 30.0,
|
||||
*,
|
||||
auth: CLIAuth | None = None,
|
||||
http_client_factory: HttpClientFactory | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Make an API request to the cloud service."""
|
||||
headers = headers or {}
|
||||
auth_headers = await get_authenticated_headers()
|
||||
auth_headers = await get_authenticated_headers(auth=auth)
|
||||
headers.update(auth_headers)
|
||||
# Add debug headers to help with compression issues
|
||||
headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
client_factory = http_client_factory or (lambda: _default_http_client(timeout))
|
||||
async with client_factory() as client:
|
||||
try:
|
||||
console.print(f"[dim]Making {method} request to {url}[/dim]")
|
||||
console.print(f"[dim]Headers: {dict(headers)}[/dim]")
|
||||
|
||||
response = await client.request(method=method, url=url, headers=headers, json=json_data)
|
||||
|
||||
console.print(f"[dim]Response status: {response.status_code}[/dim]")
|
||||
console.print(f"[dim]Response headers: {dict(response.headers)}[/dim]")
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except httpx.HTTPError as e:
|
||||
console.print(f"[red]HTTP Error details: {e}[/red]")
|
||||
# Check if this is a response error with response details
|
||||
if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
|
||||
response = e.response # type: ignore
|
||||
console.print(f"[red]Response status: {response.status_code}[/red]")
|
||||
console.print(f"[red]Response headers: {dict(response.headers)}[/red]")
|
||||
|
||||
# Try to parse error detail from response
|
||||
error_detail = None
|
||||
try:
|
||||
console.print(f"[red]Response text: {response.text}[/red]")
|
||||
error_detail = response.json()
|
||||
except Exception:
|
||||
console.print("[red]Could not read response text[/red]")
|
||||
# If JSON parsing fails, we'll handle it as a generic error
|
||||
pass
|
||||
|
||||
# Check for subscription_required error (403)
|
||||
if response.status_code == 403 and isinstance(error_detail, dict):
|
||||
# Handle both FastAPI HTTPException format (nested under "detail")
|
||||
# and direct format
|
||||
detail_obj = error_detail.get("detail", error_detail)
|
||||
if (
|
||||
isinstance(detail_obj, dict)
|
||||
and detail_obj.get("error") == "subscription_required"
|
||||
):
|
||||
message = detail_obj.get("message", "Active subscription required")
|
||||
subscribe_url = detail_obj.get(
|
||||
"subscribe_url", "https://basicmemory.com/subscribe"
|
||||
)
|
||||
raise SubscriptionRequiredError(
|
||||
message=message, subscribe_url=subscribe_url
|
||||
) from e
|
||||
|
||||
# Raise generic CloudAPIError with status code and detail
|
||||
raise CloudAPIError(
|
||||
f"API request failed: {e}",
|
||||
status_code=response.status_code,
|
||||
detail=error_detail if isinstance(error_detail, dict) else {},
|
||||
) from e
|
||||
|
||||
raise CloudAPIError(f"API request failed: {e}") from e
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cloud bisync utility functions for Basic Memory CLI."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
|
||||
from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
|
||||
|
||||
|
||||
class BisyncError(Exception):
|
||||
"""Exception raised for bisync-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_mount_info() -> TenantMountInfo:
|
||||
"""Get current tenant information from cloud API."""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
|
||||
|
||||
return TenantMountInfo.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to get tenant info: {e}") from e
|
||||
|
||||
|
||||
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
|
||||
"""Generate scoped credentials for syncing."""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
|
||||
|
||||
return MountCredentials.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to generate credentials: {e}") from e
|
||||
|
||||
|
||||
def convert_bmignore_to_rclone_filters() -> Path:
|
||||
"""Convert .bmignore patterns to rclone filter format.
|
||||
|
||||
Reads ~/.basic-memory/.bmignore (gitignore-style) and converts to
|
||||
~/.basic-memory/.bmignore.rclone (rclone filter format).
|
||||
|
||||
Only regenerates if .bmignore has been modified since last conversion.
|
||||
|
||||
Returns:
|
||||
Path to converted rclone filter file
|
||||
"""
|
||||
# Ensure .bmignore exists
|
||||
create_default_bmignore()
|
||||
|
||||
bmignore_path = get_bmignore_path()
|
||||
# Create rclone filter path: ~/.basic-memory/.bmignore -> ~/.basic-memory/.bmignore.rclone
|
||||
rclone_filter_path = bmignore_path.parent / f"{bmignore_path.name}.rclone"
|
||||
|
||||
# Skip regeneration if rclone file is newer than bmignore
|
||||
if rclone_filter_path.exists():
|
||||
bmignore_mtime = bmignore_path.stat().st_mtime
|
||||
rclone_mtime = rclone_filter_path.stat().st_mtime
|
||||
if rclone_mtime >= bmignore_mtime:
|
||||
return rclone_filter_path
|
||||
|
||||
# Read .bmignore patterns
|
||||
patterns = []
|
||||
try:
|
||||
with bmignore_path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Keep comments and empty lines
|
||||
if not line or line.startswith("#"):
|
||||
patterns.append(line)
|
||||
continue
|
||||
|
||||
# Convert gitignore pattern to rclone filter syntax
|
||||
# gitignore: node_modules → rclone: - node_modules/**
|
||||
# gitignore: *.pyc → rclone: - *.pyc
|
||||
if "*" in line:
|
||||
# Pattern already has wildcard, just add exclude prefix
|
||||
patterns.append(f"- {line}")
|
||||
else:
|
||||
# Directory pattern - add /** for recursive exclude
|
||||
patterns.append(f"- {line}/**")
|
||||
|
||||
except Exception:
|
||||
# If we can't read the file, create a minimal filter
|
||||
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
|
||||
|
||||
# Write rclone filter file
|
||||
rclone_filter_path.write_text("\n".join(patterns) + "\n")
|
||||
|
||||
return rclone_filter_path
|
||||
|
||||
|
||||
def get_bisync_filter_path() -> Path:
|
||||
"""Get path to bisync filter file.
|
||||
|
||||
Uses ~/.basic-memory/.bmignore (converted to rclone format).
|
||||
The file is automatically created with default patterns on first use.
|
||||
|
||||
Returns:
|
||||
Path to rclone filter file
|
||||
"""
|
||||
return convert_bmignore_to_rclone_filters()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Shared utilities for cloud operations."""
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import resolve_configured_workspace
|
||||
from basic_memory.schemas.cloud import (
|
||||
CloudProjectList,
|
||||
CloudProjectCreateRequest,
|
||||
CloudProjectCreateResponse,
|
||||
ProjectVisibility,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
class CloudUtilsError(Exception):
|
||||
"""Exception raised for cloud utility errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _workspace_headers(
|
||||
*,
|
||||
project_name: str | None = None,
|
||||
workspace: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build optional workspace headers using the CLI config resolution chain."""
|
||||
resolved_workspace = resolve_configured_workspace(
|
||||
project_name=project_name,
|
||||
workspace=workspace,
|
||||
)
|
||||
if resolved_workspace is None:
|
||||
return {}
|
||||
return {"X-Workspace-ID": resolved_workspace}
|
||||
|
||||
|
||||
async def fetch_cloud_projects(
|
||||
*,
|
||||
project_name: str | None = None,
|
||||
workspace: str | None = None,
|
||||
api_request=make_api_request,
|
||||
) -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
CloudProjectList with projects from cloud
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await api_request(
|
||||
method="GET",
|
||||
url=f"{host_url}/proxy/v2/projects/",
|
||||
headers=_workspace_headers(project_name=project_name, workspace=workspace),
|
||||
)
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
|
||||
|
||||
|
||||
async def create_cloud_project(
|
||||
project_name: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
visibility: ProjectVisibility = "workspace",
|
||||
api_request=make_api_request,
|
||||
) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to create
|
||||
workspace: Optional workspace override for tenant-scoped project creation
|
||||
visibility: Visibility for the created cloud project
|
||||
|
||||
Returns:
|
||||
CloudProjectCreateResponse with project details from API
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Use generate_permalink to ensure consistent naming
|
||||
project_path = generate_permalink(project_name)
|
||||
|
||||
project_data = CloudProjectCreateRequest(
|
||||
name=project_name,
|
||||
path=project_path,
|
||||
set_default=False,
|
||||
visibility=visibility,
|
||||
)
|
||||
|
||||
response = await api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/v2/projects/",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
**_workspace_headers(project_name=project_name, workspace=workspace),
|
||||
},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
return CloudProjectCreateResponse.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def sync_project(project_name: str, force_full: bool = False) -> None:
|
||||
"""Trigger sync for a specific project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to sync
|
||||
force_full: If True, force a full scan bypassing watermark optimization
|
||||
"""
|
||||
try:
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
|
||||
await run_sync(project=project_name, force_full=force_full)
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def project_exists(
|
||||
project_name: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
api_request=make_api_request,
|
||||
) -> bool:
|
||||
"""Check if a project exists on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to check
|
||||
workspace: Optional workspace override for tenant-scoped project lookup
|
||||
|
||||
Returns:
|
||||
True if project exists, False otherwise
|
||||
|
||||
Raises:
|
||||
CloudUtilsError: If the project list cannot be fetched from cloud
|
||||
"""
|
||||
projects = await fetch_cloud_projects(
|
||||
project_name=project_name,
|
||||
workspace=workspace,
|
||||
api_request=api_request,
|
||||
)
|
||||
project_names = {p.name for p in projects.projects}
|
||||
return project_name in project_names
|
||||
@@ -1,31 +1,36 @@
|
||||
"""Core cloud commands for Basic Memory CLI."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.cli.analytics import (
|
||||
track,
|
||||
EVENT_CLOUD_LOGIN_STARTED,
|
||||
EVENT_CLOUD_LOGIN_SUCCESS,
|
||||
EVENT_CLOUD_LOGIN_SUB_REQUIRED,
|
||||
EVENT_PROMO_OPTED_OUT,
|
||||
)
|
||||
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
SubscriptionRequiredError,
|
||||
get_cloud_config,
|
||||
make_api_request,
|
||||
get_authenticated_headers,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.mount_commands import (
|
||||
mount_cloud_files,
|
||||
setup_cloud_mount,
|
||||
show_mount_status,
|
||||
unmount_cloud_files,
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import (
|
||||
BisyncError,
|
||||
generate_mount_credentials,
|
||||
get_mount_info,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import (
|
||||
RcloneInstallError,
|
||||
install_rclone,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
|
||||
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -35,364 +40,242 @@ def login():
|
||||
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
|
||||
|
||||
async def _login():
|
||||
client_id, domain, _ = get_cloud_config()
|
||||
track(EVENT_CLOUD_LOGIN_STARTED)
|
||||
client_id, domain, host_url = get_cloud_config()
|
||||
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
|
||||
|
||||
success = await auth.login()
|
||||
if not success:
|
||||
console.print("[red]Login failed[/red]")
|
||||
try:
|
||||
success = await auth.login()
|
||||
if not success:
|
||||
console.print("[red]Login failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Test subscription access by calling a protected endpoint
|
||||
console.print("[dim]Verifying subscription access...[/dim]")
|
||||
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
|
||||
|
||||
track(EVENT_CLOUD_LOGIN_SUCCESS)
|
||||
console.print("[green]Cloud authentication successful[/green]")
|
||||
console.print(f"[dim]Cloud host ready: {host_url}[/dim]")
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
track(EVENT_CLOUD_LOGIN_SUB_REQUIRED)
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(
|
||||
f"OSS discount code: [bold]{OSS_DISCOUNT_CODE}[/bold] (20% off for 3 months)\n"
|
||||
)
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
console.print(
|
||||
"[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_login())
|
||||
run_with_cleanup(_login())
|
||||
|
||||
|
||||
# Project commands
|
||||
|
||||
project_app = typer.Typer(help="Manage Basic Memory Cloud Projects")
|
||||
cloud_app.add_typer(project_app, name="project")
|
||||
|
||||
|
||||
@project_app.command("list")
|
||||
def list_projects() -> None:
|
||||
"""List projects on the cloud instance."""
|
||||
|
||||
try:
|
||||
# Get cloud configuration
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
console.print(f"[blue]Fetching projects from {host_url}...[/blue]")
|
||||
|
||||
# Make API request to list projects
|
||||
response = asyncio.run(
|
||||
make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
)
|
||||
|
||||
projects_data = response.json()
|
||||
|
||||
if not projects_data.get("projects"):
|
||||
console.print("[yellow]No projects found on the cloud instance.[/yellow]")
|
||||
return
|
||||
|
||||
# Create table for display
|
||||
table = Table(
|
||||
title="Cloud Projects", show_header=True, header_style="bold blue", min_width=60
|
||||
)
|
||||
table.add_column("Name", style="green", min_width=20)
|
||||
table.add_column("Path", style="dim", min_width=30)
|
||||
|
||||
for project in projects_data["projects"]:
|
||||
# Format the path for display
|
||||
path = project.get("path", "")
|
||||
if path.startswith("/"):
|
||||
path = f"~{path}" if path.startswith(str(Path.home())) else path
|
||||
|
||||
table.add_row(
|
||||
project.get("name", "unnamed"),
|
||||
path,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[green]Found {len(projects_data['projects'])} project(s)[/green]")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("add")
|
||||
def add_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to add"),
|
||||
set_default: bool = typer.Option(False, "--default", "-d", help="Set as default project"),
|
||||
) -> None:
|
||||
"""Create a new project on the cloud instance."""
|
||||
|
||||
# Get cloud configuration
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
# Prepare headers
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
project_path = generate_permalink(name)
|
||||
# Prepare project data
|
||||
project_data = {
|
||||
"name": name,
|
||||
"path": project_path,
|
||||
"set_default": set_default,
|
||||
}
|
||||
|
||||
console.print(project_data)
|
||||
|
||||
try:
|
||||
console.print(f"[blue]Creating project '{name}' on {host_url}...[/blue]")
|
||||
|
||||
# Make API request to create project
|
||||
response = asyncio.run(
|
||||
make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers=headers,
|
||||
json_data=project_data,
|
||||
)
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
console.print(f"[green]Project '{name}' created successfully![/green]")
|
||||
|
||||
# Display project details
|
||||
if "project" in result:
|
||||
project = result["project"]
|
||||
console.print(f" Name: {project.get('name', name)}")
|
||||
console.print(f" Path: {project.get('path', 'unknown')}")
|
||||
if project.get("id"):
|
||||
console.print(f" ID: {project['id']}")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error creating project: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("upload")
|
||||
def upload_files(
|
||||
project: str = typer.Argument(..., help="Project name to upload to"),
|
||||
path_to_files: str = typer.Argument(..., help="Local path to files or directory to upload"),
|
||||
preserve_timestamps: bool = typer.Option(
|
||||
True,
|
||||
"--preserve-timestamps/--no-preserve-timestamps",
|
||||
help="Preserve file modification times",
|
||||
),
|
||||
respect_gitignore: bool = typer.Option(
|
||||
True,
|
||||
"--respect-gitignore/--no-gitignore",
|
||||
help="Respect .gitignore patterns and skip common development artifacts",
|
||||
),
|
||||
) -> None:
|
||||
"""Upload files to a cloud project using WebDAV."""
|
||||
|
||||
# Get cloud configuration
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
# Validate local path
|
||||
local_path = Path(path_to_files).expanduser().resolve()
|
||||
if not local_path.exists():
|
||||
console.print(f"[red]Error: Path '{path_to_files}' does not exist[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Prepare headers
|
||||
headers = {}
|
||||
|
||||
try:
|
||||
# Load gitignore patterns (only if enabled)
|
||||
ignore_patterns = load_gitignore_patterns(local_path) if respect_gitignore else set()
|
||||
|
||||
# Collect files to upload
|
||||
files_to_upload = []
|
||||
ignored_count = 0
|
||||
|
||||
if local_path.is_file():
|
||||
# Single file upload - check if it should be ignored
|
||||
if not respect_gitignore or not should_ignore_path(
|
||||
local_path, local_path.parent, ignore_patterns
|
||||
):
|
||||
files_to_upload.append(local_path)
|
||||
else:
|
||||
ignored_count += 1
|
||||
else:
|
||||
# Recursively collect all files
|
||||
for file_path in local_path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
if not respect_gitignore or not should_ignore_path(
|
||||
file_path, local_path, ignore_patterns
|
||||
):
|
||||
files_to_upload.append(file_path)
|
||||
else:
|
||||
ignored_count += 1
|
||||
|
||||
# Show summary
|
||||
if ignored_count > 0 and respect_gitignore:
|
||||
console.print(
|
||||
f"[dim]Ignored {ignored_count} file(s) based on .gitignore and default patterns[/dim]"
|
||||
)
|
||||
|
||||
if not files_to_upload:
|
||||
console.print("[yellow]No files found to upload[/yellow]")
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[blue]Uploading {len(files_to_upload)} file(s) to project '{project}' on {host_url}...[/blue]"
|
||||
)
|
||||
|
||||
# Upload files using WebDAV
|
||||
asyncio.run(
|
||||
_upload_files_webdav(
|
||||
files_to_upload=files_to_upload,
|
||||
local_base_path=local_path,
|
||||
project=project,
|
||||
host_url=host_url,
|
||||
headers=headers,
|
||||
preserve_timestamps=preserve_timestamps,
|
||||
)
|
||||
)
|
||||
|
||||
console.print(f"[green]Successfully uploaded {len(files_to_upload)} file(s)![/green]")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error uploading files: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
async def _upload_files_webdav(
|
||||
files_to_upload: list[Path],
|
||||
local_base_path: Path,
|
||||
project: str,
|
||||
host_url: str,
|
||||
headers: dict,
|
||||
preserve_timestamps: bool,
|
||||
) -> None:
|
||||
"""Upload files using WebDAV protocol."""
|
||||
|
||||
# Get authentication headers for WebDAV uploads
|
||||
auth_headers = await get_authenticated_headers()
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
for file_path in files_to_upload:
|
||||
# Calculate relative path for WebDAV outside try block
|
||||
if local_base_path.is_file():
|
||||
# Single file upload - use just the filename
|
||||
relative_path = file_path.name
|
||||
else:
|
||||
# Directory upload - preserve structure
|
||||
relative_path = file_path.relative_to(local_base_path)
|
||||
|
||||
try:
|
||||
# WebDAV URL
|
||||
webdav_url = f"{host_url}/proxy/{project}/webdav/{relative_path}"
|
||||
|
||||
# Prepare upload headers
|
||||
upload_headers = dict(headers)
|
||||
upload_headers.update(auth_headers)
|
||||
|
||||
# Add timestamp preservation header if requested
|
||||
if preserve_timestamps:
|
||||
mtime = file_path.stat().st_mtime
|
||||
upload_headers["X-OC-Mtime"] = str(mtime)
|
||||
|
||||
# Disable compression for WebDAV as well
|
||||
upload_headers.setdefault("Accept-Encoding", "identity")
|
||||
|
||||
# Read file content
|
||||
file_content = file_path.read_bytes()
|
||||
|
||||
# console.print(f"[dim]Uploading {relative_path} to {webdav_url}[/dim]")
|
||||
|
||||
# Upload file
|
||||
response = await client.put(
|
||||
webdav_url, content=file_content, headers=upload_headers
|
||||
)
|
||||
|
||||
# console.print(f"[dim]WebDAV response status: {response.status_code}[/dim]")
|
||||
response.raise_for_status()
|
||||
|
||||
# Show file upload progress
|
||||
console.print(f" ✓ {relative_path}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
console.print(f" ✗ {relative_path} - {e}")
|
||||
if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
|
||||
response = e.response # type: ignore
|
||||
console.print(f"[red]WebDAV Response status: {response.status_code}[/red]")
|
||||
console.print(f"[red]WebDAV Response headers: {dict(response.headers)}[/red]")
|
||||
raise CloudAPIError(f"Failed to upload {file_path.name}: {e}") from e
|
||||
@cloud_app.command()
|
||||
def logout():
|
||||
"""Remove stored OAuth tokens."""
|
||||
config = ConfigManager().config
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
auth.logout()
|
||||
console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]")
|
||||
|
||||
|
||||
@cloud_app.command("status")
|
||||
def status() -> None:
|
||||
"""Check the status of the cloud instance."""
|
||||
"""Check cloud authentication and connection status."""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
tokens = auth.load_tokens()
|
||||
|
||||
# Get cloud configuration
|
||||
console.print("[bold blue]Cloud Status[/bold blue]")
|
||||
console.print(f" Host: {config.cloud_host}")
|
||||
console.print(
|
||||
f" API Key: {'[green]configured[/green]' if config.cloud_api_key else '[yellow]not set[/yellow]'}"
|
||||
)
|
||||
|
||||
oauth_status = "[yellow]not logged in[/yellow]"
|
||||
if tokens:
|
||||
if auth.is_token_valid(tokens):
|
||||
oauth_status = "[green]token valid[/green]"
|
||||
else:
|
||||
oauth_status = "[yellow]token expired[/yellow]"
|
||||
console.print(f" OAuth: {oauth_status}")
|
||||
|
||||
has_credentials = bool(config.cloud_api_key) or tokens is not None
|
||||
if not has_credentials:
|
||||
console.print(
|
||||
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud api-key save <key>[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
# Quick connection check — just verify we can reach the cloud
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
# Prepare headers
|
||||
headers = {}
|
||||
|
||||
try:
|
||||
console.print(f"[blue]Checking status of {host_url}...[/blue]")
|
||||
|
||||
# Make API request to check health
|
||||
response = asyncio.run(
|
||||
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
|
||||
run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health"))
|
||||
console.print("\n[green]Cloud connected[/green]")
|
||||
except CloudAPIError:
|
||||
console.print("\n[yellow]Cloud not connected[/yellow]")
|
||||
console.print(
|
||||
"[dim]Try re-authenticating with 'bm cloud login' or 'bm cloud api-key save'.[/dim]"
|
||||
)
|
||||
|
||||
health_data = response.json()
|
||||
|
||||
console.print("[green]Cloud instance is healthy[/green]")
|
||||
|
||||
# Display status details
|
||||
if "status" in health_data:
|
||||
console.print(f" Status: {health_data['status']}")
|
||||
if "version" in health_data:
|
||||
console.print(f" Version: {health_data['version']}")
|
||||
if "timestamp" in health_data:
|
||||
console.print(f" Timestamp: {health_data['timestamp']}")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error checking status: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Mount commands
|
||||
except Exception:
|
||||
console.print("\n[yellow]Cloud not connected[/yellow]")
|
||||
|
||||
|
||||
@cloud_app.command("setup")
|
||||
def setup() -> None:
|
||||
"""Set up local file access with automatic rclone installation and configuration."""
|
||||
setup_cloud_mount()
|
||||
"""Set up cloud sync by installing rclone and configuring credentials.
|
||||
|
||||
After setup, use project commands for syncing:
|
||||
bm project add <name> --cloud --local-path ~/projects/<name>
|
||||
bm project bisync --name <name> --resync # First time
|
||||
bm project bisync --name <name> # Subsequent syncs
|
||||
"""
|
||||
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
|
||||
console.print("Setting up cloud sync with rclone...\n")
|
||||
|
||||
try:
|
||||
# Step 1: Install rclone
|
||||
console.print("[blue]Step 1: Installing rclone...[/blue]")
|
||||
install_rclone()
|
||||
|
||||
# Step 2: Get tenant info
|
||||
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
|
||||
|
||||
# Step 3: Generate credentials
|
||||
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
|
||||
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
|
||||
console.print("[green]Generated secure credentials[/green]")
|
||||
|
||||
# Step 4: Configure rclone remote
|
||||
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
|
||||
configure_rclone_remote(
|
||||
access_key=creds.access_key,
|
||||
secret_key=creds.secret_key,
|
||||
)
|
||||
|
||||
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
|
||||
console.print("\n[bold]Next steps:[/bold]")
|
||||
console.print("1. Add a project with local sync path:")
|
||||
console.print(" bm project add research --cloud --local-path ~/Documents/research")
|
||||
console.print("\n Or configure sync for an existing project:")
|
||||
console.print(" bm project sync-setup research ~/Documents/research")
|
||||
console.print("\n2. Preview the initial sync (recommended):")
|
||||
console.print(" bm project bisync --name research --resync --dry-run")
|
||||
console.print("\n3. If all looks good, run the actual sync:")
|
||||
console.print(" bm project bisync --name research --resync")
|
||||
console.print("\n4. Subsequent syncs (no --resync needed):")
|
||||
console.print(" bm project bisync --name research")
|
||||
console.print(
|
||||
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
|
||||
)
|
||||
|
||||
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
|
||||
console.print(f"\n[red]Setup failed: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("mount")
|
||||
def mount(
|
||||
profile: str = typer.Option(
|
||||
"balanced", help=f"Mount profile: {', '.join(MOUNT_PROFILES.keys())}"
|
||||
),
|
||||
path: Optional[str] = typer.Option(
|
||||
None, help="Custom mount path (default: ~/basic-memory-{tenant-id})"
|
||||
),
|
||||
@cloud_app.command("promo")
|
||||
def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disable CLI promos.")):
|
||||
"""Enable or disable CLI cloud promo messages."""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_promo_opt_out = not enabled
|
||||
config_manager.save_config(config)
|
||||
|
||||
if enabled:
|
||||
console.print("[green]Cloud promo messages enabled[/green]")
|
||||
else:
|
||||
track(EVENT_PROMO_OPTED_OUT)
|
||||
console.print("[yellow]Cloud promo messages disabled[/yellow]")
|
||||
|
||||
|
||||
# --- API key management subcommand group ---
|
||||
|
||||
api_key_app = typer.Typer(help="Manage cloud API keys")
|
||||
cloud_app.add_typer(api_key_app, name="api-key")
|
||||
|
||||
|
||||
@api_key_app.command("save")
|
||||
def api_key_save(
|
||||
api_key: str = typer.Argument(..., help="API key (bmc_ prefixed) for cloud access"),
|
||||
) -> None:
|
||||
"""Mount cloud files locally for editing."""
|
||||
try:
|
||||
mount_cloud_files(profile_name=profile)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Mount failed: {e}[/red]")
|
||||
"""Save an existing API key to local config.
|
||||
|
||||
Use when you already have an API key (e.g., from the web app).
|
||||
|
||||
Example:
|
||||
bm cloud api-key save bmc_abc123...
|
||||
"""
|
||||
if not api_key.startswith("bmc_"):
|
||||
console.print("[red]Error: API key must start with 'bmc_'[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_api_key = api_key
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print("[green]API key saved[/green]")
|
||||
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
|
||||
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
|
||||
|
||||
|
||||
@api_key_app.command("create")
|
||||
def api_key_create(
|
||||
name: str = typer.Argument(..., help="Human-readable name for the API key"),
|
||||
) -> None:
|
||||
"""Create a new API key via the cloud API and save it locally.
|
||||
|
||||
Requires active OAuth session (run 'bm cloud login' first).
|
||||
|
||||
Example:
|
||||
bm cloud api-key create "my-laptop"
|
||||
"""
|
||||
|
||||
async def _create_key():
|
||||
_, _, host_url = get_cloud_config()
|
||||
host_url = host_url.rstrip("/")
|
||||
|
||||
console.print(f"[dim]Creating API key '{name}'...[/dim]")
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/api/keys",
|
||||
json_data={"name": name},
|
||||
)
|
||||
|
||||
key_data = response.json()
|
||||
api_key = key_data.get("key")
|
||||
if not api_key:
|
||||
console.print("[red]Error: No key returned from API[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Save to config
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
config.cloud_api_key = api_key
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]API key '{name}' created and saved[/green]")
|
||||
console.print("[dim]Projects set to cloud mode will use this key for authentication[/dim]")
|
||||
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
|
||||
|
||||
@cloud_app.command("unmount")
|
||||
def unmount() -> None:
|
||||
"""Unmount cloud files."""
|
||||
try:
|
||||
unmount_cloud_files()
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unmount failed: {e}[/red]")
|
||||
run_with_cleanup(_create_key())
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error creating API key: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("mount-status")
|
||||
def mount_status() -> None:
|
||||
"""Show current mount status."""
|
||||
show_mount_status()
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Cloud mount commands for Basic Memory CLI."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
|
||||
from basic_memory.cli.commands.cloud.rclone_config import (
|
||||
MOUNT_PROFILES,
|
||||
add_tenant_to_rclone_config,
|
||||
build_mount_command,
|
||||
cleanup_orphaned_rclone_processes,
|
||||
get_default_mount_path,
|
||||
get_rclone_processes,
|
||||
is_path_mounted,
|
||||
unmount_path,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class MountError(Exception):
|
||||
"""Exception raised for mount-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_tenant_info() -> dict:
|
||||
"""Get current tenant information from cloud API."""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
|
||||
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
raise MountError(f"Failed to get tenant info: {e}") from e
|
||||
|
||||
|
||||
async def generate_mount_credentials(tenant_id: str) -> dict:
|
||||
"""Generate scoped credentials for mounting."""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
|
||||
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
raise MountError(f"Failed to generate mount credentials: {e}") from e
|
||||
|
||||
|
||||
def setup_cloud_mount() -> None:
|
||||
"""Set up cloud mount with rclone installation and configuration."""
|
||||
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
|
||||
console.print("Setting up local file access to your cloud tenant...\n")
|
||||
|
||||
try:
|
||||
# Step 1: Install rclone
|
||||
console.print("[blue]Step 1: Installing rclone...[/blue]")
|
||||
install_rclone()
|
||||
|
||||
# Step 2: Get tenant info
|
||||
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
bucket_name = tenant_info.get("bucket_name")
|
||||
|
||||
if not tenant_id or not bucket_name:
|
||||
raise MountError("Invalid tenant information received from cloud API")
|
||||
|
||||
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
|
||||
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
|
||||
|
||||
# Step 3: Generate mount credentials
|
||||
console.print("\n[blue]Step 3: Generating mount credentials...[/blue]")
|
||||
creds = asyncio.run(generate_mount_credentials(tenant_id))
|
||||
|
||||
access_key = creds.get("access_key")
|
||||
secret_key = creds.get("secret_key")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise MountError("Failed to generate mount credentials")
|
||||
|
||||
console.print("[green]✓ Generated secure credentials[/green]")
|
||||
|
||||
# Step 4: Configure rclone
|
||||
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
|
||||
add_tenant_to_rclone_config(
|
||||
tenant_id=tenant_id,
|
||||
bucket_name=bucket_name,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
)
|
||||
|
||||
# Step 5: Perform initial mount
|
||||
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
|
||||
mount_path = get_default_mount_path(tenant_id)
|
||||
MOUNT_PROFILES["balanced"]
|
||||
|
||||
mount_cloud_files(
|
||||
tenant_id=tenant_id,
|
||||
bucket_name=bucket_name,
|
||||
mount_path=mount_path,
|
||||
profile_name="balanced",
|
||||
)
|
||||
|
||||
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
|
||||
console.print("\nYour cloud files are now accessible at:")
|
||||
console.print(f" {mount_path}")
|
||||
console.print("\nYou can now edit files locally and they will sync to the cloud!")
|
||||
console.print("\nUseful commands:")
|
||||
console.print(" basic-memory cloud mount-status # Check mount status")
|
||||
console.print(" basic-memory cloud unmount # Unmount files")
|
||||
console.print(" basic-memory cloud mount --profile fast # Remount with faster sync")
|
||||
|
||||
except (RcloneInstallError, MountError, CloudAPIError) as e:
|
||||
console.print(f"\n[red]Setup failed: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def mount_cloud_files(
|
||||
tenant_id: Optional[str] = None,
|
||||
bucket_name: Optional[str] = None,
|
||||
mount_path: Optional[Path] = None,
|
||||
profile_name: str = "balanced",
|
||||
) -> None:
|
||||
"""Mount cloud files with specified profile."""
|
||||
|
||||
try:
|
||||
# Get tenant info if not provided
|
||||
if not tenant_id or not bucket_name:
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
bucket_name = tenant_info.get("bucket_name")
|
||||
|
||||
if not tenant_id or not bucket_name:
|
||||
raise MountError("Could not determine tenant information")
|
||||
|
||||
# Set default mount path if not provided
|
||||
if not mount_path:
|
||||
mount_path = get_default_mount_path(tenant_id)
|
||||
|
||||
# Get mount profile
|
||||
if profile_name not in MOUNT_PROFILES:
|
||||
raise MountError(
|
||||
f"Unknown profile: {profile_name}. Available: {list(MOUNT_PROFILES.keys())}"
|
||||
)
|
||||
|
||||
profile = MOUNT_PROFILES[profile_name]
|
||||
|
||||
# Check if already mounted
|
||||
if is_path_mounted(mount_path):
|
||||
console.print(f"[yellow]Path {mount_path} is already mounted[/yellow]")
|
||||
console.print("Use 'basic-memory cloud unmount' first, or mount to a different path")
|
||||
return
|
||||
|
||||
# Create mount directory
|
||||
mount_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build and execute mount command
|
||||
mount_cmd = build_mount_command(tenant_id, bucket_name, mount_path, profile)
|
||||
|
||||
console.print(
|
||||
f"[blue]Mounting with profile '{profile_name}' ({profile.description})...[/blue]"
|
||||
)
|
||||
console.print(f"[dim]Command: {' '.join(mount_cmd)}[/dim]")
|
||||
|
||||
result = subprocess.run(mount_cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr or "Unknown error"
|
||||
raise MountError(f"Mount command failed: {error_msg}")
|
||||
|
||||
# Wait a moment for mount to establish
|
||||
time.sleep(2)
|
||||
|
||||
# Verify mount
|
||||
if is_path_mounted(mount_path):
|
||||
console.print(f"[green]✓ Successfully mounted to {mount_path}[/green]")
|
||||
console.print(f"[green]✓ Sync profile: {profile.description}[/green]")
|
||||
else:
|
||||
raise MountError("Mount command succeeded but path is not mounted")
|
||||
|
||||
except MountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise MountError(f"Unexpected error during mount: {e}") from e
|
||||
|
||||
|
||||
def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
|
||||
"""Unmount cloud files."""
|
||||
|
||||
try:
|
||||
# Get tenant info if not provided
|
||||
if not tenant_id:
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
|
||||
if not tenant_id:
|
||||
raise MountError("Could not determine tenant ID")
|
||||
|
||||
mount_path = get_default_mount_path(tenant_id)
|
||||
|
||||
if not is_path_mounted(mount_path):
|
||||
console.print(f"[yellow]Path {mount_path} is not mounted[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"[blue]Unmounting {mount_path}...[/blue]")
|
||||
|
||||
# Unmount the path
|
||||
if unmount_path(mount_path):
|
||||
console.print(f"[green]✓ Successfully unmounted {mount_path}[/green]")
|
||||
|
||||
# Clean up any orphaned rclone processes
|
||||
killed_count = cleanup_orphaned_rclone_processes()
|
||||
if killed_count > 0:
|
||||
console.print(
|
||||
f"[green]✓ Cleaned up {killed_count} orphaned rclone process(es)[/green]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]✗ Failed to unmount {mount_path}[/red]")
|
||||
console.print("You may need to manually unmount or restart your system")
|
||||
|
||||
except MountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise MountError(f"Unexpected error during unmount: {e}") from e
|
||||
|
||||
|
||||
def show_mount_status() -> None:
|
||||
"""Show current mount status and running processes."""
|
||||
|
||||
try:
|
||||
# Get tenant info
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
|
||||
if not tenant_id:
|
||||
console.print("[red]Could not determine tenant ID[/red]")
|
||||
return
|
||||
|
||||
mount_path = get_default_mount_path(tenant_id)
|
||||
|
||||
# Create status table
|
||||
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
|
||||
table.add_column("Property", style="green", min_width=15)
|
||||
table.add_column("Value", style="dim", min_width=30)
|
||||
|
||||
# Check mount status
|
||||
is_mounted = is_path_mounted(mount_path)
|
||||
mount_status = "[green]✓ Mounted[/green]" if is_mounted else "[red]✗ Not mounted[/red]"
|
||||
|
||||
table.add_row("Tenant ID", tenant_id)
|
||||
table.add_row("Mount Path", str(mount_path))
|
||||
table.add_row("Status", mount_status)
|
||||
|
||||
# Get rclone processes
|
||||
processes = get_rclone_processes()
|
||||
if processes:
|
||||
table.add_row("rclone Processes", f"{len(processes)} running")
|
||||
else:
|
||||
table.add_row("rclone Processes", "None")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Show running processes details
|
||||
if processes:
|
||||
console.print("\n[bold]Running rclone processes:[/bold]")
|
||||
for proc in processes:
|
||||
console.print(f" PID {proc['pid']}: {proc['command'][:80]}...")
|
||||
|
||||
# Show mount profiles
|
||||
console.print("\n[bold]Available mount profiles:[/bold]")
|
||||
for name, profile in MOUNT_PROFILES.items():
|
||||
console.print(f" {name}: {profile.description}")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error getting mount status: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Cloud sync commands for Basic Memory projects.
|
||||
|
||||
Commands for syncing, bisyncing, and checking integrity between local and cloud
|
||||
project instances. These were previously in project.py but belong here since
|
||||
they are cloud-specific operations.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
RcloneError,
|
||||
SyncProject,
|
||||
get_project_bisync_state,
|
||||
project_bisync,
|
||||
project_check,
|
||||
project_sync,
|
||||
)
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing
|
||||
from basic_memory.config import ConfigManager, ProjectEntry
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# --- Shared helpers ---
|
||||
|
||||
|
||||
def _has_cloud_credentials(config) -> bool:
|
||||
"""Return whether cloud credentials are available (API key or OAuth token)."""
|
||||
from basic_memory.config import has_cloud_credentials
|
||||
|
||||
return has_cloud_credentials(config)
|
||||
|
||||
|
||||
def _require_cloud_credentials(config) -> None:
|
||||
"""Exit with actionable guidance when cloud credentials are missing."""
|
||||
if _has_cloud_credentials(config):
|
||||
return
|
||||
|
||||
console.print("[red]Error: cloud credentials are required for this command[/red]")
|
||||
console.print("[dim]Run 'bm cloud login' or 'bm cloud api-key save <key>' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
async def _get_cloud_project(name: str) -> ProjectItem | None:
|
||||
"""Fetch a project by name from the cloud API."""
|
||||
async with get_client(project_name=name) as client:
|
||||
projects_list = await ProjectClient(client).list_projects()
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
return proj
|
||||
return None
|
||||
|
||||
|
||||
def _get_sync_project(
|
||||
name: str, config, project_data: ProjectItem
|
||||
) -> tuple[SyncProject, str | None]:
|
||||
"""Build a SyncProject and resolve local_sync_path from config.
|
||||
|
||||
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
|
||||
"""
|
||||
sync_entry = config.projects.get(name)
|
||||
# Support both new (path) and legacy (local_sync_path) configs
|
||||
local_sync_path = (sync_entry.local_sync_path or sync_entry.path) if sync_entry else None
|
||||
|
||||
if not local_sync_path or not os.path.isabs(local_sync_path):
|
||||
console.print(f"[red]Error: Project '{name}' has no local sync path configured[/red]")
|
||||
console.print(f"\nConfigure sync with: bm cloud sync-setup {name} ~/path/to/local")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
return sync_project, local_sync_path
|
||||
|
||||
|
||||
# --- Commands ---
|
||||
|
||||
|
||||
@cloud_app.command("sync")
|
||||
def sync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to sync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""One-way sync: local -> cloud (make cloud identical to local).
|
||||
|
||||
Example:
|
||||
bm cloud sync --name research
|
||||
bm cloud sync --name research --dry-run
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run sync
|
||||
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
|
||||
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} synced successfully[/green]")
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client(project_name=name) as client:
|
||||
return await ProjectClient(client).sync(
|
||||
project_data.external_id, force_full=False
|
||||
)
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} sync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Sync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("bisync")
|
||||
def bisync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to bisync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Two-way sync: local <-> cloud (bidirectional sync).
|
||||
|
||||
Examples:
|
||||
bm cloud bisync --name research --resync # First time
|
||||
bm cloud bisync --name research # Subsequent syncs
|
||||
bm cloud bisync --name research --dry-run # Preview changes
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run bisync
|
||||
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
|
||||
success = project_bisync(
|
||||
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
|
||||
)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} bisync completed successfully[/green]")
|
||||
|
||||
# Update config — sync_entry is guaranteed non-None because
|
||||
# _get_sync_project validated local_sync_path (which comes from sync_entry)
|
||||
sync_entry = config.projects.get(name)
|
||||
if sync_entry is None:
|
||||
raise RuntimeError(
|
||||
f"Sync entry for project '{name}' unexpectedly missing after validation"
|
||||
)
|
||||
sync_entry.last_sync = datetime.now()
|
||||
sync_entry.bisync_initialized = True
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client(project_name=name) as client:
|
||||
return await ProjectClient(client).sync(
|
||||
project_data.external_id, force_full=False
|
||||
)
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} bisync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Bisync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("check")
|
||||
def check_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to check"),
|
||||
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
|
||||
) -> None:
|
||||
"""Verify file integrity between local and cloud.
|
||||
|
||||
Example:
|
||||
bm cloud check --name research
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run check
|
||||
console.print(f"[blue]Checking {name} integrity...[/blue]")
|
||||
match = project_check(sync_project, bucket_name, one_way=one_way)
|
||||
|
||||
if match:
|
||||
console.print(f"[green]{name} files match[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]!{name} has differences[/yellow]")
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Check error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("bisync-reset")
|
||||
def bisync_reset(
|
||||
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
|
||||
) -> None:
|
||||
"""Clear bisync state for a project.
|
||||
|
||||
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
|
||||
Useful when bisync gets into an inconsistent state or when remote path changes.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
try:
|
||||
state_path = get_project_bisync_state(name)
|
||||
|
||||
if not state_path.exists():
|
||||
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
|
||||
return
|
||||
|
||||
# Remove the entire state directory
|
||||
shutil.rmtree(state_path)
|
||||
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("sync-setup")
|
||||
def setup_project_sync(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
local_path: str = typer.Argument(..., help="Local sync directory"),
|
||||
) -> None:
|
||||
"""Configure local sync for an existing cloud project.
|
||||
|
||||
Example:
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
async with get_client(project_name=name) as client:
|
||||
projects_list = await ProjectClient(client).list_projects()
|
||||
project_names = [p.name for p in projects_list.projects]
|
||||
if name not in project_names:
|
||||
raise ValueError(f"Project '{name}' not found on cloud")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Verify project exists on cloud
|
||||
with force_routing(cloud=True):
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
|
||||
# Resolve and create local path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
|
||||
resolved_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update project entry with sync path — path is always the local directory
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.path = resolved_path.as_posix()
|
||||
entry.local_sync_path = resolved_path.as_posix()
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
else:
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=resolved_path.as_posix(),
|
||||
local_sync_path=resolved_path.as_posix(),
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
# Create the project in the local DB so the MCP server can immediately use it
|
||||
async def _create_local_project():
|
||||
async with get_client() as client:
|
||||
data = {"name": name, "path": resolved_path.as_posix(), "set_default": False}
|
||||
return await ProjectClient(client).create_project(data)
|
||||
|
||||
with force_routing(local=True):
|
||||
try:
|
||||
run_with_cleanup(_create_local_project())
|
||||
except Exception:
|
||||
pass # Project may already exist locally; reconcile on next startup
|
||||
|
||||
console.print(f"[green]Sync configured for project '{name}'[/green]")
|
||||
console.print(f"\nLocal sync path: {resolved_path}")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Project-scoped rclone sync commands for Basic Memory Cloud.
|
||||
|
||||
This module provides simplified, project-scoped rclone operations:
|
||||
- Each project syncs independently
|
||||
- Uses single "basic-memory-cloud" remote (not tenant-specific)
|
||||
- Balanced defaults from SPEC-8 Phase 4 testing
|
||||
- Per-project bisync state tracking
|
||||
|
||||
Replaces tenant-wide sync with project-scoped workflows.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Protocol
|
||||
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
console = Console()
|
||||
|
||||
# Minimum rclone version for --create-empty-src-dirs support
|
||||
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
|
||||
|
||||
# Tigris edge caching returns stale data for users outside the origin region (iad).
|
||||
# --header is rclone's global flag that applies to ALL HTTP transactions (list, download,
|
||||
# upload). This is critical because bisync starts with S3 ListObjectsV2, which is neither
|
||||
# a download nor upload — so --header-download/--header-upload would miss list requests.
|
||||
# See: https://www.tigrisdata.com/docs/objects/consistency/
|
||||
TIGRIS_CONSISTENCY_HEADERS = [
|
||||
"--header",
|
||||
"X-Tigris-Consistent: true",
|
||||
]
|
||||
|
||||
|
||||
class RunResult(Protocol):
|
||||
returncode: int
|
||||
stdout: str
|
||||
|
||||
|
||||
RunFunc = Callable[..., RunResult]
|
||||
IsInstalledFunc = Callable[[], bool]
|
||||
|
||||
|
||||
class RcloneError(Exception):
|
||||
"""Exception raised for rclone command errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def check_rclone_installed(is_installed: IsInstalledFunc = is_rclone_installed) -> None:
|
||||
"""Check if rclone is installed and raise helpful error if not.
|
||||
|
||||
Raises:
|
||||
RcloneError: If rclone is not installed with installation instructions
|
||||
"""
|
||||
if not is_installed():
|
||||
raise RcloneError(
|
||||
"rclone is not installed.\n\n"
|
||||
"Install rclone by running: bm cloud setup\n"
|
||||
"Or install manually from: https://rclone.org/downloads/\n\n"
|
||||
"Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_rclone_version(run: RunFunc = subprocess.run) -> tuple[int, int, int] | None:
|
||||
"""Get rclone version as (major, minor, patch) tuple.
|
||||
|
||||
Returns:
|
||||
Version tuple like (1, 64, 2), or None if version cannot be determined.
|
||||
|
||||
Note:
|
||||
Result is cached since rclone version won't change during runtime.
|
||||
"""
|
||||
try:
|
||||
result = run(["rclone", "version"], capture_output=True, text=True, timeout=10)
|
||||
# Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
|
||||
match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
|
||||
if match:
|
||||
version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||
logger.debug(f"Detected rclone version: {version}")
|
||||
return version
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not determine rclone version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def supports_create_empty_src_dirs(version: tuple[int, int, int] | None) -> bool:
|
||||
"""Check if installed rclone supports --create-empty-src-dirs flag.
|
||||
|
||||
Returns:
|
||||
True if rclone version >= 1.64.0, False otherwise.
|
||||
"""
|
||||
if version is None:
|
||||
# If we can't determine version, assume older and skip the flag
|
||||
return False
|
||||
return version >= MIN_RCLONE_VERSION_EMPTY_DIRS
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncProject:
|
||||
"""Project configured for cloud sync.
|
||||
|
||||
Attributes:
|
||||
name: Project name
|
||||
path: Cloud path (e.g., "app/data/research")
|
||||
local_sync_path: Local directory for syncing (optional)
|
||||
"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
local_sync_path: Optional[str] = None
|
||||
|
||||
|
||||
def get_bmignore_filter_path() -> Path:
|
||||
"""Get path to rclone filter file.
|
||||
|
||||
Uses ~/.basic-memory/.bmignore converted to rclone format.
|
||||
File is automatically created with default patterns on first use.
|
||||
|
||||
Returns:
|
||||
Path to rclone filter file
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import (
|
||||
convert_bmignore_to_rclone_filters,
|
||||
)
|
||||
|
||||
return convert_bmignore_to_rclone_filters()
|
||||
|
||||
|
||||
def get_project_bisync_state(project_name: str) -> Path:
|
||||
"""Get path to project's bisync state directory.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
Path to bisync state directory for this project
|
||||
"""
|
||||
return Path.home() / ".basic-memory" / "bisync-state" / project_name
|
||||
|
||||
|
||||
def bisync_initialized(project_name: str) -> bool:
|
||||
"""Check if bisync has been initialized for this project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
|
||||
Returns:
|
||||
True if bisync state exists, False otherwise
|
||||
"""
|
||||
state_path = get_project_bisync_state(project_name)
|
||||
return state_path.exists() and any(state_path.iterdir())
|
||||
|
||||
|
||||
def get_project_remote(project: SyncProject, bucket_name: str) -> str:
|
||||
"""Build rclone remote path for project.
|
||||
|
||||
Args:
|
||||
project: Project with cloud path
|
||||
bucket_name: S3 bucket name
|
||||
|
||||
Returns:
|
||||
Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
|
||||
|
||||
Note:
|
||||
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
|
||||
is mounted at /app/data on the fly machine. We need to strip the /app/data/
|
||||
prefix to get the actual S3 path within the bucket.
|
||||
"""
|
||||
# Normalize path to strip /app/data/ mount point prefix
|
||||
cloud_path = normalize_project_path(project.path).lstrip("/")
|
||||
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
|
||||
|
||||
|
||||
def project_sync(
|
||||
project: SyncProject,
|
||||
bucket_name: str,
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False,
|
||||
*,
|
||||
run: RunFunc = subprocess.run,
|
||||
is_installed: IsInstalledFunc = is_rclone_installed,
|
||||
filter_path: Path | None = None,
|
||||
) -> bool:
|
||||
"""One-way sync: local → cloud.
|
||||
|
||||
Makes cloud identical to local using rclone sync.
|
||||
|
||||
Args:
|
||||
project: Project to sync
|
||||
bucket_name: S3 bucket name
|
||||
dry_run: Preview changes without applying
|
||||
verbose: Show detailed output
|
||||
|
||||
Returns:
|
||||
True if sync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed(is_installed=is_installed)
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
local_path = Path(project.local_sync_path).expanduser()
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
filter_path = filter_path or get_bmignore_filter_path()
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
"sync",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
*TIGRIS_CONSISTENCY_HEADERS,
|
||||
"--filter-from",
|
||||
str(filter_path),
|
||||
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
|
||||
# See: rclone/rclone#6801
|
||||
"--local-no-preallocate",
|
||||
]
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
else:
|
||||
cmd.append("--progress")
|
||||
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
result = run(cmd, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def project_bisync(
|
||||
project: SyncProject,
|
||||
bucket_name: str,
|
||||
dry_run: bool = False,
|
||||
resync: bool = False,
|
||||
verbose: bool = False,
|
||||
*,
|
||||
run: RunFunc = subprocess.run,
|
||||
is_installed: IsInstalledFunc = is_rclone_installed,
|
||||
version: tuple[int, int, int] | None = None,
|
||||
filter_path: Path | None = None,
|
||||
state_path: Path | None = None,
|
||||
is_initialized: Callable[[str], bool] = bisync_initialized,
|
||||
) -> bool:
|
||||
"""Two-way sync: local ↔ cloud.
|
||||
|
||||
Uses rclone bisync with balanced defaults:
|
||||
- conflict_resolve: newer (auto-resolve to most recent)
|
||||
- max_delete: 25 (safety limit)
|
||||
- compare: modtime (ignore size differences from line ending conversions)
|
||||
- check_access: false (skip for performance)
|
||||
|
||||
Args:
|
||||
project: Project to sync
|
||||
bucket_name: S3 bucket name
|
||||
dry_run: Preview changes without applying
|
||||
resync: Force resync to establish new baseline
|
||||
verbose: Show detailed output
|
||||
|
||||
Returns:
|
||||
True if bisync succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
|
||||
"""
|
||||
check_rclone_installed(is_installed=is_installed)
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
local_path = Path(project.local_sync_path).expanduser()
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
filter_path = filter_path or get_bmignore_filter_path()
|
||||
state_path = state_path or get_project_bisync_state(project.name)
|
||||
|
||||
# Ensure state directory exists
|
||||
state_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
"bisync",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
*TIGRIS_CONSISTENCY_HEADERS,
|
||||
"--resilient",
|
||||
"--conflict-resolve=newer",
|
||||
"--max-delete=25",
|
||||
"--compare=modtime", # Ignore size differences from line ending conversions
|
||||
"--filter-from",
|
||||
str(filter_path),
|
||||
"--workdir",
|
||||
str(state_path),
|
||||
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
|
||||
# See: rclone/rclone#6801
|
||||
"--local-no-preallocate",
|
||||
]
|
||||
|
||||
# Add --create-empty-src-dirs if rclone version supports it (v1.64+)
|
||||
version = version if version is not None else get_rclone_version(run=run)
|
||||
if supports_create_empty_src_dirs(version):
|
||||
cmd.append("--create-empty-src-dirs")
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
else:
|
||||
cmd.append("--progress")
|
||||
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
if resync:
|
||||
cmd.append("--resync")
|
||||
|
||||
# Check if first run requires resync
|
||||
if not resync and not is_initialized(project.name) and not dry_run:
|
||||
raise RcloneError(
|
||||
f"First bisync for {project.name} requires --resync to establish baseline.\n"
|
||||
f"Run: bm project bisync --name {project.name} --resync"
|
||||
)
|
||||
|
||||
result = run(cmd, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def project_check(
|
||||
project: SyncProject,
|
||||
bucket_name: str,
|
||||
one_way: bool = False,
|
||||
*,
|
||||
run: RunFunc = subprocess.run,
|
||||
is_installed: IsInstalledFunc = is_rclone_installed,
|
||||
filter_path: Path | None = None,
|
||||
) -> bool:
|
||||
"""Check integrity between local and cloud.
|
||||
|
||||
Verifies files match without transferring data.
|
||||
|
||||
Args:
|
||||
project: Project to check
|
||||
bucket_name: S3 bucket name
|
||||
one_way: Only check for missing files on destination (faster)
|
||||
|
||||
Returns:
|
||||
True if files match, False if differences found
|
||||
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_installed(is_installed=is_installed)
|
||||
|
||||
if not project.local_sync_path:
|
||||
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
||||
|
||||
local_path = Path(project.local_sync_path).expanduser()
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
filter_path = filter_path or get_bmignore_filter_path()
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
"check",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
*TIGRIS_CONSISTENCY_HEADERS,
|
||||
"--filter-from",
|
||||
str(filter_path),
|
||||
]
|
||||
|
||||
if one_way:
|
||||
cmd.append("--one-way")
|
||||
|
||||
result = run(cmd, capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def project_ls(
|
||||
project: SyncProject,
|
||||
bucket_name: str,
|
||||
path: Optional[str] = None,
|
||||
*,
|
||||
run: RunFunc = subprocess.run,
|
||||
is_installed: IsInstalledFunc = is_rclone_installed,
|
||||
) -> list[str]:
|
||||
"""List files in remote project.
|
||||
|
||||
Args:
|
||||
project: Project to list files from
|
||||
bucket_name: S3 bucket name
|
||||
path: Optional subdirectory within project
|
||||
|
||||
Returns:
|
||||
List of file paths
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If rclone command fails
|
||||
RcloneError: If rclone is not installed
|
||||
"""
|
||||
check_rclone_installed(is_installed=is_installed)
|
||||
|
||||
remote_path = get_project_remote(project, bucket_name)
|
||||
if path:
|
||||
remote_path = f"{remote_path}/{path}"
|
||||
|
||||
cmd = ["rclone", "ls", *TIGRIS_CONSISTENCY_HEADERS, remote_path]
|
||||
result = run(cmd, capture_output=True, text=True, check=True)
|
||||
return result.stdout.splitlines()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user