mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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.
|
||||
@@ -110,4 +110,58 @@ jobs:
|
||||
- name: Run tests (Postgres via testcontainers)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
just test-postgres
|
||||
|
||||
coverage:
|
||||
name: Coverage Summary (combined, Python 3.12)
|
||||
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
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run combined coverage (SQLite + Postgres)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just coverage
|
||||
|
||||
- name: Add coverage report to job summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Coverage"
|
||||
echo ""
|
||||
echo '```'
|
||||
uv run coverage report -m
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload HTML coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: htmlcov
|
||||
path: htmlcov/
|
||||
+145
@@ -1,5 +1,150 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.17.3 (2026-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **#485**: Add stable external_id (UUID) to Project and Entity models
|
||||
([`a4000f6`](https://github.com/basicmachines-co/basic-memory/commit/a4000f6))
|
||||
- Projects and entities now have immutable UUID identifiers
|
||||
- API v2 endpoints use external_id for stable references
|
||||
- Directory responses include external_id for entities
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#501**: Update mcp dependency to support protocol version 2025-11-25
|
||||
([`c6baf58`](https://github.com/basicmachines-co/basic-memory/commit/c6baf58))
|
||||
- Fixes "Unsupported protocol version" error when using Claude Code
|
||||
- Bump mcp from >=1.2.0 to >=1.23.1
|
||||
|
||||
- **#499**: Fix route ordering for cloud deployments
|
||||
([`53c4c20`](https://github.com/basicmachines-co/basic-memory/commit/53c4c20))
|
||||
|
||||
- **#486**: Skip config file update for set_default_project in cloud mode
|
||||
([`fd732aa`](https://github.com/basicmachines-co/basic-memory/commit/fd732aa))
|
||||
|
||||
- **#484**: Make RelationResponse.from_id optional to handle null permalinks
|
||||
([`537e58a`](https://github.com/basicmachines-co/basic-memory/commit/537e58a))
|
||||
|
||||
- Use upsert to prevent IntegrityError during parallel search indexing
|
||||
([`4ce2198`](https://github.com/basicmachines-co/basic-memory/commit/4ce2198))
|
||||
|
||||
- Use relative file paths in importers for cloud storage compatibility
|
||||
([`8adf1f4`](https://github.com/basicmachines-co/basic-memory/commit/8adf1f4))
|
||||
|
||||
### Internal
|
||||
|
||||
- Refactor importers to use FileService for cloud compatibility
|
||||
([`45ce181`](https://github.com/basicmachines-co/basic-memory/commit/45ce181))
|
||||
|
||||
- Strengthen integration test coverage, remove stdlib mocks
|
||||
([`b4486d2`](https://github.com/basicmachines-co/basic-memory/commit/b4486d2))
|
||||
|
||||
## v0.17.2 (2025-12-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow recent_activity discovery mode in cloud mode
|
||||
([`0bcda4a`](https://github.com/basicmachines-co/basic-memory/commit/0bcda4a))
|
||||
- Add `allow_discovery` parameter to `resolve_project_parameter()`
|
||||
- Tools like `recent_activity` can now work across all projects in cloud mode
|
||||
- Fix circular import in project_context module
|
||||
|
||||
### Internal
|
||||
|
||||
- Optimize release workflow by running lint/typecheck only (skip full tests)
|
||||
([`0b5425f`](https://github.com/basicmachines-co/basic-memory/commit/0b5425f))
|
||||
|
||||
## v0.17.1 (2025-12-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#482**: Only set BASIC_MEMORY_ENV=test during pytest runs
|
||||
([`98fbd60`](https://github.com/basicmachines-co/basic-memory/commit/98fbd60))
|
||||
- Fixes environment variable pollution affecting alembic migrations
|
||||
- Test environment detection now scoped to pytest execution only
|
||||
|
||||
## v0.17.0 (2025-12-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **#478**: Add anonymous usage telemetry with Homebrew-style opt-out
|
||||
([`856737f`](https://github.com/basicmachines-co/basic-memory/commit/856737f))
|
||||
- Privacy-respecting anonymous usage analytics
|
||||
- Easy opt-out via `BASIC_MEMORY_NO_ANALYTICS=1` environment variable
|
||||
- Helps improve Basic Memory based on real usage patterns
|
||||
|
||||
- **#474**: Add auto-format files on save with built-in Python formatter
|
||||
([`1fd680c`](https://github.com/basicmachines-co/basic-memory/commit/1fd680c))
|
||||
- Automatic markdown formatting on file save
|
||||
- Built-in Python formatter for consistent code style
|
||||
- Configurable formatting options
|
||||
|
||||
- **#447**: Complete Phase 2 of API v2 migration - MCP tools use v2 endpoints
|
||||
([`1a74d85`](https://github.com/basicmachines-co/basic-memory/commit/1a74d85))
|
||||
- All MCP tools now use optimized v2 API endpoints
|
||||
- Improved performance for knowledge graph operations
|
||||
- Foundation for future API enhancements
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix UTF-8 BOM handling in frontmatter parsing
|
||||
([`85684f8`](https://github.com/basicmachines-co/basic-memory/commit/85684f8))
|
||||
- Handles files with UTF-8 byte order marks correctly
|
||||
- Prevents frontmatter parsing failures
|
||||
|
||||
- **#475**: Handle null titles in ChatGPT import
|
||||
([`14ce5a3`](https://github.com/basicmachines-co/basic-memory/commit/14ce5a3))
|
||||
- Gracefully handles conversations without titles
|
||||
- Improved import robustness
|
||||
|
||||
- Remove MaxLen constraint from observation content
|
||||
([`45d6caf`](https://github.com/basicmachines-co/basic-memory/commit/45d6caf))
|
||||
- Allows longer observation content without truncation
|
||||
- Removes arbitrary 2000 character limit
|
||||
|
||||
- Handle FileNotFoundError gracefully during sync
|
||||
([`1652f86`](https://github.com/basicmachines-co/basic-memory/commit/1652f86))
|
||||
- Prevents sync failures when files are deleted during sync
|
||||
- More resilient file watching
|
||||
|
||||
- Use canonical project names in API response messages
|
||||
([`c23927d`](https://github.com/basicmachines-co/basic-memory/commit/c23927d))
|
||||
- Consistent project name formatting in all responses
|
||||
|
||||
- Suppress CLI warnings for cleaner output
|
||||
([`d71c6e8`](https://github.com/basicmachines-co/basic-memory/commit/d71c6e8))
|
||||
- Cleaner terminal output without spurious warnings
|
||||
|
||||
- Prevent DEBUG logs from appearing on CLI stdout
|
||||
([`63b9849`](https://github.com/basicmachines-co/basic-memory/commit/63b9849))
|
||||
- Debug logging no longer pollutes CLI output
|
||||
|
||||
- **#473**: Detect rclone version for --create-empty-src-dirs support
|
||||
([`622d37e`](https://github.com/basicmachines-co/basic-memory/commit/622d37e))
|
||||
- Automatic rclone version detection for compatibility
|
||||
- Prevents errors on older rclone versions
|
||||
|
||||
- **#471**: Prevent CLI commands from hanging on exit
|
||||
([`916baf8`](https://github.com/basicmachines-co/basic-memory/commit/916baf8))
|
||||
- Fixes CLI hang on shutdown
|
||||
- Proper async cleanup
|
||||
|
||||
- Add cloud_mode check to initialize_app()
|
||||
([`ef7adb7`](https://github.com/basicmachines-co/basic-memory/commit/ef7adb7))
|
||||
- Correct initialization for cloud deployments
|
||||
|
||||
### Internal
|
||||
|
||||
- Centralize test environment detection in config.is_test_env
|
||||
([`3cd9178`](https://github.com/basicmachines-co/basic-memory/commit/3cd9178))
|
||||
- Unified test environment detection
|
||||
- Disables analytics in test environments
|
||||
|
||||
- Make test-int-postgres compatible with macOS
|
||||
([`95937c6`](https://github.com/basicmachines-co/basic-memory/commit/95937c6))
|
||||
- Cross-platform PostgreSQL testing support
|
||||
|
||||
## v0.16.3 (2025-12-20)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -58,11 +58,69 @@ See the [README.md](README.md) file for a project overview.
|
||||
- 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
|
||||
|
||||
- `/alembic` - Alembic db migrations
|
||||
- `/api` - FastAPI implementation of REST endpoints
|
||||
- `/cli` - Typer command-line interface
|
||||
- `/importers` - Import functionality for Claude, ChatGPT, and other sources
|
||||
- `/markdown` - Markdown parsing and processing
|
||||
- `/mcp` - Model Context Protocol server implementation
|
||||
- `/models` - SQLAlchemy ORM models
|
||||
@@ -140,22 +198,26 @@ See SPEC-16 for full context manager refactor details.
|
||||
### Basic Memory Commands
|
||||
|
||||
**Local Commands:**
|
||||
- Sync knowledge: `basic-memory sync` or `basic-memory sync --watch`
|
||||
- Check sync status: `basic-memory status`
|
||||
- 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"`
|
||||
- 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`
|
||||
- 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: `basic-memory cloud login`
|
||||
- Logout: `basic-memory cloud logout`
|
||||
- Bidirectional sync: `basic-memory cloud sync`
|
||||
- Integrity check: `basic-memory cloud check`
|
||||
- Mount cloud storage: `basic-memory cloud mount`
|
||||
- Unmount cloud storage: `basic-memory cloud unmount`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
|
||||
### MCP Capabilities
|
||||
|
||||
@@ -182,18 +244,19 @@ See SPEC-16 for full context manager refactor details.
|
||||
- `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
|
||||
- `get_current_project()` - Get current project information and stats
|
||||
- `sync_status()` - Check file synchronization and background operation status
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, folder)` - 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
|
||||
- `json_canvas_spec()` - Full JSON Canvas specification for Obsidian visualization
|
||||
|
||||
### Cloud Features (v0.15.0+)
|
||||
|
||||
|
||||
@@ -466,6 +466,39 @@ tail -f ~/.basic-memory/basic-memory.log
|
||||
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
|
||||
|
||||
**What we collect:**
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands are used)
|
||||
- Error types (sanitized - no file paths or personal data)
|
||||
|
||||
**What we NEVER collect:**
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses
|
||||
|
||||
**Opting out:**
|
||||
```bash
|
||||
# Disable telemetry
|
||||
basic-memory telemetry disable
|
||||
|
||||
# Check status
|
||||
basic-memory telemetry status
|
||||
|
||||
# Re-enable
|
||||
basic-memory telemetry enable
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
```bash
|
||||
export BASIC_MEMORY_TELEMETRY_ENABLED=false
|
||||
```
|
||||
|
||||
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
## 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)
|
||||
- external analytics
|
||||
|
||||
### 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).
|
||||
- `src/basic_memory/telemetry.py`: external analytics; exercised lightly but excluded from strict coverage gate.
|
||||
|
||||
### 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).
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ test-postgres: test-unit-postgres test-int-postgres
|
||||
|
||||
# Run unit tests against SQLite
|
||||
test-unit-sqlite:
|
||||
uv run pytest -p pytest_mock -v --no-cov tests
|
||||
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run unit tests against Postgres
|
||||
test-unit-postgres:
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests against SQLite
|
||||
test-int-sqlite:
|
||||
@@ -51,7 +51,16 @@ test-int-sqlite:
|
||||
# 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:
|
||||
timeout --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
|
||||
#!/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_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
|
||||
else
|
||||
echo "⚠️ No timeout command found, running without timeout..."
|
||||
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
fi
|
||||
|
||||
# Reset Postgres test database (drops and recreates schema)
|
||||
# Useful when Alembic migration state gets out of sync during development
|
||||
@@ -89,8 +98,30 @@ test-all:
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
uv run pytest -p pytest_mock -v -n auto tests test-int --cov-report=html
|
||||
@echo "Coverage report generated in htmlcov/index.html"
|
||||
#!/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
|
||||
@@ -118,14 +149,6 @@ 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
|
||||
|
||||
# Build Windows installer
|
||||
installer-win:
|
||||
cd installer && uv run python setup.py bdist_win32
|
||||
|
||||
# Update all dependencies to latest versions
|
||||
update-deps:
|
||||
@@ -173,8 +196,9 @@ 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..."
|
||||
@@ -232,8 +256,9 @@ 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..."
|
||||
|
||||
+12
-4
@@ -15,7 +15,7 @@ dependencies = [
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.1.1",
|
||||
"pydantic[email,timezone]>=2.10.3",
|
||||
"mcp>=1.2.0",
|
||||
"mcp>=1.23.1",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"loguru>=0.7.3",
|
||||
"pyright>=1.1.390",
|
||||
@@ -38,6 +38,10 @@ dependencies = [
|
||||
"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",
|
||||
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
|
||||
]
|
||||
|
||||
|
||||
@@ -108,6 +112,8 @@ pythonVersion = "3.12"
|
||||
|
||||
[tool.coverage.run]
|
||||
concurrency = ["thread", "gevent"]
|
||||
parallel = true
|
||||
source = ["basic_memory"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
@@ -129,9 +135,11 @@ 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
|
||||
"*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
|
||||
"*/services/migration_service.py", # Complex migration scenarios
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.16.3"
|
||||
__version__ = "0.17.3"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -21,8 +21,12 @@ 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
|
||||
|
||||
@@ -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
|
||||
+112
-72
@@ -10,6 +10,42 @@ 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.
|
||||
@@ -36,101 +72,105 @@ def upgrade() -> None:
|
||||
# Add project_id to relation table
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Step 1: Add project_id column as nullable first
|
||||
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
# 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
|
||||
# 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 entity.id = relation.from_id
|
||||
)
|
||||
""")
|
||||
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(
|
||||
# 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
|
||||
op.create_index("ix_relation_project_id", "relation", ["project_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
|
||||
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
||||
# 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
|
||||
# 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 entity.id = observation.entity_id
|
||||
)
|
||||
""")
|
||||
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(
|
||||
# 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
|
||||
op.create_index("ix_observation_project_id", "observation", ["project_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":
|
||||
|
||||
+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")
|
||||
+23
-15
@@ -53,12 +53,20 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
app.state.session_maker = session_maker
|
||||
logger.info("Database connections cached in app state")
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# Start file sync if enabled
|
||||
if app_config.sync_changes and not app_config.is_test_env:
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
|
||||
# start file sync task in background
|
||||
app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
|
||||
async def _file_sync_runner() -> None:
|
||||
await initialize_file_sync(app_config)
|
||||
|
||||
app.state.sync_task = asyncio.create_task(_file_sync_runner())
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected. Skipping file sync service.")
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
app.state.sync_task = None
|
||||
|
||||
# proceed with startup
|
||||
@@ -84,17 +92,7 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Include v1 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}")
|
||||
|
||||
# Include v2 routers (ID-based paths)
|
||||
# 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}")
|
||||
@@ -104,6 +102,16 @@ app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Include v1 routers (/{project} is a catch-all, must come after specific prefixes)
|
||||
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}")
|
||||
|
||||
# Project resource router works across projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
|
||||
@@ -51,9 +51,10 @@ async def resolve_relations_background(sync_service, entity_id: int, entity_perm
|
||||
logger.debug(
|
||||
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail - this is a background task
|
||||
logger.warning(
|
||||
except Exception as e: # pragma: no cover
|
||||
# Log but don't fail - this is a background task.
|
||||
# Avoid forcing synthetic failures just for coverage.
|
||||
logger.warning( # pragma: no cover
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ async def get_project(
|
||||
|
||||
return ProjectItem(
|
||||
id=found_project.id,
|
||||
external_id=found_project.external_id,
|
||||
name=found_project.name,
|
||||
path=normalize_project_path(found_project.path),
|
||||
is_default=found_project.is_default or False,
|
||||
@@ -89,6 +90,7 @@ async def update_project(
|
||||
|
||||
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,
|
||||
@@ -102,7 +104,9 @@ async def update_project(
|
||||
# Get updated project info
|
||||
updated_project = await project_service.get_project(name)
|
||||
if not updated_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found after update")
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Project '{name}' not found after update"
|
||||
)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' updated successfully",
|
||||
@@ -111,13 +115,14 @@ async def update_project(
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
|
||||
|
||||
# Sync project filesystem
|
||||
@@ -181,10 +186,10 @@ async def project_sync_status(
|
||||
Returns:
|
||||
Scan report with details on files that need syncing
|
||||
"""
|
||||
logger.info(f"Scanning filesystem for project: {project_config.name}")
|
||||
sync_report = await sync_service.scan(project_config.home)
|
||||
logger.info(f"Scanning filesystem for project: {project_config.name}") # pragma: no cover
|
||||
sync_report = await sync_service.scan(project_config.home) # pragma: no cover
|
||||
|
||||
return SyncReportResponse.from_sync_report(sync_report)
|
||||
return SyncReportResponse.from_sync_report(sync_report) # pragma: no cover
|
||||
|
||||
|
||||
# List all available projects
|
||||
@@ -203,6 +208,7 @@ async def list_projects(
|
||||
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,
|
||||
@@ -250,6 +256,7 @@ async def add_project(
|
||||
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,
|
||||
@@ -274,11 +281,12 @@ async def add_project(
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
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,
|
||||
@@ -329,11 +337,12 @@ async def remove_project(
|
||||
await project_service.remove_project(name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' removed successfully",
|
||||
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,
|
||||
@@ -382,12 +391,14 @@ async def set_default_project(
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
external_id=default_project.external_id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
external_id=new_default_project.external_id,
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
@@ -417,6 +428,7 @@ async def get_default_project(
|
||||
|
||||
return ProjectItem(
|
||||
id=default_project.id,
|
||||
external_id=default_project.external_id,
|
||||
name=default_project.name,
|
||||
path=default_project.path,
|
||||
is_default=True,
|
||||
|
||||
@@ -31,8 +31,8 @@ def _mtime_to_datetime(entity: EntityModel) -> datetime:
|
||||
Returns the file's actual modification time, falling back to updated_at
|
||||
if mtime is not available.
|
||||
"""
|
||||
if entity.mtime:
|
||||
return datetime.fromtimestamp(entity.mtime).astimezone()
|
||||
if entity.mtime: # pragma: no cover
|
||||
return datetime.fromtimestamp(entity.mtime).astimezone() # pragma: no cover
|
||||
return entity.updated_at
|
||||
|
||||
|
||||
@@ -169,11 +169,11 @@ async def write_resource(
|
||||
# FastAPI should validate this, but if a dict somehow gets through
|
||||
# (e.g., via JSON body parsing), we need to catch it here
|
||||
if isinstance(content, dict):
|
||||
logger.error(
|
||||
logger.error( # pragma: no cover
|
||||
f"Error writing resource {file_path}: "
|
||||
f"content is a dict, expected string. Keys: {list(content.keys())}"
|
||||
)
|
||||
raise HTTPException(
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=400,
|
||||
detail="content must be a string, not a dict. "
|
||||
"Ensure request body is sent as raw string content, not JSON object.",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""V2 Directory Router - ID-based directory tree operations.
|
||||
|
||||
This router provides directory structure browsing for projects using
|
||||
integer project IDs instead of name-based identifiers.
|
||||
external_id UUIDs instead of name-based identifiers.
|
||||
|
||||
Key improvements:
|
||||
- Direct project lookup via integer primary keys
|
||||
- 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
|
||||
from fastapi import APIRouter, Query, Path
|
||||
|
||||
from basic_memory.deps import DirectoryServiceV2Dep, ProjectIdPathDep
|
||||
from basic_memory.deps import DirectoryServiceV2ExternalDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory-v2"])
|
||||
@@ -21,14 +21,14 @@ 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: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
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: Numeric project ID
|
||||
project_id: Project external UUID
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
@@ -42,8 +42,8 @@ async def get_directory_tree(
|
||||
|
||||
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_structure(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
directory_service: DirectoryServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Get folder structure for navigation (no files).
|
||||
|
||||
@@ -52,7 +52,7 @@ async def get_directory_structure(
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
project_id: Project external UUID
|
||||
|
||||
Returns:
|
||||
DirectoryNode tree containing only folders (type="directory")
|
||||
@@ -63,8 +63,8 @@ async def get_directory_structure(
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
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(
|
||||
@@ -75,7 +75,7 @@ async def list_directory(
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
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*")
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"""V2 Import Router - ID-based data import operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
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 (
|
||||
ChatGPTImporterV2Dep,
|
||||
ClaudeConversationsImporterV2Dep,
|
||||
ClaudeProjectsImporterV2Dep,
|
||||
MemoryJsonImporterV2Dep,
|
||||
ProjectIdPathDep,
|
||||
ChatGPTImporterV2ExternalDep,
|
||||
ClaudeConversationsImporterV2ExternalDep,
|
||||
ClaudeProjectsImporterV2ExternalDep,
|
||||
MemoryJsonImporterV2ExternalDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
@@ -30,15 +29,15 @@ router = APIRouter(prefix="/import", tags=["import-v2"])
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ChatGPTImporterV2Dep,
|
||||
importer: ChatGPTImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: ChatGPT importer instance.
|
||||
@@ -55,15 +54,15 @@ async def import_chatgpt(
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeConversationsImporterV2Dep,
|
||||
importer: ClaudeConversationsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: Claude conversations importer instance.
|
||||
@@ -80,15 +79,15 @@ async def import_claude_conversations(
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeProjectsImporterV2Dep,
|
||||
importer: ClaudeProjectsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude projects.json file.
|
||||
folder: The base folder to place the files in.
|
||||
importer: Claude projects importer instance.
|
||||
@@ -105,15 +104,15 @@ async def import_claude_projects(
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: MemoryJsonImporterV2Dep,
|
||||
importer: MemoryJsonImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
file: The memory.json file.
|
||||
folder: Optional destination folder within the project.
|
||||
importer: Memory JSON importer instance.
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"""V2 Knowledge Router - ID-based entity operations.
|
||||
"""V2 Knowledge Router - External ID-based entity operations.
|
||||
|
||||
This router provides ID-based CRUD operations for entities, replacing the
|
||||
path-based identifiers used in v1 with direct integer ID lookups.
|
||||
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:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with file moves
|
||||
- Better performance through indexed queries
|
||||
- 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
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityServiceV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
LinkResolverV2Dep,
|
||||
ProjectConfigV2Dep,
|
||||
EntityServiceV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
LinkResolverV2ExternalDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
AppConfigDep,
|
||||
SyncServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
ProjectIdPathDep,
|
||||
SyncServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -42,15 +42,15 @@ async def resolve_relations_background(sync_service, entity_id: int, entity_perm
|
||||
This runs asynchronously after the API response is sent, preventing
|
||||
long delays when creating entities with many relations.
|
||||
"""
|
||||
try:
|
||||
try: # pragma: no cover
|
||||
# Only resolve relations for the newly created entity
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
logger.debug(
|
||||
await sync_service.resolve_relations(entity_id=entity_id) # pragma: no cover
|
||||
logger.debug( # pragma: no cover
|
||||
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception as e: # pragma: no cover
|
||||
# Log but don't fail - this is a background task
|
||||
logger.warning(
|
||||
logger.warning( # pragma: no cover
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
@@ -60,30 +60,32 @@ async def resolve_relations_background(sync_service, entity_id: int, entity_perm
|
||||
|
||||
@router.post("/resolve", response_model=EntityResolveResponse)
|
||||
async def resolve_identifier(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
data: EntityResolveRequest,
|
||||
link_resolver: LinkResolverV2Dep,
|
||||
link_resolver: LinkResolverV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
) -> EntityResolveResponse:
|
||||
"""Resolve a string identifier (permalink, title, or path) to an entity ID.
|
||||
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
|
||||
|
||||
This endpoint provides a bridge between v1-style identifiers and v2 entity IDs.
|
||||
Use this to convert existing references to the new ID-based format.
|
||||
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 ID and metadata about how it was resolved
|
||||
Entity external_id and metadata about how it was resolved
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if identifier cannot be resolved
|
||||
|
||||
Example:
|
||||
POST /v2/{project}/knowledge/resolve
|
||||
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",
|
||||
@@ -93,25 +95,29 @@ async def resolve_identifier(
|
||||
"""
|
||||
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
|
||||
|
||||
# Try to resolve the identifier
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Could not resolve identifier: '{data.identifier}'"
|
||||
)
|
||||
# Try to resolve by external_id first
|
||||
entity = await entity_repository.get_by_external_id(data.identifier)
|
||||
resolution_method = "external_id" if entity else "search"
|
||||
|
||||
# Determine resolution method
|
||||
resolution_method = "search" # default
|
||||
if data.identifier.isdigit():
|
||||
resolution_method = "id"
|
||||
elif entity.permalink == data.identifier:
|
||||
resolution_method = "permalink"
|
||||
elif entity.title == data.identifier:
|
||||
resolution_method = "title"
|
||||
elif entity.file_path == data.identifier:
|
||||
resolution_method = "path"
|
||||
# If not found by external_id, try other resolution methods
|
||||
if not entity:
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
if entity:
|
||||
# Determine resolution method
|
||||
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}'")
|
||||
|
||||
result = EntityResolveResponse(
|
||||
external_id=entity.external_id,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
@@ -120,7 +126,7 @@ async def resolve_identifier(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: resolved '{data.identifier}' to entity_id={result.entity_id} via {resolution_method}"
|
||||
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -131,17 +137,17 @@ async def resolve_identifier(
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def get_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Get an entity by its numeric ID.
|
||||
"""Get an entity by its external ID (UUID).
|
||||
|
||||
This is the primary entity retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
This is the primary entity retrieval method in v2, using stable UUID
|
||||
identifiers that won't change with file moves.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
entity_id: External ID (UUID string)
|
||||
|
||||
Returns:
|
||||
Complete entity with observations and relations
|
||||
@@ -151,12 +157,14 @@ async def get_entity_by_id(
|
||||
"""
|
||||
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
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")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, title='{result.title}'")
|
||||
logger.info(f"API v2 response: external_id={entity_id}, title='{result.title}'")
|
||||
|
||||
return result
|
||||
|
||||
@@ -166,11 +174,11 @@ async def get_entity_by_id(
|
||||
|
||||
@router.post("/entities", response_model=EntityResponseV2)
|
||||
async def create_entity(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
@@ -178,7 +186,7 @@ async def create_entity(
|
||||
data: Entity data to create
|
||||
|
||||
Returns:
|
||||
Created entity with generated ID
|
||||
Created entity with generated external_id (UUID)
|
||||
"""
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
@@ -191,7 +199,7 @@ async def create_entity(
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: endpoint='create_entity' id={entity.id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -201,22 +209,22 @@ async def create_entity(
|
||||
|
||||
@router.put("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def update_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
sync_service: SyncServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by ID.
|
||||
"""Update an entity by external ID.
|
||||
|
||||
If the entity doesn't exist, it will be created (upsert behavior).
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
entity_id: External ID (UUID string)
|
||||
data: Updated entity data
|
||||
|
||||
Returns:
|
||||
@@ -225,7 +233,7 @@ async def update_entity_by_id(
|
||||
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = await entity_repository.get_by_id(entity_id)
|
||||
existing = await entity_repository.get_by_external_id(entity_id)
|
||||
created = existing is None
|
||||
|
||||
# Perform update or create
|
||||
@@ -237,32 +245,32 @@ async def update_entity_by_id(
|
||||
|
||||
# Schedule relation resolution for new entities
|
||||
if created:
|
||||
background_tasks.add_task(
|
||||
background_tasks.add_task( # pragma: no cover
|
||||
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: entity_id={entity_id}, created={created}, status_code={response.status_code}"
|
||||
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(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by ID using operations like append, prepend, etc.
|
||||
"""Edit an existing entity by external ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
entity_id: External ID (UUID string)
|
||||
data: Edit operation details
|
||||
|
||||
Returns:
|
||||
@@ -276,9 +284,11 @@ async def edit_entity_by_id(
|
||||
)
|
||||
|
||||
# Verify entity exists
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
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")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
try:
|
||||
# Edit using the entity's permalink or path
|
||||
@@ -298,7 +308,7 @@ async def edit_entity_by_id(
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: entity_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -313,17 +323,17 @@ async def edit_entity_by_id(
|
||||
|
||||
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
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 ID.
|
||||
"""Delete an entity by external ID.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
entity_id: External ID (UUID string)
|
||||
|
||||
Returns:
|
||||
Deletion status
|
||||
@@ -332,19 +342,19 @@ async def delete_entity_by_id(
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: entity_id={entity_id} not found, deleted=False")
|
||||
logger.info(f"API v2 response: external_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
deleted = await entity_service.delete_entity(entity_id)
|
||||
# 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)
|
||||
background_tasks.add_task(search_service.handle_delete, entity) # pragma: no cover
|
||||
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, deleted={deleted}")
|
||||
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
|
||||
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
|
||||
@@ -354,24 +364,24 @@ async def delete_entity_by_id(
|
||||
|
||||
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
|
||||
async def move_entity(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: MoveEntityRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
project_config: ProjectConfigV2Dep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
) -> EntityResponseV2:
|
||||
"""Move an entity to a new file location.
|
||||
|
||||
V2 API uses entity ID in the URL path for stable references.
|
||||
The entity ID will remain stable after the move.
|
||||
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 ID from URL path
|
||||
entity_id: Entity ID from URL path (primary identifier)
|
||||
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:
|
||||
@@ -382,10 +392,12 @@ async def move_entity(
|
||||
)
|
||||
|
||||
try:
|
||||
# First, get the entity by ID to verify it exists
|
||||
entity = await entity_repository.find_by_id(entity_id)
|
||||
# First, get the entity by external_id to verify it exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
|
||||
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(
|
||||
@@ -403,13 +415,13 @@ async def move_entity(
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: moved entity_id={moved_entity.id} to '{data.destination_path}'"
|
||||
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
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))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""V2 routes for memory:// URI operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient routing.
|
||||
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
|
||||
from fastapi import APIRouter, Query, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceV2Dep, EntityRepositoryV2Dep, ProjectIdPathDep
|
||||
from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -24,9 +24,9 @@ router = APIRouter(tags=["memory"])
|
||||
|
||||
@router.get("/memory/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
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",
|
||||
@@ -37,7 +37,7 @@ async def recent(
|
||||
"""Get recent activity context for a project.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
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)
|
||||
@@ -81,10 +81,10 @@ async def recent(
|
||||
|
||||
@router.get("/memory/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
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,
|
||||
@@ -98,7 +98,7 @@ async def get_memory_context(
|
||||
- ID-based: memory://id/123 or memory://123
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
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")
|
||||
|
||||
@@ -1,65 +1,146 @@
|
||||
"""V2 Project Router - ID-based project management operations.
|
||||
"""V2 Project Router - External ID-based project management operations.
|
||||
|
||||
This router provides ID-based CRUD operations for projects, replacing the
|
||||
name-based identifiers used in v1 with direct integer ID lookups.
|
||||
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:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with project renames
|
||||
- Better performance through indexed queries
|
||||
- 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
|
||||
from fastapi import APIRouter, HTTPException, Body, Query, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectServiceDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.utils import normalize_project_path
|
||||
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("/{project_id}", response_model=ProjectItem)
|
||||
async def get_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
@router.post("/resolve", response_model=ProjectResolveResponse)
|
||||
async def resolve_project_identifier(
|
||||
data: ProjectResolveRequest,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectItem:
|
||||
"""Get project by its numeric ID.
|
||||
) -> ProjectResolveResponse:
|
||||
"""Resolve a project identifier (name, permalink, or external_id) to project info.
|
||||
|
||||
This is the primary project retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
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:
|
||||
project_id: Numeric project ID
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Project information
|
||||
Project information including the external_id (UUID)
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
GET /v2/projects/3
|
||||
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_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 ID {project_id} not found")
|
||||
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,
|
||||
@@ -68,16 +149,16 @@ async def get_project_by_id(
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def update_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
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 ID.
|
||||
"""Update a project's information by external ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
project_id: External ID (UUID string)
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
@@ -88,7 +169,7 @@ async def update_project_by_id(
|
||||
HTTPException: 400 if validation fails, 404 if project not found
|
||||
|
||||
Example:
|
||||
PATCH /v2/projects/3
|
||||
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}")
|
||||
@@ -99,12 +180,15 @@ async def update_project_by_id(
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_repository.get_by_id(project_id)
|
||||
old_project = await project_repository.get_by_external_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
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,
|
||||
@@ -116,42 +200,44 @@ async def update_project_by_id(
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(old_project.name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_project = await project_repository.get_by_id(project_id)
|
||||
# Get updated project info (use the same external_id)
|
||||
updated_project = await project_repository.get_by_external_id(project_id)
|
||||
if not updated_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with ID {project_id} not found after update"
|
||||
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.name == project_service.default_project),
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
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_id: ProjectIdPathDep,
|
||||
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 ID.
|
||||
"""Delete a project by external ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
project_id: External ID (UUID string)
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
@@ -161,28 +247,33 @@ async def delete_project_by_id(
|
||||
HTTPException: 400 if trying to delete default project, 404 if not found
|
||||
|
||||
Example:
|
||||
DELETE /v2/projects/3?delete_notes=false
|
||||
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_id(project_id)
|
||||
old_project = await project_repository.get_by_external_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
|
||||
# Check if trying to delete the default project
|
||||
if old_project.name == project_service.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.id != project_id]
|
||||
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 += (
|
||||
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."
|
||||
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)
|
||||
@@ -194,26 +285,27 @@ async def delete_project_by_id(
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
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_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project by ID.
|
||||
"""Set a project as the default project by external ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID to set as default
|
||||
project_id: External ID (UUID string) to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
@@ -222,23 +314,24 @@ async def set_default_project_by_id(
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
PUT /v2/projects/3/default
|
||||
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
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
# Get the old default project from database
|
||||
default_project = await project_repository.get_default_project()
|
||||
if not default_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail="No default project is currently set"
|
||||
)
|
||||
|
||||
# Get the new default project
|
||||
new_default_project = await project_repository.get_by_id(project_id)
|
||||
# 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 ID {project_id} not found")
|
||||
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)
|
||||
@@ -249,16 +342,18 @@ async def set_default_project_by_id(
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_name,
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
"""V2 Prompt Router - ID-based prompt generation operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
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.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
ContextServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
EntityServiceV2Dep,
|
||||
ProjectIdPathDep,
|
||||
ContextServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
@@ -32,12 +31,12 @@ router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
|
||||
|
||||
@router.post("/continue-conversation", response_model=PromptResponse)
|
||||
async def continue_conversation(
|
||||
project_id: ProjectIdPathDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
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.
|
||||
|
||||
@@ -45,7 +44,7 @@ async def continue_conversation(
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
@@ -197,10 +196,10 @@ async def continue_conversation(
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
project_id: ProjectIdPathDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
request: SearchPromptRequest,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
@@ -210,7 +209,7 @@ async def search_prompt(
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
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
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
"""V2 Resource Router - ID-based resource content operations.
|
||||
|
||||
This router uses entity IDs for all operations, with file paths in request bodies
|
||||
when needed. This is consistent with v2's ID-first design.
|
||||
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 integer entity IDs in URL paths instead of file paths
|
||||
- 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
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pathlib import Path as PathLib
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi import APIRouter, HTTPException, Response, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigV2Dep,
|
||||
EntityServiceV2Dep,
|
||||
FileServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
ProjectIdPathDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
FileServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
@@ -35,19 +33,19 @@ router = APIRouter(prefix="/resource", tags=["resources-v2"])
|
||||
|
||||
@router.get("/{entity_id}")
|
||||
async def get_resource_content(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
config: ProjectConfigV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
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 ID.
|
||||
"""Get raw resource content by entity external_id.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
entity_id: Numeric entity ID
|
||||
project_id: Project external UUID from URL path
|
||||
entity_id: Entity external UUID
|
||||
config: Project configuration
|
||||
entity_service: Entity service for fetching entity data
|
||||
entity_repository: Entity repository for fetching entity data
|
||||
file_service: File service for reading file content
|
||||
|
||||
Returns:
|
||||
@@ -58,25 +56,25 @@ async def get_resource_content(
|
||||
"""
|
||||
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
|
||||
|
||||
# Get entity by ID
|
||||
entities = await entity_service.get_entities_by_id([entity_id])
|
||||
if not entities:
|
||||
# Get entity by external_id
|
||||
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")
|
||||
|
||||
entity = entities[0]
|
||||
|
||||
# Validate entity file path to prevent path traversal
|
||||
project_path = Path(config.home)
|
||||
project_path = PathLib(config.home)
|
||||
if not validate_project_path(entity.file_path, project_path):
|
||||
logger.error(f"Invalid file path in entity {entity.id}: {entity.file_path}")
|
||||
raise HTTPException(
|
||||
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",
|
||||
)
|
||||
|
||||
# Check file exists via file_service (for cloud compatibility)
|
||||
if not await file_service.exists(entity.file_path):
|
||||
raise HTTPException(
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404,
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
@@ -90,17 +88,17 @@ async def get_resource_content(
|
||||
|
||||
@router.post("", response_model=ResourceResponse)
|
||||
async def create_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: CreateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
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: Validated numeric project ID from URL path
|
||||
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
|
||||
@@ -108,14 +106,14 @@ async def create_resource(
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with file information including entity_id
|
||||
ResourceResponse with file information including entity_id and external_id
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for invalid file paths, 409 if file already exists
|
||||
"""
|
||||
try:
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = Path(config.home)
|
||||
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}"
|
||||
@@ -131,20 +129,20 @@ async def create_resource(
|
||||
if existing_entity:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
|
||||
f"Use PUT /resource/{existing_entity.id} to update it.",
|
||||
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.",
|
||||
)
|
||||
|
||||
# Cloud compatibility: avoid assuming a local filesystem path.
|
||||
# Delegate directory creation + writes to FileService (local or S3).
|
||||
await file_service.ensure_directory(Path(data.file_path).parent)
|
||||
await file_service.ensure_directory(PathLib(data.file_path).parent)
|
||||
checksum = await file_service.write_file(data.file_path, data.content)
|
||||
|
||||
# Get file info
|
||||
file_metadata = await file_service.get_file_metadata(data.file_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(data.file_path).name
|
||||
file_name = PathLib(data.file_path).name
|
||||
content_type = file_service.content_type(data.file_path)
|
||||
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
@@ -166,6 +164,7 @@ async def create_resource(
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
external_id=entity.external_id,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
@@ -182,21 +181,21 @@ async def create_resource(
|
||||
|
||||
@router.put("/{entity_id}", response_model=ResourceResponse)
|
||||
async def update_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: UpdateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
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 ID.
|
||||
"""Update an existing resource by entity external_id.
|
||||
|
||||
Can update content and optionally move the file to a new path.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
entity_id: Entity ID of the resource to update
|
||||
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
|
||||
@@ -210,8 +209,8 @@ async def update_resource(
|
||||
HTTPException: 404 if entity not found, 400 for invalid paths
|
||||
"""
|
||||
try:
|
||||
# Get existing entity
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
# Get existing entity by external_id
|
||||
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")
|
||||
|
||||
@@ -219,7 +218,7 @@ async def update_resource(
|
||||
target_file_path = data.file_path if data.file_path else entity.file_path
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = Path(config.home)
|
||||
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}"
|
||||
@@ -233,14 +232,14 @@ async def update_resource(
|
||||
# If moving file, handle the move
|
||||
if data.file_path and data.file_path != entity.file_path:
|
||||
# Ensure new parent directory exists (no-op for S3)
|
||||
await file_service.ensure_directory(Path(target_file_path).parent)
|
||||
await file_service.ensure_directory(PathLib(target_file_path).parent)
|
||||
|
||||
# If old file exists, remove it via file_service (for cloud compatibility)
|
||||
if await file_service.exists(entity.file_path):
|
||||
await file_service.delete_file(entity.file_path)
|
||||
else:
|
||||
# Ensure directory exists for in-place update
|
||||
await file_service.ensure_directory(Path(target_file_path).parent)
|
||||
await file_service.ensure_directory(PathLib(target_file_path).parent)
|
||||
|
||||
# Write content to target file
|
||||
checksum = await file_service.write_file(target_file_path, data.content)
|
||||
@@ -249,13 +248,13 @@ async def update_resource(
|
||||
file_metadata = await file_service.get_file_metadata(target_file_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(target_file_path).name
|
||||
file_name = PathLib(target_file_path).name
|
||||
content_type = file_service.content_type(target_file_path)
|
||||
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Update entity
|
||||
# Update entity using internal ID
|
||||
updated_entity = await entity_repository.update(
|
||||
entity_id,
|
||||
entity.id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
@@ -271,7 +270,8 @@ async def update_resource(
|
||||
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity_id,
|
||||
entity_id=entity.id,
|
||||
external_id=entity.external_id,
|
||||
file_path=target_file_path,
|
||||
checksum=checksum,
|
||||
size=file_metadata.size,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""V2 router for search operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient routing.
|
||||
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, BackgroundTasks
|
||||
from fastapi import APIRouter, BackgroundTasks, Path
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
|
||||
from basic_memory.deps import SearchServiceV2ExternalDep, EntityServiceV2ExternalDep
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
||||
router = APIRouter(tags=["search"])
|
||||
@@ -16,19 +16,19 @@ router = APIRouter(tags=["search"])
|
||||
|
||||
@router.post("/search/", response_model=SearchResponse)
|
||||
async def search(
|
||||
project_id: ProjectIdPathDep,
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
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 integer project IDs for improved performance and stability.
|
||||
V2 uses external_id UUIDs for stable API references.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
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
|
||||
@@ -51,9 +51,9 @@ async def search(
|
||||
|
||||
@router.post("/search/reindex")
|
||||
async def reindex(
|
||||
project_id: ProjectIdPathDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
search_service: SearchServiceV2Dep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Recreate and populate the search index for a project.
|
||||
|
||||
@@ -62,7 +62,7 @@ async def reindex(
|
||||
corrupted.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
project_id: Project external UUID from URL path
|
||||
background_tasks: FastAPI background tasks handler
|
||||
search_service: Search service scoped to project
|
||||
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
from typing import Optional
|
||||
# Suppress Logfire "not configured" warning - we only use Logfire in cloud/server contexts
|
||||
import os
|
||||
|
||||
import typer
|
||||
os.environ.setdefault("LOGFIRE_IGNORE_NO_CONFIG", "1")
|
||||
|
||||
from basic_memory.config import ConfigManager, init_cli_logging
|
||||
# Remove loguru's default handler IMMEDIATELY, before any other imports.
|
||||
# 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
|
||||
|
||||
logger.remove()
|
||||
|
||||
from typing import Optional # noqa: E402
|
||||
|
||||
import typer # noqa: E402
|
||||
|
||||
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -34,8 +47,22 @@ def app_callback(
|
||||
# Initialize logging for CLI (file only, no stdout)
|
||||
init_cli_logging()
|
||||
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
# Show telemetry notice and track CLI startup
|
||||
# Skip for 'mcp' command - it handles its own telemetry in lifespan
|
||||
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
|
||||
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
|
||||
show_notice_if_needed()
|
||||
track_app_started("cli")
|
||||
|
||||
# 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
|
||||
api_commands = {"mcp", "status", "sync", "project", "tool"}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
and ctx.invoked_subcommand not in api_commands
|
||||
):
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
@@ -7,6 +7,9 @@ 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
|
||||
@@ -19,7 +22,12 @@ 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
|
||||
@@ -28,6 +36,21 @@ class CLIAuth:
|
||||
# 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."""
|
||||
@@ -57,7 +80,7 @@ class CLIAuth:
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -111,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:
|
||||
@@ -201,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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -13,4 +13,6 @@ __all__ = [
|
||||
"import_chatgpt",
|
||||
"tool",
|
||||
"project",
|
||||
"format",
|
||||
"telemetry",
|
||||
]
|
||||
|
||||
@@ -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,6 +14,8 @@ from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
HttpClientFactory = Callable[[], AsyncContextManager[httpx.AsyncClient]]
|
||||
|
||||
|
||||
class CloudAPIError(Exception):
|
||||
"""Exception raised for cloud API errors."""
|
||||
@@ -38,14 +43,14 @@ 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]:
|
||||
async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Get authentication headers with JWT token.
|
||||
handles jwt refresh if needed.
|
||||
"""
|
||||
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]")
|
||||
raise typer.Exit(1)
|
||||
@@ -53,21 +58,31 @@ async def get_authenticated_headers() -> dict[str, str]:
|
||||
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:
|
||||
response = await client.request(method=method, url=url, headers=headers, json=json_data)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -16,7 +16,10 @@ class CloudUtilsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def fetch_cloud_projects() -> CloudProjectList:
|
||||
async def fetch_cloud_projects(
|
||||
*,
|
||||
api_request=make_api_request,
|
||||
) -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
@@ -27,14 +30,18 @@ async def fetch_cloud_projects() -> CloudProjectList:
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
response = await api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
|
||||
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) -> CloudProjectCreateResponse:
|
||||
async def create_cloud_project(
|
||||
project_name: str,
|
||||
*,
|
||||
api_request=make_api_request,
|
||||
) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
@@ -57,7 +64,7 @@ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
||||
set_default=False,
|
||||
)
|
||||
|
||||
response = await make_api_request(
|
||||
response = await api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers={"Content-Type": "application/json"},
|
||||
@@ -84,7 +91,7 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
|
||||
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def project_exists(project_name: str) -> bool:
|
||||
async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
|
||||
"""Check if a project exists on cloud.
|
||||
|
||||
Args:
|
||||
@@ -94,7 +101,7 @@ async def project_exists(project_name: str) -> bool:
|
||||
True if project exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
projects = await fetch_cloud_projects()
|
||||
projects = await fetch_cloud_projects(api_request=api_request)
|
||||
project_names = {p.name for p in projects.projects}
|
||||
return project_name in project_names
|
||||
except Exception:
|
||||
|
||||
@@ -9,11 +9,14 @@ This module provides simplified, project-scoped rclone operations:
|
||||
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 Optional
|
||||
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
|
||||
@@ -21,6 +24,17 @@ 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)
|
||||
|
||||
class RunResult(Protocol):
|
||||
returncode: int
|
||||
stdout: str
|
||||
|
||||
|
||||
RunFunc = Callable[..., RunResult]
|
||||
IsInstalledFunc = Callable[[], bool]
|
||||
|
||||
|
||||
class RcloneError(Exception):
|
||||
"""Exception raised for rclone command errors."""
|
||||
@@ -28,13 +42,13 @@ class RcloneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def check_rclone_installed() -> None:
|
||||
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_rclone_installed():
|
||||
if not is_installed():
|
||||
raise RcloneError(
|
||||
"rclone is not installed.\n\n"
|
||||
"Install rclone by running: bm cloud setup\n"
|
||||
@@ -43,6 +57,41 @@ def check_rclone_installed() -> None:
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
@@ -125,6 +174,10 @@ def project_sync(
|
||||
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.
|
||||
|
||||
@@ -142,14 +195,14 @@ def project_sync(
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_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 = get_bmignore_filter_path()
|
||||
filter_path = filter_path or get_bmignore_filter_path()
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
@@ -168,7 +221,7 @@ def project_sync(
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
result = subprocess.run(cmd, text=True)
|
||||
result = run(cmd, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
@@ -178,6 +231,13 @@ def project_bisync(
|
||||
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.
|
||||
|
||||
@@ -200,15 +260,15 @@ def project_bisync(
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
|
||||
"""
|
||||
check_rclone_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 = get_bmignore_filter_path()
|
||||
state_path = get_project_bisync_state(project.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)
|
||||
@@ -218,7 +278,6 @@ def project_bisync(
|
||||
"bisync",
|
||||
str(local_path),
|
||||
remote_path,
|
||||
"--create-empty-src-dirs",
|
||||
"--resilient",
|
||||
"--conflict-resolve=newer",
|
||||
"--max-delete=25",
|
||||
@@ -229,6 +288,11 @@ def project_bisync(
|
||||
str(state_path),
|
||||
]
|
||||
|
||||
# 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:
|
||||
@@ -241,13 +305,13 @@ def project_bisync(
|
||||
cmd.append("--resync")
|
||||
|
||||
# Check if first run requires resync
|
||||
if not resync and not bisync_initialized(project.name) and not dry_run:
|
||||
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 = subprocess.run(cmd, text=True)
|
||||
result = run(cmd, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
@@ -255,6 +319,10 @@ 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.
|
||||
|
||||
@@ -271,14 +339,14 @@ def project_check(
|
||||
Raises:
|
||||
RcloneError: If project has no local_sync_path configured or rclone not installed
|
||||
"""
|
||||
check_rclone_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 = get_bmignore_filter_path()
|
||||
filter_path = filter_path or get_bmignore_filter_path()
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
@@ -292,7 +360,7 @@ def project_check(
|
||||
if one_way:
|
||||
cmd.append("--one-way")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
result = run(cmd, capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
@@ -300,6 +368,9 @@ 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.
|
||||
|
||||
@@ -315,12 +386,12 @@ def project_ls(
|
||||
subprocess.CalledProcessError: If rclone command fails
|
||||
RcloneError: If rclone is not installed
|
||||
"""
|
||||
check_rclone_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", remote_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
result = run(cmd, capture_output=True, text=True, check=True)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Callable
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
@@ -20,6 +22,9 @@ async def upload_path(
|
||||
verbose: bool = False,
|
||||
use_gitignore: bool = True,
|
||||
dry_run: bool = False,
|
||||
*,
|
||||
client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
|
||||
put_func=call_put,
|
||||
) -> bool:
|
||||
"""
|
||||
Upload a file or directory to cloud project via WebDAV.
|
||||
@@ -85,8 +90,10 @@ async def upload_path(
|
||||
size_str = f"{size / (1024 * 1024):.1f} MB"
|
||||
print(f" {relative_path} ({size_str})")
|
||||
else:
|
||||
# Upload files using httpx
|
||||
async with get_client() as client:
|
||||
# Upload files using httpx.
|
||||
# Allow injection for tests (MockTransport) while keeping production default.
|
||||
cm_factory = client_cm_factory or get_client
|
||||
async with cm_factory() as client:
|
||||
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
|
||||
# Skip archive files (zip, tar, gz, etc.)
|
||||
if _is_archive_file(file_path):
|
||||
@@ -110,7 +117,7 @@ async def upload_path(
|
||||
|
||||
# Upload via HTTP PUT to WebDAV endpoint with mtime header
|
||||
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
|
||||
response = await call_put(
|
||||
response = await put_func(
|
||||
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""utility functions for commands"""
|
||||
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
from typing import Optional, TypeVar, Coroutine, Any
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
import typer
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
@@ -15,6 +17,30 @@ from basic_memory.schemas import ProjectInfoResponse
|
||||
|
||||
console = Console()
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run an async coroutine with proper database cleanup.
|
||||
|
||||
This helper ensures database connections are cleaned up before the event
|
||||
loop closes, preventing process hangs in CLI commands.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run
|
||||
|
||||
Returns:
|
||||
The result of the coroutine
|
||||
"""
|
||||
|
||||
async def _with_cleanup() -> T:
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
await db.shutdown_db()
|
||||
|
||||
return asyncio.run(_with_cleanup())
|
||||
|
||||
|
||||
async def run_sync(project: Optional[str] = None, force_full: bool = False):
|
||||
"""Run sync operation via API endpoint.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Format command for basic-memory CLI."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.file_utils import format_file
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def is_markdown_extension(path: Path) -> bool:
|
||||
"""Check if file has a markdown extension."""
|
||||
return path.suffix.lower() in (".md", ".markdown")
|
||||
|
||||
|
||||
async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]:
|
||||
"""Format a single file.
|
||||
|
||||
Returns:
|
||||
Tuple of (path, success, error_message)
|
||||
"""
|
||||
try:
|
||||
result = await format_file(
|
||||
file_path, app_config, is_markdown=is_markdown_extension(file_path)
|
||||
)
|
||||
if result is not None:
|
||||
return (file_path, True, None)
|
||||
else:
|
||||
return (file_path, False, "No formatter configured or formatting skipped")
|
||||
except Exception as e:
|
||||
return (file_path, False, str(e))
|
||||
|
||||
|
||||
async def format_files(
|
||||
paths: list[Path], app_config, show_progress: bool = True
|
||||
) -> tuple[int, int, list[tuple[Path, str]]]:
|
||||
"""Format multiple files.
|
||||
|
||||
Returns:
|
||||
Tuple of (formatted_count, skipped_count, errors)
|
||||
"""
|
||||
formatted = 0
|
||||
skipped = 0
|
||||
errors: list[tuple[Path, str]] = []
|
||||
|
||||
if show_progress:
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task("Formatting files...", total=len(paths))
|
||||
|
||||
for file_path in paths:
|
||||
path, success, error = await format_single_file(file_path, app_config)
|
||||
if success:
|
||||
formatted += 1
|
||||
elif error and "No formatter configured" not in error:
|
||||
errors.append((path, error))
|
||||
else:
|
||||
skipped += 1
|
||||
progress.update(task, advance=1)
|
||||
else:
|
||||
for file_path in paths:
|
||||
path, success, error = await format_single_file(file_path, app_config)
|
||||
if success:
|
||||
formatted += 1
|
||||
elif error and "No formatter configured" not in error:
|
||||
errors.append((path, error))
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
return formatted, skipped, errors
|
||||
|
||||
|
||||
async def run_format(
|
||||
path: Optional[Path] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Run the format command."""
|
||||
app_config = ConfigManager().config
|
||||
|
||||
# Check if formatting is enabled
|
||||
if (
|
||||
not app_config.format_on_save
|
||||
and not app_config.formatter_command
|
||||
and not app_config.formatters
|
||||
):
|
||||
console.print(
|
||||
"[yellow]No formatters configured. Set format_on_save=true and "
|
||||
"formatter_command or formatters in your config.[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"\nExample config (~/.basic-memory/config.json):\n"
|
||||
' "format_on_save": true,\n'
|
||||
' "formatter_command": "prettier --write {file}"\n'
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Temporarily enable format_on_save for this command
|
||||
# (so format_file actually runs the formatter)
|
||||
original_format_on_save = app_config.format_on_save
|
||||
app_config.format_on_save = True
|
||||
|
||||
try:
|
||||
# Determine which files to format
|
||||
if path:
|
||||
# Format specific file or directory
|
||||
if path.is_file():
|
||||
files = [path]
|
||||
elif path.is_dir():
|
||||
# Find all markdown and json files
|
||||
files = (
|
||||
list(path.rglob("*.md"))
|
||||
+ list(path.rglob("*.json"))
|
||||
+ list(path.rglob("*.canvas"))
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]Path not found: {path}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Format all files in project
|
||||
project_config = get_project_config(project)
|
||||
project_path = Path(project_config.home)
|
||||
|
||||
if not project_path.exists():
|
||||
console.print(f"[red]Project path not found: {project_path}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Find all markdown and json files
|
||||
files = (
|
||||
list(project_path.rglob("*.md"))
|
||||
+ list(project_path.rglob("*.json"))
|
||||
+ list(project_path.rglob("*.canvas"))
|
||||
)
|
||||
|
||||
if not files:
|
||||
console.print("[yellow]No files found to format.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"Found {len(files)} file(s) to format...")
|
||||
|
||||
formatted, skipped, errors = await format_files(files, app_config)
|
||||
|
||||
# Print summary
|
||||
console.print()
|
||||
if formatted > 0:
|
||||
console.print(f"[green]Formatted: {formatted} file(s)[/green]")
|
||||
if skipped > 0:
|
||||
console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]")
|
||||
if errors:
|
||||
console.print(f"[red]Errors: {len(errors)} file(s)[/red]")
|
||||
for path, error in errors:
|
||||
console.print(f" [red]{path}[/red]: {error}")
|
||||
|
||||
finally:
|
||||
# Restore original setting
|
||||
app_config.format_on_save = original_format_on_save
|
||||
|
||||
|
||||
@app.command()
|
||||
def format(
|
||||
path: Annotated[
|
||||
Optional[Path],
|
||||
typer.Argument(help="File or directory to format. Defaults to current project."),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--project", "-p", help="Project name to format."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Format files using configured formatters.
|
||||
|
||||
Uses the formatter_command or formatters settings from your config.
|
||||
By default, formats all .md, .json, and .canvas files in the current project.
|
||||
|
||||
Examples:
|
||||
basic-memory format # Format all files in current project
|
||||
basic-memory format --project research # Format files in specific project
|
||||
basic-memory format notes/meeting.md # Format a specific file
|
||||
basic-memory format notes/ # Format all files in directory
|
||||
"""
|
||||
try:
|
||||
asyncio.run(run_format(path, project))
|
||||
except Exception as e:
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.error(f"Error formatting files: {e}")
|
||||
console.print(f"[red]Error formatting files: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
raise
|
||||
@@ -3,13 +3,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Tuple
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers import ChatGPTImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.services.file_service import FileService
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -17,11 +18,14 @@ from rich.panel import Panel
|
||||
console = Console()
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
|
||||
"""Get MarkdownProcessor and FileService instances for importers."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(config.home, markdown_processor, app_config=app_config)
|
||||
return markdown_processor, file_service
|
||||
|
||||
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
@@ -48,15 +52,15 @@ def import_chatgpt(
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get importer dependencies
|
||||
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
|
||||
config = get_project_config()
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
|
||||
# Create importer and run import
|
||||
importer = ChatGPTImporter(config.home, markdown_processor)
|
||||
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
|
||||
with conversations_json.open("r", encoding="utf-8") as file:
|
||||
json_data = json.load(file)
|
||||
result = asyncio.run(importer.import_data(json_data, folder))
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Tuple
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.services.file_service import FileService
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -17,11 +18,14 @@ from rich.panel import Panel
|
||||
console = Console()
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
|
||||
"""Get MarkdownProcessor and FileService instances for importers."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(config.home, markdown_processor, app_config=app_config)
|
||||
return markdown_processor, file_service
|
||||
|
||||
|
||||
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
|
||||
@@ -49,11 +53,11 @@ def import_claude(
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get importer dependencies
|
||||
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeConversationsImporter(config.home, markdown_processor)
|
||||
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Tuple
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.services.file_service import FileService
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -17,11 +18,14 @@ from rich.panel import Panel
|
||||
console = Console()
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
|
||||
"""Get MarkdownProcessor and FileService instances for importers."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(config.home, markdown_processor, app_config=app_config)
|
||||
return markdown_processor, file_service
|
||||
|
||||
|
||||
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
|
||||
@@ -48,11 +52,11 @@ def import_projects(
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get importer dependencies
|
||||
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeProjectsImporter(config.home, markdown_processor)
|
||||
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Tuple
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.services.file_service import FileService
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -17,11 +18,14 @@ from rich.panel import Panel
|
||||
console = Console()
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
|
||||
"""Get MarkdownProcessor and FileService instances for importers."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(config.home, markdown_processor, app_config=app_config)
|
||||
return markdown_processor, file_service
|
||||
|
||||
|
||||
@import_app.command()
|
||||
@@ -47,11 +51,11 @@ def memory_json(
|
||||
|
||||
config = get_project_config()
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get importer dependencies
|
||||
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
|
||||
|
||||
# Create the importer
|
||||
importer = MemoryJsonImporter(config.home, markdown_processor)
|
||||
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home if not destination_folder else config.home / destination_folder
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""MCP server command with streamable HTTP transport."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import typer
|
||||
from typing import Optional
|
||||
@@ -8,7 +7,7 @@ from typing import Optional
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, init_mcp_logging
|
||||
|
||||
# Import mcp instance
|
||||
# Import mcp instance (has lifespan that handles initialization and file sync)
|
||||
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
|
||||
# Import mcp tools to register them
|
||||
@@ -17,8 +16,6 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
from loguru import logger
|
||||
import threading
|
||||
from basic_memory.services.initialization import initialize_file_sync
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
@@ -43,6 +40,8 @@ if not config.cloud_mode_enabled:
|
||||
- stdio: Standard I/O (good for local usage)
|
||||
- streamable-http: Recommended for web deployments (default)
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
|
||||
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
|
||||
"""
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
@@ -59,27 +58,8 @@ if not config.cloud_mode_enabled:
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"MCP server constrained to project: {project_name}")
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
def run_file_sync():
|
||||
"""Run file sync in a separate thread with its own event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(initialize_file_sync(app_config))
|
||||
except Exception as e:
|
||||
logger.error(f"File sync error: {e}", err=True)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# Start the sync thread
|
||||
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
|
||||
sync_thread.start()
|
||||
logger.info("Started file sync in background")
|
||||
|
||||
# Now run the MCP server (blocks)
|
||||
# Run the MCP server (blocks)
|
||||
# Lifespan handles: initialization, migrations, file sync, cleanup
|
||||
logger.info(f"Starting MCP server with {transport.upper()} transport")
|
||||
|
||||
if transport == "stdio":
|
||||
|
||||
@@ -16,14 +16,9 @@ from datetime import datetime
|
||||
|
||||
from rich.panel import Panel
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.project_info import ProjectStatusResponse
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
from basic_memory.mcp.tools.utils import call_patch
|
||||
|
||||
# Import rclone commands for project sync
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
@@ -254,9 +249,17 @@ def remove_project(
|
||||
|
||||
async def _remove_project():
|
||||
async with get_client() as client:
|
||||
# Convert name to permalink for efficient resolution
|
||||
project_permalink = generate_permalink(name)
|
||||
|
||||
# Use v2 project resolver to find project ID by permalink
|
||||
resolve_data = {"identifier": project_permalink}
|
||||
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
|
||||
target_project = response.json()
|
||||
|
||||
# Use v2 API with project ID
|
||||
response = await call_delete(
|
||||
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
|
||||
client, f"/v2/projects/{target_project['external_id']}?delete_notes={delete_notes}"
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
@@ -329,8 +332,18 @@ def set_default_project(
|
||||
|
||||
async def _set_default():
|
||||
async with get_client() as client:
|
||||
# Convert name to permalink for efficient resolution
|
||||
project_permalink = generate_permalink(name)
|
||||
response = await call_put(client, f"/projects/{project_permalink}/default")
|
||||
|
||||
# Use v2 project resolver to find project ID by permalink
|
||||
resolve_data = {"identifier": project_permalink}
|
||||
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
|
||||
target_project = response.json()
|
||||
|
||||
# Use v2 API with project ID
|
||||
response = await call_put(
|
||||
client, f"/v2/projects/{target_project['external_id']}/default"
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Status command for basic-memory CLI."""
|
||||
|
||||
import asyncio
|
||||
from typing import Set, Dict
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -165,8 +164,10 @@ def status(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
|
||||
try:
|
||||
asyncio.run(run_status(project, verbose)) # pragma: no cover
|
||||
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Telemetry commands for basic-memory CLI."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
# Create telemetry subcommand group
|
||||
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
|
||||
app.add_typer(telemetry_app, name="telemetry")
|
||||
|
||||
|
||||
@telemetry_app.command("enable")
|
||||
def enable() -> None:
|
||||
"""Enable anonymous telemetry.
|
||||
|
||||
Telemetry helps improve Basic Memory by collecting anonymous usage data.
|
||||
No personal data, note content, or file paths are ever collected.
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
config.telemetry_enabled = True
|
||||
config_manager.save_config(config)
|
||||
console.print("[green]Telemetry enabled[/green]")
|
||||
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
|
||||
|
||||
|
||||
@telemetry_app.command("disable")
|
||||
def disable() -> None:
|
||||
"""Disable anonymous telemetry.
|
||||
|
||||
You can re-enable telemetry anytime with: bm telemetry enable
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
config.telemetry_enabled = False
|
||||
config_manager.save_config(config)
|
||||
console.print("[yellow]Telemetry disabled[/yellow]")
|
||||
|
||||
|
||||
@telemetry_app.command("status")
|
||||
def status() -> None:
|
||||
"""Show current telemetry status and what's collected."""
|
||||
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
status_text = (
|
||||
"[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
|
||||
)
|
||||
|
||||
console.print(f"\nTelemetry: {status_text}")
|
||||
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
|
||||
console.print()
|
||||
|
||||
what_we_collect = """
|
||||
[bold]What we collect:[/bold]
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands)
|
||||
- Sync statistics (entity count, duration)
|
||||
- Error types (sanitized, no file paths)
|
||||
|
||||
[bold]What we NEVER collect:[/bold]
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses
|
||||
"""
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
what_we_collect.strip(),
|
||||
title="Telemetry Details",
|
||||
border_style="blue",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
|
||||
@@ -13,9 +13,16 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
telemetry,
|
||||
tool,
|
||||
)
|
||||
|
||||
# Re-apply warning filter AFTER all imports
|
||||
# (authlib adds a DeprecationWarning filter that overrides ours)
|
||||
import warnings # pragma: no cover
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# start the app
|
||||
app()
|
||||
|
||||
@@ -40,7 +40,7 @@ class ProjectConfig:
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
return self.name
|
||||
return self.name # pragma: no cover
|
||||
|
||||
@property
|
||||
def project_url(self) -> str: # pragma: no cover
|
||||
@@ -165,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
||||
)
|
||||
|
||||
# File formatting configuration
|
||||
format_on_save: bool = Field(
|
||||
default=False,
|
||||
description="Automatically format files after saving using configured formatter. Disabled by default.",
|
||||
)
|
||||
|
||||
formatter_command: Optional[str] = Field(
|
||||
default=None,
|
||||
description="External formatter command. Use {file} as placeholder for file path. If not set, uses built-in mdformat (Python, no Node.js required). Set to 'npx prettier --write {file}' for Prettier.",
|
||||
)
|
||||
|
||||
formatters: Dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
|
||||
)
|
||||
|
||||
formatter_timeout: float = Field(
|
||||
default=5.0,
|
||||
description="Maximum seconds to wait for formatter to complete",
|
||||
gt=0,
|
||||
)
|
||||
|
||||
# Project path constraints
|
||||
project_root: Optional[str] = Field(
|
||||
default=None,
|
||||
@@ -199,6 +221,34 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
# Telemetry configuration (Homebrew-style opt-out)
|
||||
telemetry_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
|
||||
)
|
||||
|
||||
telemetry_notice_shown: bool = Field(
|
||||
default=False,
|
||||
description="Whether the one-time telemetry notice has been shown to the user",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_test_env(self) -> bool:
|
||||
"""Check if running in a test environment.
|
||||
|
||||
Returns True if any of:
|
||||
- env field is set to "test"
|
||||
- BASIC_MEMORY_ENV environment variable is "test"
|
||||
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
|
||||
|
||||
Used to disable features like telemetry and file watchers during tests.
|
||||
"""
|
||||
return (
|
||||
self.env == "test"
|
||||
or os.getenv("BASIC_MEMORY_ENV", "").lower() == "test"
|
||||
or os.getenv("PYTEST_CURRENT_TEST") is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def cloud_mode_enabled(self) -> bool:
|
||||
"""Check if cloud mode is enabled.
|
||||
@@ -237,7 +287,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
Returns:
|
||||
BasicMemoryConfig configured for cloud mode
|
||||
"""
|
||||
return cls(
|
||||
return cls( # pragma: no cover
|
||||
database_backend=DatabaseBackend.POSTGRES,
|
||||
database_url=database_url,
|
||||
projects=projects or {},
|
||||
@@ -262,8 +312,8 @@ class BasicMemoryConfig(BaseSettings):
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
# Skip project initialization in cloud mode - projects are discovered from DB
|
||||
if self.database_backend == DatabaseBackend.POSTGRES:
|
||||
return
|
||||
if self.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
|
||||
return # pragma: no cover
|
||||
|
||||
# Ensure at least one project exists; if none exist then create main
|
||||
if not self.projects: # pragma: no cover
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,21 @@ from sqlalchemy.pool import NullPool
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Windows event loop policy
|
||||
# -----------------------------------------------------------------------------
|
||||
# On Windows, the default ProactorEventLoop has known rough edges with aiosqlite
|
||||
# during shutdown/teardown (threads posting results to a loop that's closing),
|
||||
# which can manifest as:
|
||||
# - "RuntimeError: Event loop is closed"
|
||||
# - "IndexError: pop from an empty deque"
|
||||
#
|
||||
# The SelectorEventLoop doesn't support subprocess operations, so code that uses
|
||||
# asyncio.create_subprocess_shell() (like sync_service._quick_count_files) must
|
||||
# detect Windows and use fallback implementations.
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# Module level state
|
||||
_engine: Optional[AsyncEngine] = None
|
||||
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
|
||||
+349
-28
@@ -102,7 +102,35 @@ async def get_project_config_v2(
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)] # pragma: no cover
|
||||
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)]
|
||||
|
||||
|
||||
async def get_project_config_v2_external(
|
||||
project_id: "ProjectExternalIdPathDep", project_repository: "ProjectRepositoryDep"
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the project config for v2 API (uses external_id UUID from path).
|
||||
|
||||
Args:
|
||||
project_id: The internal project ID resolved from external_id
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found (this should not happen since ProjectExternalIdPathDep already validates)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2ExternalDep = Annotated[ProjectConfig, Depends(get_project_config_v2_external)] # pragma: no cover
|
||||
|
||||
## sqlalchemy
|
||||
|
||||
@@ -190,6 +218,38 @@ async def validate_project_id(
|
||||
ProjectIdPathDep = Annotated[int, Depends(validate_project_id)]
|
||||
|
||||
|
||||
async def validate_project_external_id(
|
||||
project_id: str,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> int:
|
||||
"""Validate that a project external_id (UUID) exists in the database.
|
||||
|
||||
This is used for v2 API endpoints that take project external_ids as strings in the path.
|
||||
The project_id parameter will be automatically extracted from the URL path by FastAPI.
|
||||
|
||||
Args:
|
||||
project_id: The external UUID from the URL path (named project_id for URL consistency)
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The internal numeric project ID (for use by repositories)
|
||||
|
||||
Raises:
|
||||
HTTPException: If project with that external_id is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_external_id(project_id)
|
||||
if not project_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with external_id '{project_id}' not found.",
|
||||
)
|
||||
return project_obj.id
|
||||
|
||||
|
||||
# V2 API: Validated external UUID project ID from path (returns internal int ID)
|
||||
ProjectExternalIdPathDep = Annotated[int, Depends(validate_project_external_id)]
|
||||
|
||||
|
||||
async def get_project_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project: ProjectPathDep,
|
||||
@@ -259,6 +319,17 @@ async def get_entity_repository_v2(
|
||||
EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
|
||||
|
||||
|
||||
async def get_entity_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for v2 API (uses external_id from path)."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryV2ExternalDep = Annotated[EntityRepository, Depends(get_entity_repository_v2_external)]
|
||||
|
||||
|
||||
async def get_observation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -283,6 +354,19 @@ ObservationRepositoryV2Dep = Annotated[
|
||||
]
|
||||
|
||||
|
||||
async def get_observation_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for v2 API (uses external_id)."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryV2ExternalDep = Annotated[
|
||||
ObservationRepository, Depends(get_observation_repository_v2_external)
|
||||
]
|
||||
|
||||
|
||||
async def get_relation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -305,6 +389,17 @@ async def get_relation_repository_v2(
|
||||
RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
|
||||
|
||||
|
||||
async def get_relation_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for v2 API (uses external_id)."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryV2ExternalDep = Annotated[RelationRepository, Depends(get_relation_repository_v2_external)]
|
||||
|
||||
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -331,6 +426,17 @@ async def get_search_repository_v2(
|
||||
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
|
||||
|
||||
|
||||
async def get_search_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API (uses external_id)."""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2ExternalDep = Annotated[SearchRepository, Depends(get_search_repository_v2_external)]
|
||||
|
||||
|
||||
# ProjectInfoRepository is deprecated and will be removed in a future version.
|
||||
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
|
||||
|
||||
@@ -351,24 +457,46 @@ async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityPars
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_entity_parser_v2_external(project_config: ProjectConfigV2ExternalDep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserV2ExternalDep = Annotated["EntityParser", Depends(get_entity_parser_v2_external)]
|
||||
|
||||
|
||||
async def get_markdown_processor(
|
||||
entity_parser: EntityParserDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2(entity_parser: EntityParserV2Dep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_markdown_processor_v2(
|
||||
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2_external(
|
||||
entity_parser: EntityParserV2ExternalDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2ExternalDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2_external)]
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
|
||||
)
|
||||
@@ -379,9 +507,11 @@ FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
@@ -391,6 +521,21 @@ async def get_file_service_v2(
|
||||
FileServiceV2Dep = Annotated[FileService, Depends(get_file_service_v2)]
|
||||
|
||||
|
||||
async def get_file_service_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceV2ExternalDep = Annotated[FileService, Depends(get_file_service_v2_external)]
|
||||
|
||||
|
||||
async def get_entity_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
@@ -398,6 +543,7 @@ async def get_entity_service(
|
||||
entity_parser: EntityParserDep,
|
||||
file_service: FileServiceDep,
|
||||
link_resolver: "LinkResolverDep",
|
||||
search_service: "SearchServiceDep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService with repository."""
|
||||
@@ -408,6 +554,7 @@ async def get_entity_service(
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
@@ -422,6 +569,7 @@ async def get_entity_service_v2(
|
||||
entity_parser: EntityParserV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
link_resolver: "LinkResolverV2Dep",
|
||||
search_service: "SearchServiceV2Dep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API."""
|
||||
@@ -432,6 +580,7 @@ async def get_entity_service_v2(
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
@@ -439,6 +588,32 @@ async def get_entity_service_v2(
|
||||
EntityServiceV2Dep = Annotated[EntityService, Depends(get_entity_service_v2)]
|
||||
|
||||
|
||||
async def get_entity_service_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
observation_repository: ObservationRepositoryV2ExternalDep,
|
||||
relation_repository: RelationRepositoryV2ExternalDep,
|
||||
entity_parser: EntityParserV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
link_resolver: "LinkResolverV2ExternalDep",
|
||||
search_service: "SearchServiceV2ExternalDep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API (uses external_id)."""
|
||||
return EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceV2ExternalDep = Annotated[EntityService, Depends(get_entity_service_v2_external)]
|
||||
|
||||
|
||||
async def get_search_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -463,6 +638,18 @@ async def get_search_service_v2(
|
||||
SearchServiceV2Dep = Annotated[SearchService, Depends(get_search_service_v2)]
|
||||
|
||||
|
||||
async def get_search_service_v2_external(
|
||||
search_repository: SearchRepositoryV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService for v2 API (uses external_id)."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceV2ExternalDep = Annotated[SearchService, Depends(get_search_service_v2_external)]
|
||||
|
||||
|
||||
async def get_link_resolver(
|
||||
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
|
||||
) -> LinkResolver:
|
||||
@@ -481,6 +668,15 @@ async def get_link_resolver_v2(
|
||||
LinkResolverV2Dep = Annotated[LinkResolver, Depends(get_link_resolver_v2)]
|
||||
|
||||
|
||||
async def get_link_resolver_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep, search_service: SearchServiceV2ExternalDep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverV2ExternalDep = Annotated[LinkResolver, Depends(get_link_resolver_v2_external)]
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -512,6 +708,22 @@ async def get_context_service_v2(
|
||||
ContextServiceV2Dep = Annotated[ContextService, Depends(get_context_service_v2)]
|
||||
|
||||
|
||||
async def get_context_service_v2_external(
|
||||
search_repository: SearchRepositoryV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
observation_repository: ObservationRepositoryV2ExternalDep,
|
||||
) -> ContextService:
|
||||
"""Create ContextService for v2 API (uses external_id)."""
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceV2ExternalDep = Annotated[ContextService, Depends(get_context_service_v2_external)]
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceDep,
|
||||
@@ -567,6 +779,32 @@ async def get_sync_service_v2(
|
||||
SyncServiceV2Dep = Annotated[SyncService, Depends(get_sync_service_v2)]
|
||||
|
||||
|
||||
async def get_sync_service_v2_external(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_parser: EntityParserV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
relation_repository: RelationRepositoryV2ExternalDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""Create SyncService for v2 API (uses external_id)."""
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
project_repository=project_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceV2ExternalDep = Annotated[SyncService, Depends(get_sync_service_v2_external)]
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectService:
|
||||
@@ -601,24 +839,40 @@ async def get_directory_service_v2(
|
||||
DirectoryServiceV2Dep = Annotated[DirectoryService, Depends(get_directory_service_v2)]
|
||||
|
||||
|
||||
async def get_directory_service_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService for v2 API (uses external_id from path)."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceV2ExternalDep = Annotated[DirectoryService, Depends(get_directory_service_v2_external)]
|
||||
|
||||
|
||||
# Import
|
||||
|
||||
|
||||
async def get_chatgpt_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor)
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
||||
"""Create ClaudeConversationsImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterDep = Annotated[
|
||||
@@ -627,20 +881,24 @@ ClaudeConversationsImporterDep = Annotated[
|
||||
|
||||
|
||||
async def get_claude_projects_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
||||
"""Create ClaudeProjectsImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
||||
|
||||
|
||||
async def get_memory_json_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor)
|
||||
"""Create MemoryJsonImporter with dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
@@ -650,20 +908,24 @@ MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_im
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor)
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
@@ -672,10 +934,12 @@ ClaudeConversationsImporterV2Dep = Annotated[
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
@@ -684,10 +948,67 @@ ClaudeProjectsImporterV2Dep = Annotated[
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor)
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
|
||||
|
||||
# V2 External Import dependencies (using external_id)
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 external_id dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2ExternalDep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2_external)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2ExternalDep = Annotated[
|
||||
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2_external)
|
||||
]
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 external_id dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2ExternalDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2_external)]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Utilities for file operations."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Dict, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
|
||||
import aiofiles
|
||||
import yaml
|
||||
@@ -14,6 +16,9 @@ from loguru import logger
|
||||
|
||||
from basic_memory.utils import FilePath
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
@@ -69,6 +74,28 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
|
||||
raise FileError(f"Failed to compute checksum: {e}")
|
||||
|
||||
|
||||
# UTF-8 BOM character that can appear at the start of files
|
||||
UTF8_BOM = "\ufeff"
|
||||
|
||||
|
||||
def strip_bom(content: str) -> str:
|
||||
"""Strip UTF-8 BOM from the start of content if present.
|
||||
|
||||
BOM (Byte Order Mark) characters can be present in files created on Windows
|
||||
or copied from certain sources. They should be stripped before processing
|
||||
frontmatter. See issue #452.
|
||||
|
||||
Args:
|
||||
content: Content that may start with BOM
|
||||
|
||||
Returns:
|
||||
Content with BOM removed if present
|
||||
"""
|
||||
if content and content.startswith(UTF8_BOM):
|
||||
return content[1:]
|
||||
return content
|
||||
|
||||
|
||||
async def write_file_atomic(path: FilePath, content: str) -> None:
|
||||
"""
|
||||
Write file with atomic operation using temporary file.
|
||||
@@ -100,6 +127,168 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
|
||||
raise FileWriteError(f"Failed to write file {path}: {e}")
|
||||
|
||||
|
||||
async def format_markdown_builtin(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Format a markdown file using the built-in mdformat formatter.
|
||||
|
||||
Uses mdformat with GFM (GitHub Flavored Markdown) support for consistent
|
||||
formatting without requiring Node.js or external tools.
|
||||
|
||||
Args:
|
||||
path: Path to the markdown file to format
|
||||
|
||||
Returns:
|
||||
Formatted content if successful, None if formatting failed.
|
||||
"""
|
||||
try:
|
||||
import mdformat
|
||||
except ImportError: # pragma: no cover
|
||||
logger.warning(
|
||||
"mdformat not installed, skipping built-in formatting",
|
||||
path=str(path),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Read original content
|
||||
async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
|
||||
# Format using mdformat with GFM and frontmatter extensions
|
||||
# mdformat is synchronous, so we run it in a thread executor
|
||||
loop = asyncio.get_event_loop()
|
||||
formatted_content = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: mdformat.text(
|
||||
content,
|
||||
extensions={"gfm", "frontmatter"}, # GFM + YAML frontmatter support
|
||||
options={"wrap": "no"}, # Don't wrap lines
|
||||
),
|
||||
)
|
||||
|
||||
# Only write if content changed
|
||||
if formatted_content != content:
|
||||
async with aiofiles.open(path, mode="w", encoding="utf-8") as f:
|
||||
await f.write(formatted_content)
|
||||
|
||||
logger.debug(
|
||||
"Formatted file with mdformat",
|
||||
path=str(path),
|
||||
changed=formatted_content != content,
|
||||
)
|
||||
return formatted_content
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(
|
||||
"mdformat formatting failed",
|
||||
path=str(path),
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def format_file(
|
||||
path: Path,
|
||||
config: "BasicMemoryConfig",
|
||||
is_markdown: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Format a file using configured formatter.
|
||||
|
||||
By default, uses the built-in mdformat formatter for markdown files (pure Python,
|
||||
no Node.js required). External formatters like Prettier can be configured via
|
||||
formatter_command or per-extension formatters.
|
||||
|
||||
Args:
|
||||
path: File to format
|
||||
config: Configuration with formatter settings
|
||||
is_markdown: Whether this is a markdown file (caller should use FileService.is_markdown)
|
||||
|
||||
Returns:
|
||||
Formatted content if successful, None if formatting was skipped or failed.
|
||||
Failures are logged as warnings but don't raise exceptions.
|
||||
"""
|
||||
if not config.format_on_save:
|
||||
return None
|
||||
|
||||
extension = path.suffix.lstrip(".")
|
||||
formatter = config.formatters.get(extension) or config.formatter_command
|
||||
|
||||
# Use built-in mdformat for markdown files when no external formatter configured
|
||||
if not formatter:
|
||||
if is_markdown:
|
||||
return await format_markdown_builtin(path)
|
||||
else:
|
||||
logger.debug("No formatter configured for extension", extension=extension)
|
||||
return None
|
||||
|
||||
# Use external formatter
|
||||
# Replace {file} placeholder with the actual path
|
||||
cmd = formatter.replace("{file}", str(path))
|
||||
|
||||
try:
|
||||
# Parse command into args list for safer execution (no shell=True)
|
||||
args = shlex.split(cmd)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=config.formatter_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
logger.warning(
|
||||
"Formatter timed out",
|
||||
path=str(path),
|
||||
timeout=config.formatter_timeout,
|
||||
)
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"Formatter exited with non-zero status",
|
||||
path=str(path),
|
||||
returncode=proc.returncode,
|
||||
stderr=stderr.decode("utf-8", errors="replace") if stderr else "",
|
||||
)
|
||||
# Still try to read the file - formatter may have partially worked
|
||||
# or the file may be unchanged
|
||||
|
||||
# Read formatted content
|
||||
async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
|
||||
formatted_content = await f.read()
|
||||
|
||||
logger.debug(
|
||||
"Formatted file successfully",
|
||||
path=str(path),
|
||||
formatter=args[0] if args else formatter,
|
||||
)
|
||||
return formatted_content
|
||||
|
||||
except FileNotFoundError:
|
||||
# Formatter executable not found
|
||||
logger.warning(
|
||||
"Formatter executable not found",
|
||||
command=cmd.split()[0] if cmd else "",
|
||||
path=str(path),
|
||||
)
|
||||
return None
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(
|
||||
"Formatter failed",
|
||||
path=str(path),
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def has_frontmatter(content: str) -> bool:
|
||||
"""
|
||||
Check if content contains valid YAML frontmatter.
|
||||
@@ -113,7 +302,8 @@ def has_frontmatter(content: str) -> bool:
|
||||
if not content:
|
||||
return False
|
||||
|
||||
content = content.strip()
|
||||
# Strip BOM before checking for frontmatter markers
|
||||
content = strip_bom(content).strip()
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
|
||||
@@ -134,6 +324,8 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
|
||||
ParseError: If frontmatter is invalid or parsing fails
|
||||
"""
|
||||
try:
|
||||
# Strip BOM before parsing frontmatter
|
||||
content = strip_bom(content)
|
||||
if not content.strip().startswith("---"):
|
||||
raise ParseError("Content has no frontmatter")
|
||||
|
||||
@@ -175,7 +367,8 @@ def remove_frontmatter(content: str) -> str:
|
||||
Raises:
|
||||
ParseError: If content starts with frontmatter marker but is malformed
|
||||
"""
|
||||
content = content.strip()
|
||||
# Strip BOM before processing
|
||||
content = strip_bom(content).strip()
|
||||
|
||||
# Return as-is if no frontmatter marker
|
||||
if not content.startswith("---"):
|
||||
|
||||
@@ -161,13 +161,13 @@ def load_bmignore_patterns() -> Set[str]:
|
||||
# Skip empty lines and comments
|
||||
if line and not line.startswith("#"):
|
||||
patterns.add(line)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
# If we can't read .bmignore, fall back to defaults
|
||||
return set(DEFAULT_IGNORE_PATTERNS)
|
||||
return set(DEFAULT_IGNORE_PATTERNS) # pragma: no cover
|
||||
|
||||
# If no patterns were loaded, use defaults
|
||||
if not patterns:
|
||||
return set(DEFAULT_IGNORE_PATTERNS)
|
||||
if not patterns: # pragma: no cover
|
||||
return set(DEFAULT_IGNORE_PATTERNS) # pragma: no cover
|
||||
|
||||
return patterns
|
||||
|
||||
@@ -261,7 +261,7 @@ def should_ignore_path(file_path: Path, base_path: Path, ignore_patterns: Set[st
|
||||
|
||||
# Glob pattern match on full path
|
||||
if fnmatch.fnmatch(relative_posix, pattern) or fnmatch.fnmatch(relative_str, pattern):
|
||||
return True
|
||||
return True # pragma: no cover
|
||||
|
||||
return False
|
||||
except ValueError:
|
||||
|
||||
@@ -3,28 +3,43 @@
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Optional, TypeVar
|
||||
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown
|
||||
from basic_memory.schemas.importer import ImportResult
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.services.file_service import FileService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=ImportResult)
|
||||
|
||||
|
||||
class Importer[T: ImportResult]:
|
||||
"""Base class for all import services."""
|
||||
"""Base class for all import services.
|
||||
|
||||
def __init__(self, base_path: Path, markdown_processor: MarkdownProcessor):
|
||||
All file operations are delegated to FileService, which can be overridden
|
||||
in cloud environments to use S3 or other storage backends.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: Path,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
file_service: "FileService",
|
||||
):
|
||||
"""Initialize the import service.
|
||||
|
||||
Args:
|
||||
markdown_processor: MarkdownProcessor instance for writing markdown files.
|
||||
base_path: Base path for the project.
|
||||
markdown_processor: MarkdownProcessor instance for markdown serialization.
|
||||
file_service: FileService instance for all file operations.
|
||||
"""
|
||||
self.base_path = base_path.resolve() # Get absolute path
|
||||
self.markdown_processor = markdown_processor
|
||||
self.file_service = file_service
|
||||
|
||||
@abstractmethod
|
||||
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
|
||||
@@ -40,28 +55,34 @@ class Importer[T: ImportResult]:
|
||||
"""
|
||||
pass # pragma: no cover
|
||||
|
||||
async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> None:
|
||||
"""Write entity to file using markdown processor.
|
||||
async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> str:
|
||||
"""Write entity to file using FileService.
|
||||
|
||||
This method serializes the entity to markdown and writes it using
|
||||
FileService, which handles directory creation and storage backend
|
||||
abstraction (local filesystem vs cloud storage).
|
||||
|
||||
Args:
|
||||
entity: EntityMarkdown instance to write.
|
||||
file_path: Path to write the entity to.
|
||||
"""
|
||||
await self.markdown_processor.write_file(file_path, entity)
|
||||
|
||||
def ensure_folder_exists(self, folder: str) -> Path:
|
||||
"""Ensure folder exists, create if it doesn't.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the project.
|
||||
folder: Folder name or path within the project.
|
||||
file_path: Relative path to write the entity to. FileService handles base_path.
|
||||
|
||||
Returns:
|
||||
Path to the folder.
|
||||
Checksum of written file.
|
||||
"""
|
||||
folder_path = self.base_path / folder
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return folder_path
|
||||
content = self.markdown_processor.to_markdown_string(entity)
|
||||
# FileService.write_file handles directory creation and returns checksum
|
||||
return await self.file_service.write_file(file_path, content)
|
||||
|
||||
async def ensure_folder_exists(self, folder: str) -> None:
|
||||
"""Ensure folder exists using FileService.
|
||||
|
||||
For cloud storage (S3), this is essentially a no-op since S3 doesn't
|
||||
have actual folders - they're just key prefixes.
|
||||
|
||||
Args:
|
||||
folder: Relative folder path within the project. FileService handles base_path.
|
||||
"""
|
||||
await self.file_service.ensure_directory(folder)
|
||||
|
||||
@abstractmethod
|
||||
def handle_error(
|
||||
|
||||
@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
|
||||
class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing ChatGPT conversations."""
|
||||
|
||||
def handle_error(
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ChatImportResult:
|
||||
"""Return a failed ChatImportResult with an error message."""
|
||||
error_msg = f"{message}: {error}" if error else message
|
||||
return ChatImportResult(
|
||||
import_count={},
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
conversations=0,
|
||||
messages=0,
|
||||
)
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ChatImportResult:
|
||||
@@ -30,7 +43,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
# Ensure the destination folder exists
|
||||
self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
conversations = source_data
|
||||
|
||||
# Process each conversation
|
||||
@@ -41,8 +54,8 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(destination_folder, chat)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
# Count messages
|
||||
@@ -67,7 +80,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import ChatGPT conversations")
|
||||
return self.handle_error("Failed to import ChatGPT conversations", e) # pyright: ignore [reportReturnType]
|
||||
return self.handle_error("Failed to import ChatGPT conversations", e)
|
||||
|
||||
def _format_chat_content(
|
||||
self, folder: str, conversation: Dict[str, Any]
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
||||
from basic_memory.importers.base import Importer
|
||||
@@ -16,6 +15,19 @@ logger = logging.getLogger(__name__)
|
||||
class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing Claude conversations."""
|
||||
|
||||
def handle_error(
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ChatImportResult:
|
||||
"""Return a failed ChatImportResult with an error message."""
|
||||
error_msg = f"{message}: {error}" if error else message
|
||||
return ChatImportResult(
|
||||
import_count={},
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
conversations=0,
|
||||
messages=0,
|
||||
)
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ChatImportResult:
|
||||
@@ -31,7 +43,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""
|
||||
try:
|
||||
# Ensure the destination folder exists
|
||||
folder_path = self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
conversations = source_data
|
||||
|
||||
@@ -45,15 +57,15 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
base_path=folder_path,
|
||||
folder=destination_folder,
|
||||
name=chat_name,
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / Path(f"{entity.frontmatter.metadata['permalink']}.md")
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
chats_imported += 1
|
||||
@@ -68,11 +80,11 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import Claude conversations")
|
||||
return self.handle_error("Failed to import Claude conversations", e) # pyright: ignore [reportReturnType]
|
||||
return self.handle_error("Failed to import Claude conversations", e)
|
||||
|
||||
def _format_chat_content(
|
||||
self,
|
||||
base_path: Path,
|
||||
folder: str,
|
||||
name: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
created_at: str,
|
||||
@@ -81,7 +93,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""Convert chat messages to Basic Memory entity format.
|
||||
|
||||
Args:
|
||||
base_path: Base path for the entity.
|
||||
folder: Destination folder name (relative path).
|
||||
name: Chat name.
|
||||
messages: List of chat messages.
|
||||
created_at: Creation timestamp.
|
||||
@@ -90,10 +102,10 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
Returns:
|
||||
EntityMarkdown instance representing the conversation.
|
||||
"""
|
||||
# Generate permalink
|
||||
# Generate permalink using folder name (relative path)
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{base_path.name}/{date_prefix}-{clean_title}"
|
||||
permalink = f"{folder}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
|
||||
@@ -14,6 +14,19 @@ logger = logging.getLogger(__name__)
|
||||
class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"""Service for importing Claude projects."""
|
||||
|
||||
def handle_error(
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ProjectImportResult:
|
||||
"""Return a failed ProjectImportResult with an error message."""
|
||||
error_msg = f"{message}: {error}" if error else message
|
||||
return ProjectImportResult(
|
||||
import_count={},
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
documents=0,
|
||||
prompts=0,
|
||||
)
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ProjectImportResult:
|
||||
@@ -29,9 +42,8 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"""
|
||||
try:
|
||||
# Ensure the base folder exists
|
||||
base_path = self.base_path
|
||||
if destination_folder:
|
||||
base_path = self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
projects = source_data
|
||||
|
||||
@@ -42,20 +54,26 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
for project in projects:
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create project directories
|
||||
docs_dir = base_path / project_dir / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Create project directories using FileService with relative path
|
||||
docs_dir = (
|
||||
f"{destination_folder}/{project_dir}/docs"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs"
|
||||
)
|
||||
await self.file_service.ensure_directory(docs_dir)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := self._format_prompt_markdown(project):
|
||||
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(prompt_entity, file_path)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = self._format_project_markdown(project, doc)
|
||||
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
entity = self._format_project_markdown(project, doc, destination_folder)
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
docs_imported += 1
|
||||
|
||||
@@ -68,16 +86,17 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import Claude projects")
|
||||
return self.handle_error("Failed to import Claude projects", e) # pyright: ignore [reportReturnType]
|
||||
return self.handle_error("Failed to import Claude projects", e)
|
||||
|
||||
def _format_project_markdown(
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any]
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
|
||||
) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
doc: Document data.
|
||||
destination_folder: Optional destination folder prefix.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the document.
|
||||
@@ -90,6 +109,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/docs/{doc_file}"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs/{doc_file}"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
@@ -98,7 +124,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"title": doc["filename"],
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/docs/{doc_file}",
|
||||
"permalink": permalink,
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
"doc_uuid": doc["uuid"],
|
||||
@@ -109,11 +135,14 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
|
||||
return entity
|
||||
|
||||
def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMarkdown]:
|
||||
def _format_prompt_markdown(
|
||||
self, project: Dict[str, Any], destination_folder: str = ""
|
||||
) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
destination_folder: Optional destination folder prefix.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the prompt template, or None if
|
||||
@@ -129,6 +158,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/prompt-template"
|
||||
if destination_folder
|
||||
else f"{project_dir}/prompt-template"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
@@ -137,7 +173,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"title": f"Prompt Template: {project['name']}",
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/prompt-template",
|
||||
"permalink": permalink,
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Memory JSON import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import EntityImportResult
|
||||
@@ -14,6 +13,20 @@ logger = logging.getLogger(__name__)
|
||||
class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
"""Service for importing memory.json format data."""
|
||||
|
||||
def handle_error(
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> EntityImportResult:
|
||||
"""Return a failed EntityImportResult with an error message."""
|
||||
error_msg = f"{message}: {error}" if error else message
|
||||
return EntityImportResult(
|
||||
import_count={},
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
entities=0,
|
||||
relations=0,
|
||||
skipped_entities=0,
|
||||
)
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str = "", **kwargs: Any
|
||||
) -> EntityImportResult:
|
||||
@@ -27,17 +40,15 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
Returns:
|
||||
EntityImportResult containing statistics and status of the import.
|
||||
"""
|
||||
config = get_project_config()
|
||||
try:
|
||||
# First pass - collect all relations by source entity
|
||||
entity_relations: Dict[str, List[Relation]] = {}
|
||||
entities: Dict[str, Dict[str, Any]] = {}
|
||||
skipped_entities: int = 0
|
||||
|
||||
# Ensure the base path exists
|
||||
base_path = config.home # pragma: no cover
|
||||
# Ensure the destination folder exists if provided
|
||||
if destination_folder: # pragma: no cover
|
||||
base_path = self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
# First pass - collect entities and relations
|
||||
for line in source_data:
|
||||
@@ -46,9 +57,9 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
# Handle different possible name keys
|
||||
entity_name = data.get("name") or data.get("entityName") or data.get("id")
|
||||
if not entity_name:
|
||||
logger.warning(f"Entity missing name field: {data}")
|
||||
skipped_entities += 1
|
||||
continue
|
||||
logger.warning(f"Entity missing name field: {data}") # pragma: no cover
|
||||
skipped_entities += 1 # pragma: no cover
|
||||
continue # pragma: no cover
|
||||
entities[entity_name] = data
|
||||
elif data["type"] == "relation":
|
||||
# Store relation with its source entity
|
||||
@@ -68,9 +79,18 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
# Get entity type with fallback
|
||||
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
||||
|
||||
# Ensure entity type directory exists
|
||||
entity_type_dir = base_path / entity_type
|
||||
entity_type_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{entity_type}/{name}"
|
||||
if destination_folder
|
||||
else f"{entity_type}/{name}"
|
||||
)
|
||||
|
||||
# Ensure entity type directory exists using FileService with relative path
|
||||
entity_type_dir = (
|
||||
f"{destination_folder}/{entity_type}" if destination_folder else entity_type
|
||||
)
|
||||
await self.file_service.ensure_directory(entity_type_dir)
|
||||
|
||||
# Get observations with fallback to empty list
|
||||
observations = entity_data.get("observations", [])
|
||||
@@ -80,7 +100,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
metadata={
|
||||
"type": entity_type,
|
||||
"title": name,
|
||||
"permalink": f"{entity_type}/{name}",
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=f"# {name}\n",
|
||||
@@ -88,8 +108,8 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
relations=entity_relations.get(name, []),
|
||||
)
|
||||
|
||||
# Write entity file
|
||||
file_path = base_path / f"{entity_type}/{name}.md"
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
entities_created += 1
|
||||
|
||||
@@ -105,4 +125,4 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import memory.json")
|
||||
return self.handle_error("Failed to import memory.json", e) # pyright: ignore [reportReturnType]
|
||||
return self.handle_error("Failed to import memory.json", e)
|
||||
|
||||
@@ -5,15 +5,18 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def clean_filename(name: str) -> str: # pragma: no cover
|
||||
def clean_filename(name: str | None) -> str: # pragma: no cover
|
||||
"""Clean a string to be used as a filename.
|
||||
|
||||
Args:
|
||||
name: The string to clean.
|
||||
name: The string to clean (can be None).
|
||||
|
||||
Returns:
|
||||
A cleaned string suitable for use as a filename.
|
||||
"""
|
||||
# Handle None or empty input
|
||||
if not name:
|
||||
return "untitled"
|
||||
# Replace common punctuation and whitespace with underscores
|
||||
name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
|
||||
# Remove any non-alphanumeric or underscore characters
|
||||
|
||||
@@ -227,6 +227,12 @@ class EntityParser:
|
||||
Returns:
|
||||
EntityMarkdown with parsed content
|
||||
"""
|
||||
# Strip BOM before parsing (can be present in files from Windows or certain sources)
|
||||
# See issue #452
|
||||
from basic_memory.file_utils import strip_bom
|
||||
|
||||
content = strip_bom(content)
|
||||
|
||||
# Parse frontmatter with proper error handling for malformed YAML
|
||||
try:
|
||||
post = frontmatter.loads(content)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from collections import OrderedDict
|
||||
|
||||
from frontmatter import Post
|
||||
@@ -11,6 +11,9 @@ from basic_memory.file_utils import dump_frontmatter
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class DirtyFileError(Exception):
|
||||
"""Raised when attempting to write to a file that has been modified."""
|
||||
@@ -36,9 +39,14 @@ class MarkdownProcessor:
|
||||
3. Track schema changes (that's done by the database)
|
||||
"""
|
||||
|
||||
def __init__(self, entity_parser: EntityParser):
|
||||
"""Initialize processor with base path and parser."""
|
||||
def __init__(
|
||||
self,
|
||||
entity_parser: EntityParser,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
):
|
||||
"""Initialize processor with parser and optional config."""
|
||||
self.entity_parser = entity_parser
|
||||
self.app_config = app_config
|
||||
|
||||
async def read_file(self, path: Path) -> EntityMarkdown:
|
||||
"""Read and parse file into EntityMarkdown schema.
|
||||
@@ -123,7 +131,61 @@ class MarkdownProcessor:
|
||||
# Write atomically and return checksum of updated file
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await file_utils.write_file_atomic(path, final_content)
|
||||
return await file_utils.compute_checksum(final_content)
|
||||
|
||||
# Format file if configured (MarkdownProcessor always handles markdown files)
|
||||
content_for_checksum = final_content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file( # pragma: no cover
|
||||
path, self.app_config, is_markdown=True
|
||||
)
|
||||
if formatted_content is not None: # pragma: no cover
|
||||
content_for_checksum = formatted_content # pragma: no cover
|
||||
|
||||
return await file_utils.compute_checksum(content_for_checksum)
|
||||
|
||||
def to_markdown_string(self, markdown: EntityMarkdown) -> str:
|
||||
"""Convert EntityMarkdown to markdown string with frontmatter.
|
||||
|
||||
This method handles serialization only - it does not write to files.
|
||||
Use FileService.write_file() to persist the output.
|
||||
|
||||
This enables cloud environments to override file operations via
|
||||
dependency injection while reusing the serialization logic.
|
||||
|
||||
Args:
|
||||
markdown: EntityMarkdown schema to serialize
|
||||
|
||||
Returns:
|
||||
Complete markdown string with frontmatter, content, and structured sections
|
||||
"""
|
||||
# Convert frontmatter to dict
|
||||
frontmatter_dict = OrderedDict()
|
||||
frontmatter_dict["title"] = markdown.frontmatter.title
|
||||
frontmatter_dict["type"] = markdown.frontmatter.type
|
||||
frontmatter_dict["permalink"] = markdown.frontmatter.permalink
|
||||
|
||||
metadata = markdown.frontmatter.metadata or {}
|
||||
for k, v in metadata.items():
|
||||
frontmatter_dict[k] = v
|
||||
|
||||
# Start with user content (or minimal title for new files)
|
||||
content = markdown.content or f"# {markdown.frontmatter.title}\n"
|
||||
|
||||
# Add structured sections with proper spacing
|
||||
content = content.rstrip() # Remove trailing whitespace
|
||||
|
||||
# Add a blank line if we have semantic content
|
||||
if markdown.observations or markdown.relations:
|
||||
content += "\n"
|
||||
|
||||
if markdown.observations:
|
||||
content += self.format_observations(markdown.observations)
|
||||
if markdown.relations:
|
||||
content += self.format_relations(markdown.relations)
|
||||
|
||||
# Create Post object for frontmatter
|
||||
post = Post(content, **frontmatter_dict)
|
||||
return dump_frontmatter(post)
|
||||
|
||||
def format_observations(self, observations: list[Observation]) -> str:
|
||||
"""Format observations section in standard way.
|
||||
|
||||
@@ -95,6 +95,7 @@ async def get_client() -> AsyncIterator[AsyncClient]:
|
||||
yield client
|
||||
else:
|
||||
# Local mode: ASGI transport for in-process calls
|
||||
# Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
|
||||
@@ -14,16 +14,17 @@ from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
|
||||
async def resolve_project_parameter(
|
||||
project: Optional[str] = None, allow_discovery: bool = False
|
||||
) -> Optional[str]:
|
||||
"""Resolve project parameter using three-tier hierarchy.
|
||||
|
||||
if config.cloud_mode:
|
||||
project is required
|
||||
project is required (unless allow_discovery=True for tools that support discovery mode)
|
||||
else:
|
||||
Resolution order:
|
||||
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
|
||||
@@ -32,17 +33,22 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None in cloud mode for discovery mode
|
||||
(used by tools like recent_activity that can operate across all projects)
|
||||
|
||||
Returns:
|
||||
Resolved project name or None if no resolution possible
|
||||
"""
|
||||
|
||||
config = ConfigManager().config
|
||||
# if cloud_mode, project is required
|
||||
# if cloud_mode, project is required (unless discovery mode is allowed)
|
||||
if config.cloud_mode:
|
||||
if project:
|
||||
logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
|
||||
return project
|
||||
elif allow_discovery:
|
||||
logger.debug("cloud_mode: discovery mode allowed, returning None")
|
||||
return None
|
||||
else:
|
||||
raise ValueError("No project specified. Project is required for cloud mode.")
|
||||
|
||||
@@ -67,6 +73,9 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
|
||||
|
||||
|
||||
async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
|
||||
# Deferred import to avoid circular dependency with tools
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
response = await call_get(client, "/projects/projects", headers=headers)
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
return [project.name for project in project_list.projects]
|
||||
@@ -92,6 +101,9 @@ async def get_active_project(
|
||||
ValueError: If no project can be resolved
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
"""
|
||||
# Deferred import to avoid circular dependency with tools
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
resolved_project = await resolve_project_parameter(project)
|
||||
if not resolved_project:
|
||||
project_names = await get_project_names(client, headers)
|
||||
|
||||
@@ -32,7 +32,7 @@ def ai_assistant_guide() -> str:
|
||||
|
||||
# Add mode-specific header
|
||||
mode_info = ""
|
||||
if config.default_project_mode:
|
||||
if config.default_project_mode: # pragma: no cover
|
||||
mode_info = f"""
|
||||
# 🎯 Default Project Mode Active
|
||||
|
||||
@@ -46,7 +46,7 @@ def ai_assistant_guide() -> str:
|
||||
────────────────────────────────────────
|
||||
|
||||
"""
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
mode_info = """
|
||||
# 🔧 Multi-Project Mode Active
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async def recent_activity_prompt(
|
||||
primary_results.append(item.primary_result)
|
||||
# Add up to 1 related result per primary item
|
||||
if item.related_results:
|
||||
related_results.extend(item.related_results[:1])
|
||||
related_results.extend(item.related_results[:1]) # pragma: no cover
|
||||
|
||||
# Limit total results for readability
|
||||
primary_results = primary_results[:8]
|
||||
@@ -78,7 +78,7 @@ async def recent_activity_prompt(
|
||||
primary_results.append(item.primary_result)
|
||||
# Add up to 2 related results per primary item
|
||||
if item.related_results:
|
||||
related_results.extend(item.related_results[:2])
|
||||
related_results.extend(item.related_results[:2]) # pragma: no cover
|
||||
|
||||
# Set topic based on mode
|
||||
if project:
|
||||
|
||||
@@ -123,9 +123,9 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
|
||||
# Add content snippet
|
||||
if hasattr(primary, "content") and primary.content: # pyright: ignore
|
||||
content = primary.content or "" # pyright: ignore
|
||||
if content:
|
||||
section += f"\n**Excerpt**:\n{content}\n"
|
||||
content = primary.content or "" # pyright: ignore # pragma: no cover
|
||||
if content: # pragma: no cover
|
||||
section += f"\n**Excerpt**:\n{content}\n" # pragma: no cover
|
||||
|
||||
section += dedent(f"""
|
||||
|
||||
|
||||
@@ -2,8 +2,80 @@
|
||||
Basic Memory FastMCP server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app, initialize_file_sync
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastMCP):
|
||||
"""Lifecycle manager for the MCP server.
|
||||
|
||||
Handles:
|
||||
- Database initialization and migrations
|
||||
- Telemetry notice and tracking
|
||||
- File sync in background (if enabled and not in cloud mode)
|
||||
- Proper cleanup on shutdown
|
||||
"""
|
||||
app_config = ConfigManager().config
|
||||
logger.info("Starting Basic Memory MCP server")
|
||||
|
||||
# Show telemetry notice (first run only) and track startup
|
||||
show_notice_if_needed()
|
||||
track_app_started("mcp")
|
||||
|
||||
# Track if we created the engine (vs test fixtures providing it)
|
||||
# This prevents disposing an engine provided by test fixtures when
|
||||
# multiple Client connections are made in the same test
|
||||
engine_was_none = db._engine is None
|
||||
|
||||
# Initialize app (runs migrations, reconciles projects)
|
||||
await initialize_app(app_config)
|
||||
|
||||
# Start file sync as background task (if enabled and not in cloud mode)
|
||||
sync_task = None
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected - skipping local file sync")
|
||||
elif app_config.sync_changes and not app_config.cloud_mode_enabled: # pragma: no cover
|
||||
logger.info("Starting file sync in background")
|
||||
|
||||
async def _file_sync_runner() -> None:
|
||||
await initialize_file_sync(app_config)
|
||||
|
||||
sync_task = asyncio.create_task(_file_sync_runner())
|
||||
elif app_config.cloud_mode_enabled: # pragma: no cover
|
||||
logger.info("Cloud mode enabled - skipping local file sync")
|
||||
else: # pragma: no cover
|
||||
logger.info("Sync changes disabled - skipping file sync")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down Basic Memory MCP server")
|
||||
if sync_task: # pragma: no cover
|
||||
sync_task.cancel()
|
||||
try:
|
||||
await sync_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("File sync task cancelled")
|
||||
|
||||
# Only shutdown DB if we created it (not if test fixture provided it)
|
||||
if engine_was_none:
|
||||
await db.shutdown_db()
|
||||
logger.info("Database connections closed")
|
||||
else: # pragma: no cover
|
||||
logger.debug("Skipping DB shutdown - engine provided externally")
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
name="Basic Memory",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -87,6 +88,7 @@ async def build_context(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
"""
|
||||
track_mcp_tool("build_context")
|
||||
logger.info(f"Building context from {url} in project {project}")
|
||||
|
||||
# Convert string depth to integer if needed
|
||||
@@ -104,11 +106,9 @@ async def build_context(
|
||||
# Get the active project using the new stateless approach
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_url}/memory/{memory_url_path(url)}",
|
||||
f"/v2/projects/{active_project.external_id}/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
|
||||
@@ -12,7 +12,8 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -94,9 +95,9 @@ async def canvas(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or folder path is invalid
|
||||
"""
|
||||
track_mcp_tool("canvas")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
@@ -108,23 +109,44 @@ async def canvas(
|
||||
# Convert to JSON
|
||||
canvas_json = json.dumps(canvas_data, indent=2)
|
||||
|
||||
# Write the file using the resource API
|
||||
# Try to create the canvas file first (optimistic create)
|
||||
logger.info(f"Creating canvas file: {file_path} in project {project}")
|
||||
# Send canvas_json as content string, not as json parameter
|
||||
# The resource endpoint expects Body() string content, not JSON-encoded data
|
||||
response = await call_put(
|
||||
client,
|
||||
f"{project_url}/resource/{file_path}",
|
||||
content=canvas_json,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
try:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource",
|
||||
json={"file_path": file_path, "content": canvas_json},
|
||||
)
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
logger.info(f"Canvas file exists, updating instead: {file_path}")
|
||||
try:
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, file_path)
|
||||
# For update, send content in JSON body
|
||||
response = await call_put(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
||||
json={"content": canvas_json},
|
||||
)
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error # pragma: no cover
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise # pragma: no cover
|
||||
|
||||
# Parse response
|
||||
result = response.json()
|
||||
logger.debug(result)
|
||||
|
||||
# Build summary
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
|
||||
|
||||
return "\n".join(summary)
|
||||
|
||||
@@ -15,6 +15,7 @@ from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
|
||||
@@ -55,7 +56,7 @@ def _format_document_for_chatgpt(
|
||||
title = "Untitled Document"
|
||||
|
||||
# Handle error cases
|
||||
if isinstance(content, str) and content.startswith("# Note Not Found"):
|
||||
if isinstance(content, str) and content.lstrip().startswith("# Note Not Found"):
|
||||
return {
|
||||
"id": identifier,
|
||||
"title": title or "Document Not Found",
|
||||
@@ -88,6 +89,7 @@ async def search(
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body contains `results`, `total_count`, and echo of `query`.
|
||||
"""
|
||||
track_mcp_tool("search")
|
||||
logger.info(f"ChatGPT search request: query='{query}'")
|
||||
|
||||
try:
|
||||
@@ -151,6 +153,7 @@ async def fetch(
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
|
||||
"""
|
||||
track_mcp_tool("fetch")
|
||||
logger.info(f"ChatGPT fetch request: id='{id}'")
|
||||
|
||||
try:
|
||||
|
||||
@@ -3,11 +3,13 @@ from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.tools.utils import call_delete, resolve_entity_id
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
|
||||
|
||||
@@ -202,12 +204,31 @@ async def delete_note(
|
||||
with suggestions for finding the correct identifier, including search
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
track_mcp_tool("delete_note")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
try:
|
||||
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
except ToolError as e:
|
||||
# If entity not found, return False (note doesn't exist)
|
||||
if "Entity not found" in str(e) or "not found" in str(e).lower():
|
||||
logger.warning(f"Note not found for deletion: {identifier}")
|
||||
return False
|
||||
# For other resolution errors, return formatted error message
|
||||
logger.error( # pragma: no cover
|
||||
f"Delete failed for '{identifier}': {e}, project: {active_project.name}"
|
||||
)
|
||||
return _format_delete_error_response( # pragma: no cover
|
||||
active_project.name, str(e), identifier
|
||||
)
|
||||
|
||||
try:
|
||||
# Call the DELETE endpoint
|
||||
response = await call_delete(
|
||||
client, f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
)
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
if result.deleted:
|
||||
@@ -216,8 +237,10 @@ async def delete_note(
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
|
||||
return False
|
||||
logger.warning( # pragma: no cover
|
||||
f"Delete operation completed but note was not deleted: {identifier}"
|
||||
)
|
||||
return False # pragma: no cover
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
|
||||
|
||||
@@ -8,7 +8,8 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_patch
|
||||
from basic_memory.mcp.tools.utils import call_patch, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@@ -214,9 +215,9 @@ async def edit_note(
|
||||
search_notes() first to find the correct identifier. The tool provides detailed
|
||||
error messages with suggestions if operations fail.
|
||||
"""
|
||||
track_mcp_tool("edit_note")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
||||
|
||||
@@ -235,6 +236,9 @@ async def edit_note(
|
||||
|
||||
# Use the PATCH endpoint to edit the entity
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
|
||||
# Prepare the edit request data
|
||||
edit_data = {
|
||||
"operation": operation,
|
||||
@@ -250,7 +254,7 @@ async def edit_note(
|
||||
edit_data["expected_replacements"] = str(expected_replacements)
|
||||
|
||||
# Call the PATCH endpoint
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_patch(client, url, json=edit_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -63,9 +64,9 @@ async def list_directory(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
track_mcp_tool("list_directory")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Prepare query parameters
|
||||
params = {
|
||||
@@ -82,7 +83,7 @@ async def list_directory(
|
||||
# Call the API endpoint
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_url}/directory/list",
|
||||
f"/v2/projects/{active_project.external_id}/directory/list",
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
from basic_memory.mcp.tools.utils import call_get, call_put, resolve_entity_id
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -104,11 +105,12 @@ def _format_potential_cross_project_guidance(
|
||||
identifier: str, destination_path: str, current_project: str, available_projects: list[str]
|
||||
) -> str:
|
||||
"""Format guidance for potentially cross-project moves."""
|
||||
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
|
||||
if len(available_projects) > 3:
|
||||
other_projects += f" (and {len(available_projects) - 3} others)"
|
||||
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects # pragma: no cover
|
||||
if len(available_projects) > 3: # pragma: no cover
|
||||
other_projects += f" (and {len(available_projects) - 3} others)" # pragma: no cover
|
||||
|
||||
return dedent(f"""
|
||||
return ( # pragma: no cover
|
||||
dedent(f"""
|
||||
# Move Failed - Check Project Context
|
||||
|
||||
Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
|
||||
@@ -139,6 +141,7 @@ def _format_potential_cross_project_guidance(
|
||||
list_memory_projects()
|
||||
```
|
||||
""").strip()
|
||||
)
|
||||
|
||||
|
||||
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
|
||||
@@ -302,9 +305,10 @@ delete_note("{identifier}")
|
||||
```"""
|
||||
|
||||
# Generic fallback
|
||||
return f"""# Move Failed
|
||||
return ( # pragma: no cover
|
||||
f"""# Move Failed
|
||||
|
||||
Error moving '{identifier}' to '{destination_path}': {error_message}
|
||||
Error moving '{identifier}' to '{destination_path}': {error_message} # pragma: no cover
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
|
||||
@@ -335,6 +339,7 @@ write_note("Title", content, "target-folder")
|
||||
# Delete original once confirmed
|
||||
delete_note("{identifier}")
|
||||
```"""
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -395,11 +400,11 @@ async def move_note(
|
||||
- Re-indexes the entity for search
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
track_mcp_tool("move_note")
|
||||
async with get_client() as client:
|
||||
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
||||
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Validate destination path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
@@ -434,8 +439,10 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
# Get the source entity information for extension validation
|
||||
source_ext = "md" # Default to .md if we can't determine source extension
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
# Fetch source entity information to get the current file extension
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
if "." in source_entity.file_path:
|
||||
@@ -467,8 +474,10 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
|
||||
# Get the source entity to check its file extension
|
||||
try:
|
||||
# Resolve identifier to entity ID (might already be cached from above)
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
# Fetch source entity information
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
|
||||
@@ -505,16 +514,17 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
||||
|
||||
try:
|
||||
# Prepare move request
|
||||
# Resolve identifier to entity ID for the move operation
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
|
||||
# Prepare move request (v2 API only needs destination_path)
|
||||
move_data = {
|
||||
"identifier": identifier,
|
||||
"destination_path": destination_path,
|
||||
"project": active_project.name,
|
||||
}
|
||||
|
||||
# Call the move API endpoint
|
||||
url = f"{project_url}/knowledge/move"
|
||||
response = await call_post(client, url, json=move_data)
|
||||
# Call the v2 move API endpoint (PUT method, entity_id in URL)
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}/move"
|
||||
response = await call_put(client, url, json=move_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Build success message
|
||||
|
||||
@@ -15,6 +15,7 @@ from basic_memory.schemas.project_info import (
|
||||
ProjectStatusResponse,
|
||||
ProjectInfoRequest,
|
||||
)
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -40,6 +41,7 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
||||
Example:
|
||||
list_memory_projects()
|
||||
"""
|
||||
track_mcp_tool("list_memory_projects")
|
||||
async with get_client() as client:
|
||||
if context: # pragma: no cover
|
||||
await context.info("Listing all available projects")
|
||||
@@ -92,6 +94,7 @@ async def create_memory_project(
|
||||
create_memory_project("my-research", "~/Documents/research")
|
||||
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
||||
"""
|
||||
track_mcp_tool("create_memory_project")
|
||||
async with get_client() as client:
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
@@ -147,6 +150,7 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
This action cannot be undone. The project will need to be re-added
|
||||
to access its content through Basic Memory again.
|
||||
"""
|
||||
track_mcp_tool("delete_project")
|
||||
async with get_client() as client:
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
@@ -160,7 +164,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
|
||||
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
|
||||
# Find the project by permalink (derived from name).
|
||||
# Note: The API response uses `ProjectItem` which derives `permalink` from `name`,
|
||||
# so a separate case-insensitive name match would be redundant here.
|
||||
project_permalink = generate_permalink(project_name)
|
||||
target_project = None
|
||||
for p in project_list.projects:
|
||||
@@ -168,10 +174,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
if p.permalink == project_permalink:
|
||||
target_project = p
|
||||
break
|
||||
# Also match by name comparison (case-insensitive)
|
||||
if p.name.lower() == project_name.lower():
|
||||
target_project = p
|
||||
break
|
||||
|
||||
if not target_project:
|
||||
available_projects = [p.name for p in project_list.projects]
|
||||
@@ -179,11 +181,8 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
||||
)
|
||||
|
||||
# Call API to delete project using URL encoding for special characters
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_name = quote(target_project.name, safe="")
|
||||
response = await call_delete(client, f"/projects/{encoded_name}")
|
||||
# Call v2 API to delete project using project external_id
|
||||
response = await call_delete(client, f"/v2/projects/{target_project.external_id}")
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
@@ -13,12 +13,14 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from PIL import Image as PILImage
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -199,11 +201,11 @@ async def read_content(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If path attempts path traversal
|
||||
"""
|
||||
track_mcp_tool("read_content")
|
||||
logger.info("Reading file", path=path, project=project)
|
||||
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
url = memory_url_path(path)
|
||||
|
||||
@@ -221,7 +223,15 @@ async def read_content(
|
||||
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
||||
}
|
||||
|
||||
response = await call_get(client, f"{project_url}/resource/{url}")
|
||||
# Resolve path to entity ID
|
||||
try:
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, url)
|
||||
except ToolError:
|
||||
# Convert resolution errors to "Resource not found" for consistency
|
||||
raise ToolError(f"Resource not found: {url}")
|
||||
|
||||
# Call the v2 resource endpoint
|
||||
response = await call_get(client, f"/v2/projects/{active_project.external_id}/resource/{entity_id}")
|
||||
content_type = response.headers.get("content-type", "application/octet-stream")
|
||||
content_length = int(response.headers.get("content-length", 0))
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
@@ -77,6 +78,7 @@ async def read_note(
|
||||
If the exact note isn't found, this tool provides helpful suggestions
|
||||
including related notes, search commands, and note creation templates.
|
||||
"""
|
||||
track_mcp_tool("read_note")
|
||||
async with get_client() as client:
|
||||
# Get and validate the project
|
||||
active_project = await get_active_project(client, project, context)
|
||||
@@ -97,23 +99,29 @@ async def read_note(
|
||||
)
|
||||
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Get the file via REST API - first try direct permalink lookup
|
||||
# Get the file via REST API - first try direct identifier resolution
|
||||
entity_path = memory_url_path(identifier)
|
||||
path = f"{project_url}/resource/{entity_path}"
|
||||
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
|
||||
logger.info(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Try direct lookup first
|
||||
response = await call_get(client, path, params={"page": page, "page_size": page_size})
|
||||
# Try to resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, entity_path)
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{path}': {e}")
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
# Continue to fallback methods
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
@@ -127,10 +135,14 @@ async def read_note(
|
||||
result = title_results.results[0] # Get the first/best match
|
||||
if result.permalink:
|
||||
try:
|
||||
# Try to fetch the content using the found permalink
|
||||
path = f"{project_url}/resource/{result.permalink}"
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, result.permalink)
|
||||
|
||||
# Fetch content using the entity ID
|
||||
response = await call_get(
|
||||
client, path, params={"page": page, "page_size": page_size}
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Recent activity tool for Basic Memory MCP server."""
|
||||
|
||||
from datetime import timezone
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from loguru import logger
|
||||
@@ -9,6 +10,7 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -98,6 +100,7 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
track_mcp_tool("recent_activity")
|
||||
async with get_client() as client:
|
||||
# Build common parameters for API calls
|
||||
params = {
|
||||
@@ -133,7 +136,8 @@ async def recent_activity(
|
||||
params["type"] = [t.value for t in validated_types] # pyright: ignore
|
||||
|
||||
# Resolve project parameter using the three-tier hierarchy
|
||||
resolved_project = await resolve_project_parameter(project)
|
||||
# allow_discovery=True enables Discovery Mode, so a project is not required
|
||||
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
|
||||
|
||||
if resolved_project is None:
|
||||
# Discovery Mode: Get activity across all projects
|
||||
@@ -193,33 +197,7 @@ async def recent_activity(
|
||||
# Generate guidance for the assistant
|
||||
guidance_lines = ["\n" + "─" * 40]
|
||||
|
||||
if most_active_project and most_active_count > 0:
|
||||
guidance_lines.extend(
|
||||
[
|
||||
f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
|
||||
f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
|
||||
]
|
||||
)
|
||||
elif active_projects > 0:
|
||||
# Has activity but no clear most active project
|
||||
active_project_names = [
|
||||
name for name, activity in projects_activity.items() if activity.item_count > 0
|
||||
]
|
||||
if len(active_project_names) == 1:
|
||||
guidance_lines.extend(
|
||||
[
|
||||
f"Suggested project: '{active_project_names[0]}' (only active project)",
|
||||
f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
|
||||
]
|
||||
)
|
||||
else:
|
||||
guidance_lines.extend(
|
||||
[
|
||||
f"Multiple active projects found: {', '.join(active_project_names)}",
|
||||
"Ask user: 'Which project should I use for this task?'",
|
||||
]
|
||||
)
|
||||
else:
|
||||
if active_projects == 0:
|
||||
# No recent activity
|
||||
guidance_lines.extend(
|
||||
[
|
||||
@@ -227,6 +205,23 @@ async def recent_activity(
|
||||
"Consider: Ask which project to use or if they want to create a new one.",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# At least one project has activity: suggest the most active project.
|
||||
suggested_project = most_active_project or next(
|
||||
(name for name, activity in projects_activity.items() if activity.item_count > 0),
|
||||
None,
|
||||
)
|
||||
if suggested_project:
|
||||
suffix = (
|
||||
f"(most active with {most_active_count} items)" if most_active_count > 0 else ""
|
||||
)
|
||||
guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip())
|
||||
if active_projects == 1:
|
||||
guidance_lines.append(f"Ask user: 'Should I use {suggested_project} for this task?'")
|
||||
else:
|
||||
guidance_lines.append(
|
||||
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
|
||||
)
|
||||
|
||||
guidance_lines.extend(
|
||||
[
|
||||
@@ -247,11 +242,10 @@ async def recent_activity(
|
||||
)
|
||||
|
||||
active_project = await get_active_project(client, resolved_project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_url}/memory/recent",
|
||||
f"/v2/projects/{active_project.external_id}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
activity_data = GraphContext.model_validate(response.json())
|
||||
@@ -274,10 +268,9 @@ async def _get_project_activity(
|
||||
Returns:
|
||||
ProjectActivity with activity data or empty activity on error
|
||||
"""
|
||||
project_url = f"/{project_info.permalink}"
|
||||
activity_response = await call_get(
|
||||
client,
|
||||
f"{project_url}/memory/recent",
|
||||
f"/v2/projects/{project_info.external_id}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
activity = GraphContext.model_validate(activity_response.json())
|
||||
@@ -289,12 +282,13 @@ async def _get_project_activity(
|
||||
for result in activity.results:
|
||||
if result.primary_result.created_at:
|
||||
current_time = result.primary_result.created_at
|
||||
try:
|
||||
if last_activity is None or current_time > last_activity:
|
||||
last_activity = current_time
|
||||
except TypeError:
|
||||
# Handle timezone comparison issues by skipping this comparison
|
||||
if last_activity is None:
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
if last_activity is None:
|
||||
last_activity = current_time
|
||||
else:
|
||||
if current_time > last_activity:
|
||||
last_activity = current_time
|
||||
|
||||
# Extract folder from file_path
|
||||
|
||||
@@ -10,6 +10,7 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
@@ -205,8 +206,8 @@ async def search_notes(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
types: List[str] = [],
|
||||
entity_types: List[str] = [],
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
@@ -330,6 +331,11 @@ async def search_notes(
|
||||
# Explicit project specification
|
||||
results = await search_notes("project planning", project="my-project")
|
||||
"""
|
||||
track_mcp_tool("search_notes")
|
||||
# Avoid mutable-default-argument footguns. Treat None as "no filter".
|
||||
types = types or []
|
||||
entity_types = entity_types or []
|
||||
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
@@ -355,14 +361,13 @@ async def search_notes(
|
||||
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
project_url = active_project.project_url
|
||||
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
|
||||
try:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"{project_url}/search/",
|
||||
f"/v2/projects/{active_project.external_id}/search/",
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
@@ -435,6 +435,34 @@ async def call_post(
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
async def resolve_entity_id(client: AsyncClient, project_external_id: str, identifier: str) -> str:
|
||||
"""Resolve a string identifier to an entity external_id using the v2 API.
|
||||
|
||||
Args:
|
||||
client: HTTP client for API calls
|
||||
project_external_id: Project external ID (UUID)
|
||||
identifier: The identifier to resolve (permalink, title, or path)
|
||||
|
||||
Returns:
|
||||
The resolved entity external_id (UUID)
|
||||
|
||||
Raises:
|
||||
ToolError: If the identifier cannot be resolved
|
||||
"""
|
||||
try:
|
||||
response = await call_post(
|
||||
client, f"/v2/projects/{project_external_id}/knowledge/resolve", json={"identifier": identifier}
|
||||
)
|
||||
data = response.json()
|
||||
return data["external_id"]
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404: # pragma: no cover
|
||||
raise ToolError(f"Entity not found: '{identifier}'") # pragma: no cover
|
||||
raise ToolError(f"Error resolving identifier '{identifier}': {e}") # pragma: no cover
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}") # pragma: no cover
|
||||
|
||||
|
||||
async def call_delete(
|
||||
client: AsyncClient,
|
||||
url: URL | str,
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -54,7 +55,7 @@ async def view_note(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If identifier attempts path traversal
|
||||
"""
|
||||
|
||||
track_mcp_tool("view_note")
|
||||
logger.info(f"Viewing note: {identifier} in project: {project}")
|
||||
|
||||
# Call the existing read_note logic
|
||||
|
||||
@@ -7,7 +7,8 @@ from loguru import logger
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -116,6 +117,7 @@ async def write_note(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If folder path attempts path traversal
|
||||
"""
|
||||
track_mcp_tool("write_note")
|
||||
async with get_client() as client:
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
|
||||
@@ -150,16 +152,37 @@ async def write_note(
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
project_url = active_project.permalink
|
||||
|
||||
# Create or update via knowledge API
|
||||
logger.debug(f"Creating entity via API permalink={entity.permalink}")
|
||||
url = f"{project_url}/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
# Try to create the entity first (optimistic create)
|
||||
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
||||
action = "Created" # Default to created
|
||||
try:
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities"
|
||||
response = await call_post(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
|
||||
try:
|
||||
if not entity.permalink:
|
||||
raise ValueError("Entity permalink is required for updates") # pragma: no cover
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, entity.permalink)
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
raise e from update_error # pragma: no cover
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise # pragma: no cover
|
||||
summary = [
|
||||
f"# {action} note",
|
||||
f"project: {active_project.name}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Knowledge graph models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from basic_memory.utils import ensure_timezone_aware
|
||||
from typing import Optional
|
||||
@@ -38,6 +39,7 @@ class Entity(Base):
|
||||
# Regular indexes
|
||||
Index("ix_entity_type", "entity_type"),
|
||||
Index("ix_entity_title", "title"),
|
||||
Index("ix_entity_external_id", "external_id", unique=True),
|
||||
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
||||
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
|
||||
Index("ix_entity_project_id", "project_id"), # For project filtering
|
||||
@@ -59,6 +61,10 @@ class Entity(Base):
|
||||
|
||||
# Core identity
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# External UUID for API references - stable identifier that won't change
|
||||
external_id: Mapped[str] = mapped_column(
|
||||
String, unique=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
entity_type: Mapped[str] = mapped_column(String)
|
||||
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
@@ -129,7 +135,7 @@ class Entity(Base):
|
||||
return value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
||||
return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
||||
|
||||
|
||||
class Observation(Base):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Project model for Basic Memory."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional
|
||||
|
||||
@@ -32,6 +33,7 @@ class Project(Base):
|
||||
# Regular indexes
|
||||
Index("ix_project_name", "name", unique=True),
|
||||
Index("ix_project_permalink", "permalink", unique=True),
|
||||
Index("ix_project_external_id", "external_id", unique=True),
|
||||
Index("ix_project_path", "path"),
|
||||
Index("ix_project_created_at", "created_at"),
|
||||
Index("ix_project_updated_at", "updated_at"),
|
||||
@@ -39,6 +41,10 @@ class Project(Base):
|
||||
|
||||
# Core identity
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# External UUID for API references - stable identifier that won't change
|
||||
external_id: Mapped[str] = mapped_column(
|
||||
String, unique=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String, unique=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -71,7 +77,7 @@ class Project(Base):
|
||||
entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"Project(id={self.id}, name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
||||
return f"Project(id={self.id}, external_id='{self.external_id}', name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
||||
|
||||
|
||||
@event.listens_for(Project, "before_insert")
|
||||
|
||||
@@ -48,6 +48,15 @@ CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
|
||||
CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
|
||||
""")
|
||||
|
||||
# Partial unique index on (permalink, project_id) for non-null permalinks
|
||||
# This prevents duplicate permalinks per project and is used by upsert operations
|
||||
# in PostgresSearchRepository to handle race conditions during parallel indexing
|
||||
CREATE_POSTGRES_SEARCH_INDEX_PERMALINK = DDL("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uix_search_index_permalink_project
|
||||
ON search_index (permalink, project_id)
|
||||
WHERE permalink IS NOT NULL
|
||||
""")
|
||||
|
||||
# Define FTS5 virtual table creation for SQLite only
|
||||
# This DDL is executed separately for SQLite databases
|
||||
CREATE_SEARCH_INDEX = DDL("""
|
||||
|
||||
@@ -45,6 +45,22 @@ class EntityRepository(Repository[Entity]):
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, entity_id)
|
||||
|
||||
async def get_by_external_id(self, external_id: str) -> Optional[Entity]:
|
||||
"""Get entity by external UUID.
|
||||
|
||||
Args:
|
||||
external_id: External UUID identifier
|
||||
|
||||
Returns:
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
query = (
|
||||
self.select()
|
||||
.where(Entity.external_id == external_id)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
||||
"""Get entity by permalink.
|
||||
|
||||
@@ -185,18 +201,20 @@ class EntityRepository(Repository[Entity]):
|
||||
Returns:
|
||||
List of (file_path, checksum) tuples for matching entities
|
||||
"""
|
||||
if not file_paths:
|
||||
return []
|
||||
if not file_paths: # pragma: no cover
|
||||
return [] # pragma: no cover
|
||||
|
||||
# Convert all paths to POSIX strings for consistent comparison
|
||||
posix_paths = [Path(fp).as_posix() for fp in file_paths]
|
||||
posix_paths = [Path(fp).as_posix() for fp in file_paths] # pragma: no cover
|
||||
|
||||
# Query ONLY file_path and checksum columns (not full Entity objects)
|
||||
query = select(Entity.file_path, Entity.checksum).where(Entity.file_path.in_(posix_paths))
|
||||
query = self._add_project_filter(query)
|
||||
query = select(Entity.file_path, Entity.checksum).where( # pragma: no cover
|
||||
Entity.file_path.in_(posix_paths)
|
||||
)
|
||||
query = self._add_project_filter(query) # pragma: no cover
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.all())
|
||||
result = await session.execute(query) # pragma: no cover
|
||||
return list(result.all()) # pragma: no cover
|
||||
|
||||
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
|
||||
"""Find entities with the given checksum.
|
||||
@@ -234,14 +252,14 @@ class EntityRepository(Repository[Entity]):
|
||||
Sequence of entities with matching checksums (may be empty).
|
||||
Multiple entities may have the same checksum if files were copied.
|
||||
"""
|
||||
if not checksums:
|
||||
return []
|
||||
if not checksums: # pragma: no cover
|
||||
return [] # pragma: no cover
|
||||
|
||||
# Query: SELECT * FROM entities WHERE checksum IN (checksum1, checksum2, ...)
|
||||
query = self.select().where(Entity.checksum.in_(checksums))
|
||||
query = self.select().where(Entity.checksum.in_(checksums)) # pragma: no cover
|
||||
# Don't load relationships for move detection - we only need file_path and checksum
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return list(result.scalars().all())
|
||||
result = await self.execute_query(query, use_query_options=False) # pragma: no cover
|
||||
return list(result.scalars().all()) # pragma: no cover
|
||||
|
||||
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
|
||||
"""Delete entity with the provided file_path.
|
||||
@@ -480,7 +498,7 @@ class EntityRepository(Repository[Entity]):
|
||||
session.add(entity)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as e:
|
||||
except IntegrityError as e: # pragma: no cover
|
||||
# Check if this is a FOREIGN KEY constraint failure
|
||||
# SQLite: "FOREIGN KEY constraint failed"
|
||||
# Postgres: "violates foreign key constraint"
|
||||
@@ -493,11 +511,11 @@ class EntityRepository(Repository[Entity]):
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
|
||||
# Project doesn't exist in database - this is a fatal sync error
|
||||
raise SyncFatalError(
|
||||
raise SyncFatalError( # pragma: no cover
|
||||
f"Cannot sync file '{entity.file_path}': "
|
||||
f"project_id={entity.project_id} does not exist in database. "
|
||||
f"The project may have been deleted. This sync will be terminated."
|
||||
) from e
|
||||
# Re-raise if not a foreign key error
|
||||
raise
|
||||
raise # pragma: no cover
|
||||
return entity
|
||||
|
||||
@@ -24,6 +24,11 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
- GIN indexes for performance
|
||||
- ts_rank() function for relevance scoring
|
||||
- JSONB containment operators for metadata search
|
||||
|
||||
Note: This implementation uses UPSERT patterns (INSERT ... ON CONFLICT) instead of
|
||||
delete-then-insert to handle race conditions during parallel entity indexing.
|
||||
The partial unique index uix_search_index_permalink_project prevents duplicate
|
||||
permalinks per project.
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
@@ -41,6 +46,63 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
|
||||
pass
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index or update a single item using UPSERT.
|
||||
|
||||
Uses INSERT ... ON CONFLICT to handle race conditions during parallel
|
||||
entity indexing. The partial unique index uix_search_index_permalink_project
|
||||
on (permalink, project_id) WHERE permalink IS NOT NULL prevents duplicate
|
||||
permalinks.
|
||||
|
||||
For rows with non-null permalinks (entities), conflicts are resolved by
|
||||
updating the existing row. For rows with null permalinks, no conflict
|
||||
occurs on this index.
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Serialize JSON for raw SQL
|
||||
insert_data = search_index_row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
|
||||
# Use upsert to handle race conditions during parallel indexing
|
||||
# ON CONFLICT (permalink, project_id) matches the partial unique index
|
||||
# uix_search_index_permalink_project WHERE permalink IS NOT NULL
|
||||
# For rows with NULL permalinks, no conflict occurs (partial index doesn't apply)
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
ON CONFLICT (permalink, project_id) WHERE permalink IS NOT NULL DO UPDATE SET
|
||||
id = EXCLUDED.id,
|
||||
title = EXCLUDED.title,
|
||||
content_stems = EXCLUDED.content_stems,
|
||||
content_snippet = EXCLUDED.content_snippet,
|
||||
file_path = EXCLUDED.file_path,
|
||||
type = EXCLUDED.type,
|
||||
metadata = EXCLUDED.metadata,
|
||||
from_id = EXCLUDED.from_id,
|
||||
to_id = EXCLUDED.to_id,
|
||||
relation_type = EXCLUDED.relation_type,
|
||||
entity_id = EXCLUDED.entity_id,
|
||||
category = EXCLUDED.category,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
"""),
|
||||
insert_data,
|
||||
)
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for tsquery format.
|
||||
|
||||
@@ -139,8 +201,6 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
|
||||
# Single word
|
||||
cleaned_term = cleaned_term.strip()
|
||||
if not cleaned_term:
|
||||
return "NOSPECIALCHARS:*"
|
||||
if is_prefix:
|
||||
return f"{cleaned_term}:*"
|
||||
else:
|
||||
@@ -269,15 +329,23 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle tsquery syntax errors
|
||||
if "tsquery" in str(e).lower() or "syntax error" in str(e).lower(): # pragma: no cover
|
||||
# Handle tsquery syntax errors (and only those).
|
||||
#
|
||||
# Important: Postgres errors for other failures (e.g. missing table) will still mention
|
||||
# `to_tsquery(...)` in the SQL text, so checking for the substring "tsquery" is too broad.
|
||||
msg = str(e).lower()
|
||||
if (
|
||||
"syntax error in tsquery" in msg
|
||||
or "invalid input syntax for type tsquery" in msg
|
||||
or "no operand in tsquery" in msg
|
||||
or "no operator in tsquery" in msg
|
||||
):
|
||||
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
else:
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
@@ -316,10 +384,14 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a single batch operation using UPSERT.
|
||||
|
||||
Uses INSERT ... ON CONFLICT DO UPDATE to handle re-indexing of existing
|
||||
entities (e.g., during forward reference resolution) without requiring
|
||||
a separate delete operation. This eliminates race conditions between
|
||||
delete and insert operations in separate transactions.
|
||||
Uses INSERT ... ON CONFLICT to handle race conditions during parallel
|
||||
entity indexing. The partial unique index uix_search_index_permalink_project
|
||||
on (permalink, project_id) WHERE permalink IS NOT NULL prevents duplicate
|
||||
permalinks.
|
||||
|
||||
For rows with non-null permalinks (entities), conflicts are resolved by
|
||||
updating the existing row. For rows with null permalinks (observations,
|
||||
relations), the partial index doesn't apply and they are inserted directly.
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
@@ -338,11 +410,10 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Use UPSERT (INSERT ... ON CONFLICT) to handle re-indexing
|
||||
# Primary key is (id, type, project_id)
|
||||
# This handles race conditions during forward reference resolution
|
||||
# where an entity might be re-indexed before the delete commits
|
||||
# Syntax works for both SQLite 3.24+ and PostgreSQL
|
||||
# Use upsert to handle race conditions during parallel indexing
|
||||
# ON CONFLICT (permalink, project_id) matches the partial unique index
|
||||
# uix_search_index_permalink_project WHERE permalink IS NOT NULL
|
||||
# For rows with NULL permalinks (observations, relations), no conflict occurs
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
@@ -358,12 +429,13 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
ON CONFLICT (id, type, project_id) DO UPDATE SET
|
||||
ON CONFLICT (permalink, project_id) WHERE permalink IS NOT NULL DO UPDATE SET
|
||||
id = EXCLUDED.id,
|
||||
title = EXCLUDED.title,
|
||||
content_stems = EXCLUDED.content_stems,
|
||||
content_snippet = EXCLUDED.content_snippet,
|
||||
permalink = EXCLUDED.permalink,
|
||||
file_path = EXCLUDED.file_path,
|
||||
type = EXCLUDED.type,
|
||||
metadata = EXCLUDED.metadata,
|
||||
from_id = EXCLUDED.from_id,
|
||||
to_id = EXCLUDED.to_id,
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProjectRepository(Repository[Project]):
|
||||
super().__init__(session_maker, Project)
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[Project]:
|
||||
"""Get project by name.
|
||||
"""Get project by name (exact match).
|
||||
|
||||
Args:
|
||||
name: Unique name of the project
|
||||
@@ -32,6 +32,18 @@ class ProjectRepository(Repository[Project]):
|
||||
query = self.select().where(Project.name == name)
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_name_case_insensitive(self, name: str) -> Optional[Project]:
|
||||
"""Get project by name (case-insensitive match).
|
||||
|
||||
Args:
|
||||
name: Project name (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Project if found, None otherwise
|
||||
"""
|
||||
query = self.select().where(Project.name.ilike(name))
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_permalink(self, permalink: str) -> Optional[Project]:
|
||||
"""Get project by permalink.
|
||||
|
||||
@@ -62,6 +74,18 @@ class ProjectRepository(Repository[Project]):
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, project_id)
|
||||
|
||||
async def get_by_external_id(self, external_id: str) -> Optional[Project]:
|
||||
"""Get project by external UUID.
|
||||
|
||||
Args:
|
||||
external_id: External UUID identifier
|
||||
|
||||
Returns:
|
||||
Project if found, None otherwise
|
||||
"""
|
||||
query = self.select().where(Project.external_id == external_id)
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_default_project(self) -> Optional[Project]:
|
||||
"""Get the default project (the one marked as is_default=True)."""
|
||||
query = self.select().where(Project.is_default.is_not(None))
|
||||
|
||||
@@ -124,17 +124,17 @@ class RelationRepository(Repository[Relation]):
|
||||
# Check dialect to use appropriate insert
|
||||
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
|
||||
|
||||
if dialect_name == "postgresql":
|
||||
if dialect_name == "postgresql": # pragma: no cover
|
||||
# PostgreSQL: use RETURNING to count inserted rows
|
||||
# (rowcount is 0 for ON CONFLICT DO NOTHING)
|
||||
stmt = (
|
||||
stmt = ( # pragma: no cover
|
||||
pg_insert(Relation)
|
||||
.values(values)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(Relation.id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return len(result.fetchall())
|
||||
result = await session.execute(stmt) # pragma: no cover
|
||||
return len(result.fetchall()) # pragma: no cover
|
||||
else:
|
||||
# SQLite: rowcount works correctly
|
||||
stmt = sqlite_insert(Relation).values(values)
|
||||
|
||||
@@ -81,8 +81,8 @@ def create_search_repository(
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.database_backend == DatabaseBackend.POSTGRES:
|
||||
return PostgresSearchRepository(session_maker, project_id=project_id)
|
||||
if config.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
|
||||
return PostgresSearchRepository(session_maker, project_id=project_id) # pragma: no cover
|
||||
else:
|
||||
return SQLiteSearchRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
@@ -158,8 +158,8 @@ class SearchRepositoryBase(ABC):
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
|
||||
if not search_index_rows:
|
||||
return
|
||||
if not search_index_rows: # pragma: no cover
|
||||
return # pragma: no cover
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
|
||||
@@ -242,7 +242,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
term = f'"{escaped_term}"' # pragma: no cover
|
||||
else:
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
|
||||
@@ -108,7 +108,7 @@ def parse_timeframe(timeframe: str) -> datetime:
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.astimezone()
|
||||
else:
|
||||
parsed = parsed
|
||||
parsed = parsed # pragma: no cover
|
||||
|
||||
# Enforce minimum 1-day lookback to handle timezone differences
|
||||
# This ensures we don't miss recent activity due to client/server timezone mismatches
|
||||
@@ -138,7 +138,7 @@ def validate_timeframe(timeframe: str) -> str:
|
||||
# Convert to duration
|
||||
now = datetime.now().astimezone()
|
||||
if parsed > now:
|
||||
raise ValueError("Timeframe cannot be in the future")
|
||||
raise ValueError("Timeframe cannot be in the future") # pragma: no cover
|
||||
|
||||
# Could format the duration back to our standard format
|
||||
days = (now - parsed).days
|
||||
@@ -183,7 +183,7 @@ ObservationStr = Annotated[
|
||||
str,
|
||||
BeforeValidator(str.strip), # Clean whitespace
|
||||
MinLen(1), # Ensure non-empty after stripping
|
||||
MaxLen(1000), # Keep reasonable length
|
||||
# No MaxLen - matches DB Text column which has no length restriction
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ class DirectoryNode(BaseModel):
|
||||
children: List["DirectoryNode"] = [] # Default to empty list
|
||||
title: Optional[str] = None
|
||||
permalink: Optional[str] = None
|
||||
entity_id: Optional[int] = None
|
||||
external_id: Optional[str] = None # UUID (primary API identifier for v2)
|
||||
entity_id: Optional[int] = None # Internal numeric ID
|
||||
entity_type: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -262,7 +262,7 @@ class ProjectActivity(BaseModel):
|
||||
|
||||
@field_serializer("last_activity")
|
||||
def serialize_last_activity(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt else None
|
||||
return dt.isoformat() if dt else None # pragma: no cover
|
||||
|
||||
|
||||
class ProjectActivitySummary(BaseModel):
|
||||
@@ -282,4 +282,4 @@ class ProjectActivitySummary(BaseModel):
|
||||
|
||||
@field_serializer("generated_at")
|
||||
def serialize_generated_at(self, dt: datetime) -> str:
|
||||
return dt.isoformat()
|
||||
return dt.isoformat() # pragma: no cover
|
||||
|
||||
@@ -174,6 +174,7 @@ class ProjectItem(BaseModel):
|
||||
"""Simple representation of a project."""
|
||||
|
||||
id: int
|
||||
external_id: str # UUID string for API references (required after migration)
|
||||
name: str
|
||||
path: str
|
||||
is_default: bool = False
|
||||
|
||||
@@ -14,7 +14,7 @@ Key Features:
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
|
||||
|
||||
@@ -64,32 +64,89 @@ class RelationResponse(Relation, SQLAlchemyModel):
|
||||
|
||||
permalink: Permalink
|
||||
|
||||
from_id: Permalink = Field(
|
||||
# use the permalink from the associated Entity
|
||||
# or the from_id value
|
||||
validation_alias=AliasChoices(
|
||||
AliasPath("from_entity", "permalink"),
|
||||
"from_id",
|
||||
)
|
||||
)
|
||||
to_id: Optional[Permalink] = Field( # pyright: ignore
|
||||
# use the permalink from the associated Entity
|
||||
# or the to_id value
|
||||
validation_alias=AliasChoices(
|
||||
AliasPath("to_entity", "permalink"),
|
||||
"to_id",
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
to_name: Optional[Permalink] = Field(
|
||||
# use the permalink from the associated Entity
|
||||
# or the to_id value
|
||||
validation_alias=AliasChoices(
|
||||
AliasPath("to_entity", "title"),
|
||||
"to_name",
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
# Override base Relation fields to allow Optional values
|
||||
from_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
to_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
to_name: Optional[str] = Field(default=None)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_entity_references(cls, data):
|
||||
"""Resolve from_id and to_id from joined entities, falling back to file_path.
|
||||
|
||||
When loading from SQLAlchemy models, the from_entity and to_entity relationships
|
||||
are joined. We extract the permalink from these entities, falling back to
|
||||
file_path when permalink is None.
|
||||
|
||||
We use file_path directly (not converted to permalink format) because if the
|
||||
entity doesn't have a permalink, the system won't be able to find it by a
|
||||
generated one anyway. Using the actual file_path preserves the real identifier.
|
||||
"""
|
||||
# Handle dict input (e.g., from API or tests)
|
||||
if isinstance(data, dict):
|
||||
from_entity = data.get("from_entity")
|
||||
to_entity = data.get("to_entity")
|
||||
|
||||
# Resolve from_id: prefer permalink, fall back to file_path
|
||||
if from_entity and isinstance(from_entity, dict):
|
||||
permalink = from_entity.get("permalink")
|
||||
if permalink:
|
||||
data["from_id"] = permalink
|
||||
elif from_entity.get("file_path"):
|
||||
data["from_id"] = from_entity["file_path"]
|
||||
|
||||
# Resolve to_id: prefer permalink, fall back to file_path
|
||||
if to_entity and isinstance(to_entity, dict):
|
||||
permalink = to_entity.get("permalink")
|
||||
if permalink:
|
||||
data["to_id"] = permalink
|
||||
elif to_entity.get("file_path"):
|
||||
data["to_id"] = to_entity["file_path"]
|
||||
|
||||
# Also resolve to_name from entity title
|
||||
if to_entity.get("title") and not data.get("to_name"):
|
||||
data["to_name"] = to_entity["title"]
|
||||
|
||||
return data
|
||||
|
||||
# Handle SQLAlchemy model input (from_attributes=True)
|
||||
# Access attributes directly from the ORM model
|
||||
from_entity = getattr(data, "from_entity", None)
|
||||
to_entity = getattr(data, "to_entity", None)
|
||||
|
||||
# Build a dict from the model's attributes
|
||||
result = {}
|
||||
|
||||
# Copy base fields
|
||||
for field in ["permalink", "relation_type", "context", "to_name"]:
|
||||
if hasattr(data, field):
|
||||
result[field] = getattr(data, field)
|
||||
|
||||
# Resolve from_id: prefer permalink, fall back to file_path
|
||||
if from_entity:
|
||||
permalink = getattr(from_entity, "permalink", None)
|
||||
file_path = getattr(from_entity, "file_path", None)
|
||||
if permalink:
|
||||
result["from_id"] = permalink
|
||||
elif file_path:
|
||||
result["from_id"] = file_path
|
||||
|
||||
# Resolve to_id: prefer permalink, fall back to file_path
|
||||
if to_entity:
|
||||
permalink = getattr(to_entity, "permalink", None)
|
||||
file_path = getattr(to_entity, "file_path", None)
|
||||
if permalink:
|
||||
result["to_id"] = permalink
|
||||
elif file_path:
|
||||
result["to_id"] = file_path
|
||||
|
||||
# Also resolve to_name from entity title if not set
|
||||
if not result.get("to_name"):
|
||||
title = getattr(to_entity, "title", None)
|
||||
if title:
|
||||
result["to_name"] = title
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class EntityResponse(SQLAlchemyModel):
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Dict, List, Set
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# avoid cirular imports
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""V2 API schemas - ID-based entity references."""
|
||||
"""V2 API schemas - ID-based entity and project references."""
|
||||
|
||||
from basic_memory.schemas.v2.entity import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
ProjectResolveRequest,
|
||||
ProjectResolveResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
@@ -17,6 +19,8 @@ __all__ = [
|
||||
"EntityResolveResponse",
|
||||
"EntityResponseV2",
|
||||
"MoveEntityRequestV2",
|
||||
"ProjectResolveRequest",
|
||||
"ProjectResolveResponse",
|
||||
"CreateResourceRequest",
|
||||
"UpdateResourceRequest",
|
||||
"ResourceResponse",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""V2 entity schemas with ID-first design."""
|
||||
"""V2 entity and project schemas with ID-first design."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
@@ -31,11 +31,12 @@ class EntityResolveResponse(BaseModel):
|
||||
Returns the entity ID and associated metadata for the resolved entity.
|
||||
"""
|
||||
|
||||
entity_id: int = Field(..., description="Numeric entity ID (primary identifier)")
|
||||
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
||||
entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
|
||||
permalink: Optional[str] = Field(None, description="Entity permalink")
|
||||
file_path: str = Field(..., description="Relative file path")
|
||||
title: str = Field(..., description="Entity title")
|
||||
resolution_method: Literal["id", "permalink", "title", "path", "search"] = Field(
|
||||
resolution_method: Literal["external_id", "permalink", "title", "path", "search"] = Field(
|
||||
..., description="How the identifier was resolved"
|
||||
)
|
||||
|
||||
@@ -56,14 +57,16 @@ class MoveEntityRequestV2(BaseModel):
|
||||
|
||||
|
||||
class EntityResponseV2(BaseModel):
|
||||
"""V2 entity response with ID as the primary field.
|
||||
"""V2 entity response with external_id as the primary API identifier.
|
||||
|
||||
This response format emphasizes the entity ID as the primary identifier,
|
||||
with all other fields (permalink, file_path) as secondary metadata.
|
||||
This response format emphasizes the external_id (UUID) as the primary API identifier,
|
||||
with the numeric id maintained for internal reference.
|
||||
"""
|
||||
|
||||
# ID first - this is the primary identifier in v2
|
||||
id: int = Field(..., description="Numeric entity ID (primary identifier)")
|
||||
# External UUID first - this is the primary API identifier in v2
|
||||
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
||||
# Internal numeric ID
|
||||
id: int = Field(..., description="Numeric entity ID (internal identifier)")
|
||||
|
||||
# Core entity fields
|
||||
title: str = Field(..., description="Entity title")
|
||||
@@ -94,3 +97,37 @@ class EntityResponseV2(BaseModel):
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProjectResolveRequest(BaseModel):
|
||||
"""Request to resolve a project identifier to a project ID.
|
||||
|
||||
Supports resolution of:
|
||||
- Project names (e.g., "my-project")
|
||||
- Permalinks (e.g., "my-project")
|
||||
"""
|
||||
|
||||
identifier: str = Field(
|
||||
...,
|
||||
description="Project identifier to resolve (name or permalink)",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
|
||||
class ProjectResolveResponse(BaseModel):
|
||||
"""Response from project identifier resolution.
|
||||
|
||||
Returns the project ID and associated metadata for the resolved project.
|
||||
"""
|
||||
|
||||
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
||||
project_id: int = Field(..., description="Numeric project ID (internal identifier)")
|
||||
name: str = Field(..., description="Project name")
|
||||
permalink: str = Field(..., description="Project permalink")
|
||||
path: str = Field(..., description="Project file path")
|
||||
is_active: bool = Field(..., description="Whether the project is active")
|
||||
is_default: bool = Field(..., description="Whether the project is the default")
|
||||
resolution_method: Literal["external_id", "name", "permalink"] = Field(
|
||||
..., description="How the identifier was resolved"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,8 @@ class UpdateResourceRequest(BaseModel):
|
||||
class ResourceResponse(BaseModel):
|
||||
"""Response from resource operations."""
|
||||
|
||||
entity_id: int = Field(..., description="Entity ID of the resource")
|
||||
entity_id: int = Field(..., description="Internal entity ID of the resource")
|
||||
external_id: str = Field(..., description="External UUID of the resource for API references")
|
||||
file_path: str = Field(..., description="File path of the resource")
|
||||
checksum: str = Field(..., description="File content checksum")
|
||||
size: int = Field(..., description="File size in bytes")
|
||||
|
||||
@@ -264,11 +264,11 @@ class ContextService:
|
||||
# Build date and timeframe filters conditionally based on since parameter
|
||||
if since:
|
||||
# SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
|
||||
if isinstance(self.search_repository, PostgresSearchRepository):
|
||||
if isinstance(self.search_repository, PostgresSearchRepository): # pragma: no cover
|
||||
# asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
|
||||
# even though the column stores timezone-aware values
|
||||
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since
|
||||
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore
|
||||
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since # pragma: no cover
|
||||
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
|
||||
else:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
date_filter = "AND e.created_at >= :since_date"
|
||||
@@ -293,7 +293,7 @@ class ContextService:
|
||||
# Detect database backend
|
||||
is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
|
||||
|
||||
if is_postgres:
|
||||
if is_postgres: # pragma: no cover
|
||||
query = self._build_postgres_query(
|
||||
entity_id_values,
|
||||
date_filter,
|
||||
@@ -339,7 +339,7 @@ class ContextService:
|
||||
]
|
||||
return context_rows
|
||||
|
||||
def _build_postgres_query(
|
||||
def _build_postgres_query( # pragma: no cover
|
||||
self,
|
||||
entity_id_values: str,
|
||||
date_filter: str,
|
||||
|
||||
@@ -20,8 +20,8 @@ def _mtime_to_datetime(entity: Entity) -> datetime:
|
||||
Returns the file's actual modification time, falling back to updated_at
|
||||
if mtime is not available.
|
||||
"""
|
||||
if entity.mtime:
|
||||
return datetime.fromtimestamp(entity.mtime).astimezone()
|
||||
if entity.mtime: # pragma: no cover
|
||||
return datetime.fromtimestamp(entity.mtime).astimezone() # pragma: no cover
|
||||
return entity.updated_at
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class DirectoryService:
|
||||
type="file",
|
||||
title=file.title,
|
||||
permalink=file.permalink,
|
||||
external_id=file.external_id, # UUID for v2 API
|
||||
entity_id=file.id,
|
||||
entity_type=file.entity_type,
|
||||
content_type=file.content_type,
|
||||
@@ -186,7 +187,7 @@ class DirectoryService:
|
||||
# Find the target directory node
|
||||
target_node = self._find_directory_node(root_tree, dir_name)
|
||||
if not target_node:
|
||||
return []
|
||||
return [] # pragma: no cover
|
||||
|
||||
# Collect nodes with depth and glob filtering
|
||||
result = []
|
||||
@@ -251,6 +252,7 @@ class DirectoryService:
|
||||
type="file",
|
||||
title=file.title,
|
||||
permalink=file.permalink,
|
||||
external_id=file.external_id, # UUID for v2 API
|
||||
entity_id=file.id,
|
||||
entity_type=file.entity_type,
|
||||
content_type=file.content_type,
|
||||
@@ -260,9 +262,9 @@ class DirectoryService:
|
||||
# Add to parent directory's children
|
||||
if directory_path in dir_map:
|
||||
dir_map[directory_path].children.append(file_node)
|
||||
elif root_path in dir_map:
|
||||
elif root_path in dir_map: # pragma: no cover
|
||||
# Fallback to root if parent not found
|
||||
dir_map[root_path].children.append(file_node)
|
||||
dir_map[root_path].children.append(file_node) # pragma: no cover
|
||||
|
||||
return root_node
|
||||
|
||||
@@ -273,13 +275,13 @@ class DirectoryService:
|
||||
if root.directory_path == target_path:
|
||||
return root
|
||||
|
||||
for child in root.children:
|
||||
if child.type == "directory":
|
||||
found = self._find_directory_node(child, target_path)
|
||||
if found:
|
||||
return found
|
||||
for child in root.children: # pragma: no cover
|
||||
if child.type == "directory": # pragma: no cover
|
||||
found = self._find_directory_node(child, target_path) # pragma: no cover
|
||||
if found: # pragma: no cover
|
||||
return found # pragma: no cover
|
||||
|
||||
return None
|
||||
return None # pragma: no cover
|
||||
|
||||
def _collect_nodes_recursive(
|
||||
self,
|
||||
|
||||
@@ -29,6 +29,7 @@ from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.services import BaseService, FileService
|
||||
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -43,6 +44,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
relation_repository: RelationRepository,
|
||||
file_service: FileService,
|
||||
link_resolver: LinkResolver,
|
||||
search_service: Optional[SearchService] = None,
|
||||
app_config: Optional[BasicMemoryConfig] = None,
|
||||
):
|
||||
super().__init__(entity_repository)
|
||||
@@ -51,6 +53,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
self.entity_parser = entity_parser
|
||||
self.file_service = file_service
|
||||
self.link_resolver = link_resolver
|
||||
self.search_service = search_service
|
||||
self.app_config = app_config
|
||||
|
||||
async def detect_file_path_conflicts(
|
||||
@@ -354,7 +357,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
)
|
||||
entity = entities[0]
|
||||
|
||||
# Delete file first
|
||||
# Delete from search index first (if search_service is available)
|
||||
if self.search_service:
|
||||
await self.search_service.handle_delete(entity)
|
||||
|
||||
# Delete file
|
||||
await self.file_service.delete_entity_file(entity)
|
||||
|
||||
# Delete from DB (this will cascade to observations/relations)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user